Persist QA feature evidence
This commit is contained in:
@@ -22,23 +22,30 @@ class FakeSession:
|
||||
return None
|
||||
|
||||
|
||||
def _feature(feature_id: str, coordinates: list[list[list[float]]]) -> dict:
|
||||
return {
|
||||
"type": "Feature",
|
||||
"id": feature_id,
|
||||
"properties": {"source_feature_id": feature_id},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": coordinates,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _write_dataset(path: Path, coordinates: list[list[list[float]]]) -> None:
|
||||
payload = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": coordinates,
|
||||
},
|
||||
},
|
||||
],
|
||||
"features": [_feature("feature-1", coordinates)],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_features(path: Path, features: list[dict]) -> None:
|
||||
path.write_text(json.dumps({"type": "FeatureCollection", "features": features}), encoding="utf-8")
|
||||
|
||||
|
||||
def test_qa_compare_candidate_with_reference_returns_metrics(tmp_path) -> None:
|
||||
project_id = uuid4()
|
||||
candidate_id = uuid4()
|
||||
@@ -87,6 +94,62 @@ def test_qa_compare_candidate_with_reference_returns_metrics(tmp_path) -> None:
|
||||
assert result.f1_score == 1.0
|
||||
|
||||
|
||||
def test_qa_compare_candidate_with_reference_returns_feature_level_evidence(tmp_path) -> None:
|
||||
project_id = uuid4()
|
||||
candidate_id = uuid4()
|
||||
reference_id = uuid4()
|
||||
candidate_path = tmp_path / "candidate.geojson"
|
||||
reference_path = tmp_path / "reference.geojson"
|
||||
matched_candidate = [[[4.0, 51.0], [4.1, 51.0], [4.1, 51.1], [4.0, 51.1], [4.0, 51.0]]]
|
||||
matched_reference = [[[4.0, 51.0], [4.1, 51.0], [4.1, 51.1], [4.0, 51.1], [4.0, 51.0]]]
|
||||
false_positive = [[[4.5, 51.5], [4.6, 51.5], [4.6, 51.6], [4.5, 51.6], [4.5, 51.5]]]
|
||||
false_negative = [[[4.8, 51.8], [4.9, 51.8], [4.9, 51.9], [4.8, 51.9], [4.8, 51.8]]]
|
||||
_write_features(candidate_path, [_feature("candidate-match", matched_candidate), _feature("candidate-extra", false_positive)])
|
||||
_write_features(reference_path, [_feature("reference-match", matched_reference), _feature("reference-missing", false_negative)])
|
||||
|
||||
candidate = Dataset(
|
||||
id=candidate_id,
|
||||
project_id=project_id,
|
||||
name="candidate.geojson",
|
||||
dataset_type="vector",
|
||||
source="test",
|
||||
storage_path=str(candidate_path),
|
||||
crs="EPSG:4326",
|
||||
metadata_json={"crs_assumed": False},
|
||||
)
|
||||
reference = Dataset(
|
||||
id=reference_id,
|
||||
project_id=project_id,
|
||||
name="reference.geojson",
|
||||
dataset_type="vector",
|
||||
source="test",
|
||||
storage_path=str(reference_path),
|
||||
crs="EPSG:4326",
|
||||
metadata_json={"crs_assumed": False},
|
||||
)
|
||||
|
||||
result = QaService.compare_candidate_with_reference(
|
||||
db=FakeSession([candidate, reference]),
|
||||
project_id=project_id,
|
||||
candidate_dataset_id=candidate_id,
|
||||
reference_dataset_id=reference_id,
|
||||
iou_threshold=0.5,
|
||||
)
|
||||
|
||||
assert result.matches == 1
|
||||
assert result.false_positives == 1
|
||||
assert result.false_negatives == 1
|
||||
assert result.match_evidence == [
|
||||
{
|
||||
"candidate_feature_id": "candidate-match",
|
||||
"reference_feature_id": "reference-match",
|
||||
"iou": 1.0,
|
||||
}
|
||||
]
|
||||
assert result.false_positive_evidence == [{"candidate_feature_id": "candidate-extra"}]
|
||||
assert result.false_negative_evidence == [{"reference_feature_id": "reference-missing"}]
|
||||
|
||||
|
||||
def test_dataset_reference_metadata_migration_declares_required_columns() -> None:
|
||||
migration_path = Path(__file__).parents[1] / "alembic" / "versions" / "202606120001_add_dataset_reference_metadata.py"
|
||||
migration_text = migration_path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def read_text(relative_path: str) -> str:
|
||||
return (ROOT / relative_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_qa_feature_evidence_contract_is_documented_and_rendered() -> None:
|
||||
schema = read_text("backend/app/schemas/qa.py")
|
||||
quality_panel = read_text("frontend/src/components/quality/QualityResultsPanel.tsx")
|
||||
api_contracts = read_text("docs/API_CONTRACTS.md")
|
||||
|
||||
for field_name in ("match_evidence", "false_positive_evidence", "false_negative_evidence"):
|
||||
assert field_name in schema
|
||||
assert field_name in quality_panel
|
||||
assert field_name in api_contracts
|
||||
|
||||
assert "Feature-level QA/QC evidence" in quality_panel
|
||||
assert "Matched feature ids" in quality_panel
|
||||
assert "False positive feature ids" in quality_panel
|
||||
assert "False negative feature ids" in quality_panel
|
||||
assert "evidenceLabel" in quality_panel
|
||||
|
||||
|
||||
def test_qa_services_persist_feature_evidence_without_new_migrations() -> None:
|
||||
qa_route = read_text("backend/app/api/routes/qa.py")
|
||||
detection_service = read_text("backend/app/services/detection_service.py")
|
||||
segmentation_service = read_text("backend/app/services/segmentation_service.py")
|
||||
migrations = "\n".join(path.name for path in (ROOT / "backend" / "alembic" / "versions").glob("*.py"))
|
||||
|
||||
for field_name in ("match_evidence", "false_positive_evidence", "false_negative_evidence"):
|
||||
assert field_name in qa_route
|
||||
assert field_name in detection_service
|
||||
assert field_name in segmentation_service
|
||||
|
||||
assert "quality_check_items" not in migrations
|
||||
@@ -267,6 +267,15 @@ def test_qa_route_persists_quality_check_domain_record(monkeypatch) -> None:
|
||||
"mean_iou": 1.0,
|
||||
"iou_threshold": 0.5,
|
||||
"warnings": [],
|
||||
"match_evidence": [
|
||||
{
|
||||
"candidate_feature_id": "candidate-1",
|
||||
"reference_feature_id": "reference-1",
|
||||
"iou": 1.0,
|
||||
}
|
||||
],
|
||||
"false_positive_evidence": [{"candidate_feature_id": "candidate-extra"}],
|
||||
"false_negative_evidence": [{"reference_feature_id": "reference-missing"}],
|
||||
}
|
||||
},
|
||||
)(),
|
||||
@@ -287,6 +296,9 @@ def test_qa_route_persists_quality_check_domain_record(monkeypatch) -> None:
|
||||
assert persisted_quality_checks[0].job_id == job_id
|
||||
assert persisted_quality_checks[0].candidate_dataset_id == candidate_dataset_id
|
||||
assert persisted_quality_checks[0].reference_dataset_id == reference_dataset_id
|
||||
assert persisted_quality_checks[0].findings_json["match_evidence"][0]["candidate_feature_id"] == "candidate-1"
|
||||
assert persisted_quality_checks[0].findings_json["false_positive_evidence"][0]["candidate_feature_id"] == "candidate-extra"
|
||||
assert persisted_quality_checks[0].findings_json["false_negative_evidence"][0]["reference_feature_id"] == "reference-missing"
|
||||
assert [metric.metric_key for metric in persisted_metrics] == [
|
||||
"precision",
|
||||
"recall",
|
||||
|
||||
Reference in New Issue
Block a user