From b9674a0e42d60429aa9990afd9774638eab12ba5 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 25 Jun 2026 03:17:49 +0200 Subject: [PATCH] Add QA evidence map overlay --- CHANGELOG.md | 8 + backend/app/api/routes/quality_checks.py | 10 + .../app/services/quality_evidence_service.py | 203 ++++++++++++++++++ .../test_sprint112_qa_evidence_overlay.py | 161 ++++++++++++++ docs/API_CONTRACTS.md | 52 +++++ docs/CODEX_EXECUTION_LOG.md | 36 ++++ docs/TODO.md | 1 + frontend/README.md | 1 + frontend/src/App.tsx | 21 ++ frontend/src/components/GeoMap.tsx | 83 ++++++- frontend/src/components/map/MapWorkspace.tsx | 46 ++++ .../quality/QualityResultsPanel.tsx | 36 ++++ frontend/src/hooks/useQualityWorkflow.ts | 34 ++- frontend/src/services/api/qa.ts | 4 +- frontend/src/styles/app.css | 72 ++++++- frontend/src/types.ts | 14 ++ 16 files changed, 778 insertions(+), 4 deletions(-) create mode 100644 backend/app/services/quality_evidence_service.py create mode 100644 backend/tests/test_sprint112_qa_evidence_overlay.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d940725..af6bb040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 112 QA evidence map overlay (2026-06-25) + +- Added a read-only QA/QC evidence GeoJSON endpoint for persisted quality checks. +- The endpoint resolves `match_evidence`, `false_positive_evidence` and `false_negative_evidence` ids back to persisted vector, detection or segmentation geometries where available. +- Added QA/QC actions to render evidence overlays in the existing MapLibre workspace with distinct match, false-positive and false-negative styling. +- Added frontend loading/error/clear states for the QA evidence overlay and a compact map legend. +- No migration, new table, provider fetching, AI behavior or new product domain was introduced. + ## Sprint 111 QA feature evidence persistence (2026-06-25) - Added feature-level QA evidence to dataset, detection and segmentation QA matching. diff --git a/backend/app/api/routes/quality_checks.py b/backend/app/api/routes/quality_checks.py index 28e7506e..c1b64c71 100644 --- a/backend/app/api/routes/quality_checks.py +++ b/backend/app/api/routes/quality_checks.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from app.db.session import get_db from app.schemas.qa import QualityCheckList +from app.services.quality_evidence_service import QualityEvidenceService from app.services.quality_check_service import QualityCheckService from app.utils.response import envelope @@ -27,3 +28,12 @@ def list_quality_checks( offset=offset, ) return envelope(QualityCheckList(items=items, total=total, limit=limit, offset=offset).model_dump()) + + +@router.get("/quality-checks/{quality_check_id}/evidence/geojson", response_model=dict) +def get_quality_check_evidence_geojson( + project_id: UUID, + quality_check_id: UUID, + db: Session = Depends(get_db), +) -> dict: + return envelope(QualityEvidenceService.evidence_geojson(db, project_id=project_id, quality_check_id=quality_check_id)) diff --git a/backend/app/services/quality_evidence_service.py b/backend/app/services/quality_evidence_service.py new file mode 100644 index 00000000..146d5596 --- /dev/null +++ b/backend/app/services/quality_evidence_service.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from geoalchemy2.shape import to_shape +from shapely.geometry import mapping +from sqlalchemy.orm import Session + +from app.core.errors import AppError +from app.models import Detection, QualityCheck, Segmentation, VectorFeature + + +class QualityEvidenceService: + @staticmethod + def evidence_geojson(db: Session, *, project_id: UUID, quality_check_id: UUID) -> dict[str, Any]: + quality_check = db.get(QualityCheck, quality_check_id) + if not quality_check or quality_check.project_id != project_id: + raise AppError(code="QUALITY_CHECK_NOT_FOUND", message="Quality check not found", status_code=404) + + findings = quality_check.findings_json or {} + features: list[dict[str, Any]] = [] + warnings: list[str] = [] + candidate_index = QualityEvidenceService._candidate_feature_index(db, quality_check) + reference_index = QualityEvidenceService._reference_feature_index(db, quality_check) + + for evidence in QualityEvidenceService._evidence_items(findings.get("match_evidence")): + candidate_id = QualityEvidenceService._string_value(evidence.get("candidate_feature_id")) + reference_id = QualityEvidenceService._string_value(evidence.get("reference_feature_id")) + iou = evidence.get("iou") + if candidate_id: + row = candidate_index.get(candidate_id) + if row is not None: + features.append( + QualityEvidenceService._row_to_feature( + row, + role="match_candidate", + quality_check=quality_check, + evidence=evidence, + ) + ) + else: + warnings.append(f"Candidate evidence feature not found: {candidate_id}") + if reference_id: + row = reference_index.get(reference_id) + if row is not None: + features.append( + QualityEvidenceService._row_to_feature( + row, + role="match_reference", + quality_check=quality_check, + evidence={"candidate_feature_id": candidate_id, "reference_feature_id": reference_id, "iou": iou}, + ) + ) + else: + warnings.append(f"Reference evidence feature not found: {reference_id}") + + for evidence in QualityEvidenceService._evidence_items(findings.get("false_positive_evidence")): + candidate_id = QualityEvidenceService._string_value(evidence.get("candidate_feature_id")) + if not candidate_id: + continue + row = candidate_index.get(candidate_id) + if row is not None: + features.append( + QualityEvidenceService._row_to_feature( + row, + role="false_positive", + quality_check=quality_check, + evidence=evidence, + ) + ) + else: + warnings.append(f"False-positive evidence feature not found: {candidate_id}") + + for evidence in QualityEvidenceService._evidence_items(findings.get("false_negative_evidence")): + reference_id = QualityEvidenceService._string_value(evidence.get("reference_feature_id")) + if not reference_id: + continue + row = reference_index.get(reference_id) + if row is not None: + features.append( + QualityEvidenceService._row_to_feature( + row, + role="false_negative", + quality_check=quality_check, + evidence=evidence, + ) + ) + else: + warnings.append(f"False-negative evidence feature not found: {reference_id}") + + return { + "quality_check_id": str(quality_check.id), + "project_id": str(quality_check.project_id), + "candidate_dataset_id": str(quality_check.candidate_dataset_id) if quality_check.candidate_dataset_id else None, + "reference_dataset_id": str(quality_check.reference_dataset_id), + "analysis_run_id": str(quality_check.analysis_run_id) if quality_check.analysis_run_id else None, + "feature_count": len(features), + "warnings": warnings, + "geojson": { + "type": "FeatureCollection", + "features": features, + }, + } + + @staticmethod + def _evidence_items(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + @staticmethod + def _string_value(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + @staticmethod + def _candidate_feature_index(db: Session, quality_check: QualityCheck) -> dict[str, Any]: + index: dict[str, Any] = {} + if quality_check.candidate_dataset_id: + for row in db.query(VectorFeature).filter(VectorFeature.dataset_id == quality_check.candidate_dataset_id).all(): + QualityEvidenceService._add_index_keys(index, row) + for row in db.query(Detection).filter(Detection.dataset_id == quality_check.candidate_dataset_id).all(): + QualityEvidenceService._add_index_keys(index, row) + for row in db.query(Segmentation).filter(Segmentation.dataset_id == quality_check.candidate_dataset_id).all(): + QualityEvidenceService._add_index_keys(index, row) + if quality_check.analysis_run_id: + for row in db.query(Detection).filter(Detection.analysis_run_id == quality_check.analysis_run_id).all(): + QualityEvidenceService._add_index_keys(index, row) + for row in db.query(Segmentation).filter(Segmentation.analysis_run_id == quality_check.analysis_run_id).all(): + QualityEvidenceService._add_index_keys(index, row) + elif quality_check.analysis_run_id: + for row in db.query(Detection).filter(Detection.analysis_run_id == quality_check.analysis_run_id).all(): + QualityEvidenceService._add_index_keys(index, row) + for row in db.query(Segmentation).filter(Segmentation.analysis_run_id == quality_check.analysis_run_id).all(): + QualityEvidenceService._add_index_keys(index, row) + return index + + @staticmethod + def _reference_feature_index(db: Session, quality_check: QualityCheck) -> dict[str, Any]: + index: dict[str, Any] = {} + for row in db.query(VectorFeature).filter(VectorFeature.dataset_id == quality_check.reference_dataset_id).all(): + QualityEvidenceService._add_index_keys(index, row) + return index + + @staticmethod + def _add_index_keys(index: dict[str, Any], row: Any) -> None: + for key in QualityEvidenceService._row_identifiers(row): + index.setdefault(key, row) + + @staticmethod + def _row_identifiers(row: Any) -> set[str]: + identifiers = {str(row.id)} + source_feature_id = getattr(row, "source_feature_id", None) + if source_feature_id: + identifiers.add(str(source_feature_id)) + properties = getattr(row, "properties_json", None) or {} + if isinstance(properties, dict): + for property_key in ("vector_feature_id", "source_feature_id", "detection_id", "segmentation_id", "id", "name"): + value = properties.get(property_key) + if value is not None: + identifiers.add(str(value)) + return identifiers + + @staticmethod + def _row_to_feature(row: Any, *, role: str, quality_check: QualityCheck, evidence: dict[str, Any]) -> dict[str, Any]: + try: + geometry = to_shape(row.geometry) + except Exception as exc: + raise AppError( + code="INVALID_QA_EVIDENCE_GEOMETRY", + message="Persisted QA evidence geometry could not be converted to GeoJSON", + details={"feature_id": str(getattr(row, "id", ""))}, + status_code=500, + ) from exc + + properties = dict(getattr(row, "properties_json", None) or {}) + properties.update( + { + "qa_evidence_role": role, + "quality_check_id": str(quality_check.id), + "project_id": str(quality_check.project_id), + "candidate_dataset_id": str(quality_check.candidate_dataset_id) if quality_check.candidate_dataset_id else None, + "reference_dataset_id": str(quality_check.reference_dataset_id), + "analysis_run_id": str(quality_check.analysis_run_id) if quality_check.analysis_run_id else None, + "feature_id": str(row.id), + "dataset_id": str(getattr(row, "dataset_id", "")) if getattr(row, "dataset_id", None) else None, + "source_feature_id": getattr(row, "source_feature_id", None), + "feature_class": getattr(row, "feature_class", None) or getattr(row, "class_name", None), + "candidate_feature_id": QualityEvidenceService._string_value(evidence.get("candidate_feature_id")), + "reference_feature_id": QualityEvidenceService._string_value(evidence.get("reference_feature_id")), + "iou": evidence.get("iou"), + } + ) + + return { + "type": "Feature", + "id": f"{role}:{row.id}", + "geometry": mapping(geometry), + "properties": properties, + } diff --git a/backend/tests/test_sprint112_qa_evidence_overlay.py b/backend/tests/test_sprint112_qa_evidence_overlay.py new file mode 100644 index 00000000..788bbfab --- /dev/null +++ b/backend/tests/test_sprint112_qa_evidence_overlay.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from geoalchemy2.shape import from_shape +from shapely.geometry import box + +from app.core.errors import AppError +from app.db.session import get_db +from app.main import app +from app.models import QualityCheck, VectorFeature +from app.services.quality_evidence_service import QualityEvidenceService + + +class FakeQuery: + def __init__(self, rows): + self.rows = list(rows) + + def filter(self, *criteria): + for criterion in criteria: + left = getattr(criterion, "left", None) + right = getattr(criterion, "right", None) + operator = getattr(criterion, "operator", None) + name = getattr(left, "name", None) + value = getattr(right, "value", right) + if name and operator and operator.__name__ == "eq": + self.rows = [row for row in self.rows if getattr(row, name) == value] + return self + + def all(self): + return list(self.rows) + + +class FakeSession: + def __init__(self, objects=None, query_rows=None) -> None: + self.objects = objects or {} + self.query_rows = query_rows or {} + + def get(self, model, item_id): + return self.objects.get((model, item_id)) + + def query(self, model): + return FakeQuery(self.query_rows.get(model, [])) + + +def _vector_feature(dataset_id, *, feature_id=None, source_feature_id: str, geom=None) -> VectorFeature: + return VectorFeature( + id=feature_id or uuid4(), + dataset_id=dataset_id, + source_feature_id=source_feature_id, + feature_class="building", + properties_json={"name": source_feature_id}, + geometry=from_shape(geom or box(4.0, 51.0, 4.1, 51.1), srid=4326), + ) + + +def test_quality_check_evidence_geojson_resolves_persisted_vector_features() -> None: + project_id = uuid4() + quality_check_id = uuid4() + candidate_dataset_id = uuid4() + reference_dataset_id = uuid4() + candidate_match = _vector_feature(candidate_dataset_id, source_feature_id="candidate-match") + candidate_extra = _vector_feature(candidate_dataset_id, source_feature_id="candidate-extra", geom=box(4.4, 51.4, 4.5, 51.5)) + reference_match = _vector_feature(reference_dataset_id, source_feature_id="reference-match") + reference_missing = _vector_feature(reference_dataset_id, source_feature_id="reference-missing", geom=box(4.7, 51.7, 4.8, 51.8)) + quality_check = QualityCheck( + id=quality_check_id, + project_id=project_id, + candidate_dataset_id=candidate_dataset_id, + reference_dataset_id=reference_dataset_id, + check_type="candidate_vs_reference", + status="ok", + findings_json={ + "match_evidence": [ + { + "candidate_feature_id": "candidate-match", + "reference_feature_id": "reference-match", + "iou": 1.0, + } + ], + "false_positive_evidence": [{"candidate_feature_id": "candidate-extra"}], + "false_negative_evidence": [{"reference_feature_id": "reference-missing"}], + }, + ) + db = FakeSession( + objects={(QualityCheck, quality_check_id): quality_check}, + query_rows={VectorFeature: [candidate_match, candidate_extra, reference_match, reference_missing]}, + ) + + result = QualityEvidenceService.evidence_geojson(db, project_id=project_id, quality_check_id=quality_check_id) + + assert result["quality_check_id"] == str(quality_check_id) + assert result["feature_count"] == 4 + assert result["geojson"]["type"] == "FeatureCollection" + roles = [feature["properties"]["qa_evidence_role"] for feature in result["geojson"]["features"]] + assert roles == ["match_candidate", "match_reference", "false_positive", "false_negative"] + match_candidate = result["geojson"]["features"][0] + assert match_candidate["properties"]["quality_check_id"] == str(quality_check_id) + assert match_candidate["properties"]["candidate_feature_id"] == "candidate-match" + assert match_candidate["properties"]["reference_feature_id"] == "reference-match" + assert match_candidate["properties"]["iou"] == 1.0 + assert match_candidate["properties"]["source_feature_id"] == "candidate-match" + + +def test_quality_check_evidence_geojson_rejects_cross_project_access() -> None: + quality_check_id = uuid4() + quality_check = QualityCheck( + id=quality_check_id, + project_id=uuid4(), + reference_dataset_id=uuid4(), + check_type="candidate_vs_reference", + status="ok", + findings_json={}, + ) + db = FakeSession(objects={(QualityCheck, quality_check_id): quality_check}) + + with pytest.raises(AppError) as exc: + QualityEvidenceService.evidence_geojson(db, project_id=uuid4(), quality_check_id=quality_check_id) + + assert exc.value.code == "QUALITY_CHECK_NOT_FOUND" + + +def test_quality_check_evidence_geojson_api_uses_canonical_envelope(monkeypatch) -> None: + project_id = uuid4() + quality_check_id = uuid4() + payload = { + "quality_check_id": str(quality_check_id), + "project_id": str(project_id), + "feature_count": 0, + "geojson": {"type": "FeatureCollection", "features": []}, + } + + monkeypatch.setattr( + "app.api.routes.quality_checks.QualityEvidenceService.evidence_geojson", + lambda *_args, **_kwargs: payload, + ) + app.dependency_overrides[get_db] = lambda: FakeSession() + try: + response = TestClient(app).get(f"/api/v1/projects/{project_id}/quality-checks/{quality_check_id}/evidence/geojson") + finally: + app.dependency_overrides.pop(get_db, None) + + assert response.status_code == 200 + assert response.json() == {"data": payload} + + +def test_frontend_quality_evidence_overlay_contract_is_wired() -> None: + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + geo_map = (root / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8") + map_workspace = (root / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") + qa_api = (root / "frontend" / "src" / "services" / "api" / "qa.ts").read_text(encoding="utf-8") + + assert "qaEvidenceData" in geo_map + assert "qa-evidence-fill" in geo_map + assert "qa_evidence_role" in geo_map + assert "qualityEvidenceGeoJson" in map_workspace + assert "getQualityEvidenceGeoJson" in qa_api diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 5a24c349..cdf7259d 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -1060,6 +1060,58 @@ Response: } ``` +### GET `/api/v1/projects/{project_id}/quality-checks/{quality_check_id}/evidence/geojson` + +Returns a canonical envelope containing a read-only QA/QC evidence overlay for a +persisted quality check. The endpoint reads feature ids from +`quality_checks.findings_json.match_evidence`, +`false_positive_evidence` and `false_negative_evidence`, resolves them against +persisted candidate/reference geometries and returns a GeoJSON FeatureCollection. + +Supported resolution paths: + +- dataset QA candidate/reference geometries from `vector_features`; +- detection QA candidate geometries from persisted `detections`; +- segmentation QA candidate geometries from persisted `segmentations`; +- reference geometries from persisted `vector_features`. + +Response: + +```json +{ + "data": { + "quality_check_id": "uuid", + "project_id": "uuid", + "candidate_dataset_id": "uuid-or-null", + "reference_dataset_id": "uuid", + "analysis_run_id": "uuid-or-null", + "feature_count": 4, + "warnings": [], + "geojson": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "match_candidate:feature-id", + "geometry": {}, + "properties": { + "qa_evidence_role": "match_candidate", + "quality_check_id": "uuid", + "candidate_feature_id": "candidate-feature-id", + "reference_feature_id": "reference-feature-id", + "iou": 0.83 + } + } + ] + } + } +} +``` + +`qa_evidence_role` is one of `match_candidate`, `match_reference`, +`false_positive` or `false_negative`. Missing persisted feature ids are reported +in `warnings`; no fake geometries are produced. + ## Exports ### POST `/api/v1/exports/geojson` diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 5b0a28c6..d31626dc 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,39 @@ +## Sprint 112 QA evidence map overlay (2026-06-25) + +Changed: +- Added `QualityEvidenceService` to resolve persisted QA/QC evidence ids back to stored geometries. +- Added `GET /api/v1/projects/{project_id}/quality-checks/{quality_check_id}/evidence/geojson`. +- The endpoint returns a canonical envelope with `quality_check_id`, dataset/run provenance, warnings and a GeoJSON FeatureCollection. +- Evidence resolution supports candidate dataset `vector_features`, candidate persisted `detections`/`segmentations` for analysis-run QA, and reference `vector_features`. +- Added QA/QC panel actions to show selected or latest evidence on the Map workspace. +- Added a MapLibre QA evidence source/layers with distinct match candidate, match reference, false-positive and false-negative styling. +- Added map overlay loading/error/clear state and a compact legend. +- Updated `docs/API_CONTRACTS.md`, `frontend/README.md`, `CHANGELOG.md` and `docs/TODO.md`. +- Added regression coverage in `backend/tests/test_sprint112_qa_evidence_overlay.py`. + +Validation: +- RED: `python -m pytest backend\tests\test_sprint112_qa_evidence_overlay.py -q` failed before implementation because `app.services.quality_evidence_service` did not exist. +- RED: after backend implementation, the same test failed until frontend `qaEvidenceData`/API wiring existed. +- `python -m pytest backend\tests\test_sprint112_qa_evidence_overlay.py -q` passed: 4 tests. +- `cd frontend && npm run typecheck` passed after making the MapLibre expression type explicit. +- `python -m pytest backend\tests\test_sprint112_qa_evidence_overlay.py backend\tests\test_qa_service.py backend\tests\test_sprint8c_detection_visualization_qa.py backend\tests\test_sprint9_segmentation_foundation.py -q` passed: 25 tests. +- `python -m compileall backend/app` passed. +- `cd backend && python -m pytest -q` passed: 356 tests. +- `cd frontend && npm run typecheck` passed. +- `cd frontend && npm run build` passed. +- `cd backend && python -m alembic heads` passed: `202606120900 (head)`. +- `cd backend && python -m alembic upgrade head --sql` passed. +- `bash -n scripts/live_migration_smoke.sh` passed. +- `bash scripts/run_readiness_check.sh` passed: 356 backend tests plus frontend typecheck/build. + +Limitations: +- The overlay is generated read-only from existing persisted evidence and geometries; no new evidence table or migration was introduced. +- Missing evidence ids are reported as warnings and do not create fake geometries. +- No provider fetching, AI dependency, real model behavior or new product domain was added. + +Next recommended pass: +- Add live browser validation for the QA evidence overlay against the deployed demo workflow, then consider a small export/handoff action for the evidence overlay GeoJSON. + ## Sprint 111 QA feature evidence persistence (2026-06-25) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 5cb7960f..e4e6d5c2 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -378,3 +378,4 @@ This file now starts with the current implementation status. Older preparation/b - [x] Add Map workspace QA/QC shortcut for saved derived selection datasets. - [x] Add Map workspace QA/QC evidence drilldown handoff for saved selection comparisons. - [x] Persist QA/QC feature-level evidence for matches, false positives and false negatives. +- [x] Render persisted QA/QC feature-level evidence as Map workspace overlays. diff --git a/frontend/README.md b/frontend/README.md index 566f3f70..e9f57dc2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -288,6 +288,7 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst - The QA/QC workspace shows candidate/reference handoff cards and resolves persisted quality-check dataset IDs back to dataset names when the datasets are loaded in the current project context. - The QA/QC workspace includes a selected-check evidence drilldown with candidate/reference provenance, false-positive/negative metric evidence, map handoff context and parameters/findings JSON. - QA/QC findings now persist feature-level evidence in `findings_json`: matched candidate/reference feature ids with IoU, false-positive candidate feature ids and false-negative reference feature ids. The QA/QC drilldown renders these as compact evidence lists before the raw JSON. +- Persisted QA/QC checks can be rendered as a Map workspace evidence overlay. The QA/QC panel calls `GET /api/v1/projects/{project_id}/quality-checks/{quality_check_id}/evidence/geojson`, then MapLibre draws matched candidate/reference geometries, false positives and false negatives with distinct styling and a compact legend. ## Raster dependency visibility diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 038eb7b3..4be4f057 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -205,7 +205,12 @@ function App(): JSX.Element { qaError, qualityChecks, qualityChecksError, + qualityEvidenceGeoJson, + qualityEvidenceLoading, + qualityEvidenceError, loadQualityChecks, + loadQualityEvidenceGeoJson, + clearQualityEvidenceGeoJson, runQaComparison, setQaCandidateDatasetId, setQaReferenceDatasetId, @@ -417,6 +422,13 @@ function App(): JSX.Element { const openMapSelectionQualityEvidence = () => { setActiveWorkspace('analysis') } + const openQualityEvidenceOnMap = async (qualityCheckId: string) => { + const result = await loadQualityEvidenceGeoJson(qualityCheckId) + if (result) { + setMapLayerVisible(true) + setActiveWorkspace('map') + } + } const { loadingDemoWorkflow, demoWorkflowMessage, @@ -824,6 +836,11 @@ function App(): JSX.Element { selectedMapAreaId={selectedMapAreaId} areaFeatureCollection={areaFeatureCollection} mapFeatureCollection={mapFeatureCollection} + qualityEvidenceGeoJson={qualityEvidenceGeoJson?.geojson ?? null} + qualityEvidenceFeatureCount={qualityEvidenceGeoJson?.feature_count ?? 0} + qualityEvidenceLoading={qualityEvidenceLoading} + qualityEvidenceError={qualityEvidenceError} + qualityEvidenceWarnings={qualityEvidenceGeoJson?.warnings ?? []} mapLayerLabel={mapLayerLabel} mapLayerSourceLabel={mapLayerSourceLabel} mapLayerProvenance={mapLayerProvenance} @@ -867,6 +884,7 @@ function App(): JSX.Element { onSelectMapQaReferenceDataset={setSelectedMapQaReferenceDatasetId} onRunMapSelectionQa={runMapSelectionQa} onOpenMapSelectionQualityEvidence={openMapSelectionQualityEvidence} + onClearQualityEvidence={clearQualityEvidenceGeoJson} /> ) : null} @@ -895,6 +913,9 @@ function App(): JSX.Element { candidateDatasets={candidateDatasets} referenceDatasets={referenceDatasets} onRefresh={() => loadQualityChecks()} + onOpenEvidenceMap={openQualityEvidenceOnMap} + evidenceLoading={qualityEvidenceLoading} + evidenceError={qualityEvidenceError} /> ) : null} diff --git a/frontend/src/components/GeoMap.tsx b/frontend/src/components/GeoMap.tsx index c4950087..4b971a7c 100644 --- a/frontend/src/components/GeoMap.tsx +++ b/frontend/src/components/GeoMap.tsx @@ -7,6 +7,7 @@ interface GeoMapProps { areaData?: GeoJSON.FeatureCollection | null selectedFeature?: GeoJSON.Feature | null selectionData?: GeoJSON.FeatureCollection | null + qaEvidenceData?: GeoJSON.FeatureCollection | null selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null bboxSelectionMode?: boolean visible?: boolean @@ -97,6 +98,7 @@ function GeoMap({ areaData = null, selectedFeature = null, selectionData = null, + qaEvidenceData = null, selectionBbox = null, bboxSelectionMode = false, visible = true, @@ -148,7 +150,18 @@ function GeoMap({ onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat]) return } - const layers = ['dataset-fill', 'dataset-line', 'area-fill', 'area-line'].filter((layerId) => map.getLayer(layerId)) + const layers = [ + 'qa-evidence-fill', + 'qa-evidence-line', + 'qa-evidence-circle', + 'selection-result-fill', + 'selection-result-line', + 'selection-result-circle', + 'dataset-fill', + 'dataset-line', + 'area-fill', + 'area-line', + ].filter((layerId) => map.getLayer(layerId)) if (layers.length === 0) { onFeatureSelectRef.current?.(null) return @@ -463,6 +476,74 @@ function GeoMap({ }) }, [selectionData, mapStyleReady]) + useEffect(() => { + const map = mapRef.current + if (!map || !mapStyleReady || !map.isStyleLoaded()) { + return + } + + const evidenceCollection = qaEvidenceData ?? EMPTY_FEATURE_COLLECTION + if (map.getSource('qa-evidence')) { + ;(map.getSource('qa-evidence') as maplibregl.GeoJSONSource).setData(evidenceCollection) + return + } + + map.addSource('qa-evidence', { type: 'geojson', data: evidenceCollection }) + const evidenceColor = [ + 'match', + ['get', 'qa_evidence_role'], + 'match_candidate', + '#2563eb', + 'match_reference', + '#0f766e', + 'false_positive', + '#dc2626', + 'false_negative', + '#d97706', + '#475569', + ] as maplibregl.ExpressionSpecification + map.addLayer({ + id: 'qa-evidence-fill', + type: 'fill', + source: 'qa-evidence', + filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false], + paint: { + 'fill-color': evidenceColor, + 'fill-opacity': 0.28, + }, + }) + map.addLayer({ + id: 'qa-evidence-line', + type: 'line', + source: 'qa-evidence', + filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false], + paint: { + 'line-color': evidenceColor, + 'line-width': [ + 'match', + ['get', 'qa_evidence_role'], + 'false_positive', + 4, + 'false_negative', + 4, + 3, + ], + }, + }) + map.addLayer({ + id: 'qa-evidence-circle', + type: 'circle', + source: 'qa-evidence', + filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false], + paint: { + 'circle-color': evidenceColor, + 'circle-radius': 7, + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 2, + }, + }) + }, [qaEvidenceData, mapStyleReady]) + return
} diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 6156e99b..bc895fce 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -176,6 +176,11 @@ interface MapWorkspaceProps { selectedMapAreaId: string areaFeatureCollection: GeoJSON.FeatureCollection | null mapFeatureCollection: GeoJSON.FeatureCollection | null + qualityEvidenceGeoJson?: GeoJSON.FeatureCollection | null + qualityEvidenceFeatureCount?: number + qualityEvidenceLoading?: boolean + qualityEvidenceError?: string | null + qualityEvidenceWarnings?: string[] mapLayerLabel: string mapLayerSourceLabel: string mapLayerProvenance: string @@ -219,6 +224,7 @@ interface MapWorkspaceProps { onSelectMapQaReferenceDataset: (datasetId: string) => void onRunMapSelectionQa: () => void onOpenMapSelectionQualityEvidence: () => void + onClearQualityEvidence?: () => void } export function MapWorkspace({ @@ -226,6 +232,11 @@ export function MapWorkspace({ selectedMapAreaId, areaFeatureCollection, mapFeatureCollection, + qualityEvidenceGeoJson = null, + qualityEvidenceFeatureCount = 0, + qualityEvidenceLoading = false, + qualityEvidenceError = null, + qualityEvidenceWarnings = [], mapLayerLabel, mapLayerSourceLabel, mapLayerProvenance, @@ -269,6 +280,7 @@ export function MapWorkspace({ onSelectMapQaReferenceDataset, onRunMapSelectionQa, onOpenMapSelectionQualityEvidence, + onClearQualityEvidence, }: MapWorkspaceProps): JSX.Element { const [bboxSelectionMode, setBboxSelectionMode] = useState(false) const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null) @@ -398,6 +410,11 @@ export function MapWorkspace({ {mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No layer rendered'} {mapLayerProvenance}
+
+ QA/QC evidence + {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} evidence features` : 'No evidence overlay'} + {qualityEvidenceLoading ? 'Loading persisted evidence' : 'Matches, false positives and false negatives'} +
@@ -483,9 +500,37 @@ export function MapWorkspace({ Draw state {mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No active vector or result layer'}
+
+ QA evidence overlay + {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} rendered` : 'off'} +
+ {qualityEvidenceGeoJson || qualityEvidenceError || qualityEvidenceWarnings.length > 0 ? ( +
+
+ QA/QC evidence overlay + {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} persisted features` : 'Not loaded'} + {qualityEvidenceError ?

{qualityEvidenceError}

: null} + {qualityEvidenceWarnings.length > 0 ? ( +

{qualityEvidenceWarnings.length} evidence id{qualityEvidenceWarnings.length === 1 ? '' : 's'} could not be resolved.

+ ) : null} +
+
+ Match candidate + Match reference + False positive + False negative +
+ {onClearQualityEvidence ? ( + + ) : null} +
+ ) : null} + {!mapFeatureCollection ? (
No active vector or result layer @@ -514,6 +559,7 @@ export function MapWorkspace({ areaData={areaFeatureCollection} selectedFeature={selectedFeature} selectionData={mapSelectionResult?.geojson ?? null} + qaEvidenceData={qualityEvidenceGeoJson} selectionBbox={mapSelectionBbox} bboxSelectionMode={bboxSelectionMode} visible={mapLayerVisible} diff --git a/frontend/src/components/quality/QualityResultsPanel.tsx b/frontend/src/components/quality/QualityResultsPanel.tsx index e3af9839..80862841 100644 --- a/frontend/src/components/quality/QualityResultsPanel.tsx +++ b/frontend/src/components/quality/QualityResultsPanel.tsx @@ -17,6 +17,9 @@ interface QualityResultsPanelProps { candidateDatasets: DatasetCreateResponse[] referenceDatasets: DatasetCreateResponse[] onRefresh: () => void + onOpenEvidenceMap?: (qualityCheckId: string) => void + evidenceLoading?: boolean + evidenceError?: string | null } function qualityMetricLabel(metricKey: string): string { @@ -95,6 +98,9 @@ export function QualityResultsPanel({ candidateDatasets, referenceDatasets, onRefresh, + onOpenEvidenceMap, + evidenceLoading = false, + evidenceError = null, }: QualityResultsPanelProps): JSX.Element { const [showAllQualityChecks, setShowAllQualityChecks] = useState(false) const [qualityStatusFilter, setQualityStatusFilter] = useState('all') @@ -204,7 +210,21 @@ export function QualityResultsPanel({ > Inspect latest check +
+ {evidenceError ? ( +
+ QA/QC evidence overlay could not be loaded. +

{evidenceError}

+
+ ) : null} {selectedQualityCheck ? ( <>
@@ -254,6 +274,14 @@ export function QualityResultsPanel({ Map evidence handoff {selectedCandidateName} / {selectedReferenceName}

Use the candidate and reference datasets as map layers for spatial review.

+
@@ -440,6 +468,14 @@ export function QualityResultsPanel({ +
diff --git a/frontend/src/hooks/useQualityWorkflow.ts b/frontend/src/hooks/useQualityWorkflow.ts index 45023235..8c5b246e 100644 --- a/frontend/src/hooks/useQualityWorkflow.ts +++ b/frontend/src/hooks/useQualityWorkflow.ts @@ -1,6 +1,6 @@ import { useState } from 'react' import { qaApi } from '../services/api' -import type { JobRead, QaComparisonRequest, QaComparisonResult, QualityCheckRead } from '../types' +import type { JobRead, QaComparisonRequest, QaComparisonResult, QualityCheckRead, QualityEvidenceGeoJsonResponse } from '../types' import { formatError } from '../lib/formatError' interface QualityWorkflowOptions { @@ -18,6 +18,9 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali const [qaError, setQaError] = useState(null) const [qualityChecks, setQualityChecks] = useState([]) const [qualityChecksError, setQualityChecksError] = useState(null) + const [qualityEvidenceGeoJson, setQualityEvidenceGeoJson] = useState(null) + const [qualityEvidenceLoading, setQualityEvidenceLoading] = useState(false) + const [qualityEvidenceError, setQualityEvidenceError] = useState(null) const loadQualityChecks = async (projectId = selectedProjectId): Promise => { if (!projectId) { @@ -34,6 +37,30 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali } } + const loadQualityEvidenceGeoJson = async (qualityCheckId: string, projectId = selectedProjectId): Promise => { + if (!projectId) { + setQualityEvidenceError('Select a project first') + return null + } + setQualityEvidenceLoading(true) + setQualityEvidenceError(null) + try { + const response = await qaApi.getQualityEvidenceGeoJson(projectId, qualityCheckId) + setQualityEvidenceGeoJson(response) + return response + } catch (error) { + setQualityEvidenceError(formatError(error, 'Failed to load QA/QC evidence overlay')) + return null + } finally { + setQualityEvidenceLoading(false) + } + } + + const clearQualityEvidenceGeoJson = () => { + setQualityEvidenceGeoJson(null) + setQualityEvidenceError(null) + } + const runQaComparison = async () => { if (!selectedProjectId) { setQaError('Select a project first') @@ -102,7 +129,12 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali qaError, qualityChecks, qualityChecksError, + qualityEvidenceGeoJson, + qualityEvidenceLoading, + qualityEvidenceError, loadQualityChecks, + loadQualityEvidenceGeoJson, + clearQualityEvidenceGeoJson, runQaComparison, setQaCandidateDatasetId, setQaReferenceDatasetId, diff --git a/frontend/src/services/api/qa.ts b/frontend/src/services/api/qa.ts index 03b57ecf..f53d9528 100644 --- a/frontend/src/services/api/qa.ts +++ b/frontend/src/services/api/qa.ts @@ -1,9 +1,11 @@ import { apiGet, apiPost } from './client' -import type { QaComparisonRequest, JobRead, QualityCheckListResponse } from '../../types' +import type { QaComparisonRequest, JobRead, QualityCheckListResponse, QualityEvidenceGeoJsonResponse } from '../../types' export const qaApi = { runQa: (payload: QaComparisonRequest): Promise => apiPost('/api/v1/qa/detections-vs-reference', payload), listQualityChecks: (projectId: string): Promise => apiGet(`/api/v1/projects/${projectId}/quality-checks`), + getQualityEvidenceGeoJson: (projectId: string, qualityCheckId: string): Promise => + apiGet(`/api/v1/projects/${projectId}/quality-checks/${qualityCheckId}/evidence/geojson`), } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 672c9fb7..037f3042 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -2564,11 +2564,81 @@ button.entity-card { .layer-provenance-rail { display: grid; - grid-template-columns: 0.85fr minmax(0, 1.5fr) 0.85fr; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 0.65rem; margin-bottom: 0; } +.qa-evidence-map-status { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.6fr) auto; + gap: 0.65rem; + align-items: center; + border: 1px solid #bfdbfe; + border-left: 4px solid #2563eb; + border-radius: 8px; + padding: 0.72rem; + background: #eff6ff; +} + +.qa-evidence-map-status span, +.qa-evidence-map-status strong { + display: block; +} + +.qa-evidence-map-status > div:first-child span { + color: #1d4ed8; + font-size: 0.68rem; + font-weight: 850; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.qa-evidence-map-status > div:first-child strong { + margin-top: 0.18rem; + color: #172554; +} + +.qa-evidence-legend { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr)); + gap: 0.38rem; +} + +.qa-evidence-legend span { + display: inline-flex; + align-items: center; + gap: 0.38rem; + color: #1e3a8a; + font-size: 0.78rem; + font-weight: 750; +} + +.qa-evidence-swatch { + display: inline-block; + width: 0.72rem; + height: 0.72rem; + border-radius: 999px; + border: 2px solid #ffffff; + box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.18); +} + +.qa-evidence-swatch-match-candidate { + background: #2563eb; +} + +.qa-evidence-swatch-match-reference { + background: #0f766e; +} + +.qa-evidence-swatch-false-positive { + background: #dc2626; +} + +.qa-evidence-swatch-false-negative { + background: #d97706; +} + .layer-provenance-rail > div, .feature-property-chip { min-width: 0; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ffaa7f5e..ddff361e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -600,6 +600,9 @@ export interface QaComparisonResult { iou_threshold: number unsupported_geometry: boolean unsupported_geometries: string[] + match_evidence?: Record[] + false_positive_evidence?: Record[] + false_negative_evidence?: Record[] generated_at: string quality_check_id?: string } @@ -640,6 +643,17 @@ export interface QualityCheckListResponse { offset: number } +export interface QualityEvidenceGeoJsonResponse { + quality_check_id: string + project_id: string + candidate_dataset_id?: string | null + reference_dataset_id: string + analysis_run_id?: string | null + feature_count: number + warnings: string[] + geojson: GeoJSON.FeatureCollection +} + export type ExportKind = 'dataset' | 'detection_run' | 'segmentation_run' | 'vector_selection' export interface ExportRead {