Add QA evidence map overlay
This commit is contained in:
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
# Changelog
|
# 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)
|
## Sprint 111 QA feature evidence persistence (2026-06-25)
|
||||||
|
|
||||||
- Added feature-level QA evidence to dataset, detection and segmentation QA matching.
|
- Added feature-level QA evidence to dataset, detection and segmentation QA matching.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.schemas.qa import QualityCheckList
|
from app.schemas.qa import QualityCheckList
|
||||||
|
from app.services.quality_evidence_service import QualityEvidenceService
|
||||||
from app.services.quality_check_service import QualityCheckService
|
from app.services.quality_check_service import QualityCheckService
|
||||||
from app.utils.response import envelope
|
from app.utils.response import envelope
|
||||||
|
|
||||||
@@ -27,3 +28,12 @@ def list_quality_checks(
|
|||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
return envelope(QualityCheckList(items=items, total=total, limit=limit, offset=offset).model_dump())
|
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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
## Exports
|
||||||
|
|
||||||
### POST `/api/v1/exports/geojson`
|
### POST `/api/v1/exports/geojson`
|
||||||
|
|||||||
@@ -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)
|
## Sprint 111 QA feature evidence persistence (2026-06-25)
|
||||||
|
|
||||||
Changed:
|
Changed:
|
||||||
|
|||||||
@@ -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 shortcut for saved derived selection datasets.
|
||||||
- [x] Add Map workspace QA/QC evidence drilldown handoff for saved selection comparisons.
|
- [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] 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.
|
||||||
|
|||||||
@@ -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 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.
|
- 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.
|
- 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
|
## Raster dependency visibility
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,12 @@ function App(): JSX.Element {
|
|||||||
qaError,
|
qaError,
|
||||||
qualityChecks,
|
qualityChecks,
|
||||||
qualityChecksError,
|
qualityChecksError,
|
||||||
|
qualityEvidenceGeoJson,
|
||||||
|
qualityEvidenceLoading,
|
||||||
|
qualityEvidenceError,
|
||||||
loadQualityChecks,
|
loadQualityChecks,
|
||||||
|
loadQualityEvidenceGeoJson,
|
||||||
|
clearQualityEvidenceGeoJson,
|
||||||
runQaComparison,
|
runQaComparison,
|
||||||
setQaCandidateDatasetId,
|
setQaCandidateDatasetId,
|
||||||
setQaReferenceDatasetId,
|
setQaReferenceDatasetId,
|
||||||
@@ -417,6 +422,13 @@ function App(): JSX.Element {
|
|||||||
const openMapSelectionQualityEvidence = () => {
|
const openMapSelectionQualityEvidence = () => {
|
||||||
setActiveWorkspace('analysis')
|
setActiveWorkspace('analysis')
|
||||||
}
|
}
|
||||||
|
const openQualityEvidenceOnMap = async (qualityCheckId: string) => {
|
||||||
|
const result = await loadQualityEvidenceGeoJson(qualityCheckId)
|
||||||
|
if (result) {
|
||||||
|
setMapLayerVisible(true)
|
||||||
|
setActiveWorkspace('map')
|
||||||
|
}
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
loadingDemoWorkflow,
|
loadingDemoWorkflow,
|
||||||
demoWorkflowMessage,
|
demoWorkflowMessage,
|
||||||
@@ -824,6 +836,11 @@ function App(): JSX.Element {
|
|||||||
selectedMapAreaId={selectedMapAreaId}
|
selectedMapAreaId={selectedMapAreaId}
|
||||||
areaFeatureCollection={areaFeatureCollection}
|
areaFeatureCollection={areaFeatureCollection}
|
||||||
mapFeatureCollection={mapFeatureCollection}
|
mapFeatureCollection={mapFeatureCollection}
|
||||||
|
qualityEvidenceGeoJson={qualityEvidenceGeoJson?.geojson ?? null}
|
||||||
|
qualityEvidenceFeatureCount={qualityEvidenceGeoJson?.feature_count ?? 0}
|
||||||
|
qualityEvidenceLoading={qualityEvidenceLoading}
|
||||||
|
qualityEvidenceError={qualityEvidenceError}
|
||||||
|
qualityEvidenceWarnings={qualityEvidenceGeoJson?.warnings ?? []}
|
||||||
mapLayerLabel={mapLayerLabel}
|
mapLayerLabel={mapLayerLabel}
|
||||||
mapLayerSourceLabel={mapLayerSourceLabel}
|
mapLayerSourceLabel={mapLayerSourceLabel}
|
||||||
mapLayerProvenance={mapLayerProvenance}
|
mapLayerProvenance={mapLayerProvenance}
|
||||||
@@ -867,6 +884,7 @@ function App(): JSX.Element {
|
|||||||
onSelectMapQaReferenceDataset={setSelectedMapQaReferenceDatasetId}
|
onSelectMapQaReferenceDataset={setSelectedMapQaReferenceDatasetId}
|
||||||
onRunMapSelectionQa={runMapSelectionQa}
|
onRunMapSelectionQa={runMapSelectionQa}
|
||||||
onOpenMapSelectionQualityEvidence={openMapSelectionQualityEvidence}
|
onOpenMapSelectionQualityEvidence={openMapSelectionQualityEvidence}
|
||||||
|
onClearQualityEvidence={clearQualityEvidenceGeoJson}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -895,6 +913,9 @@ function App(): JSX.Element {
|
|||||||
candidateDatasets={candidateDatasets}
|
candidateDatasets={candidateDatasets}
|
||||||
referenceDatasets={referenceDatasets}
|
referenceDatasets={referenceDatasets}
|
||||||
onRefresh={() => loadQualityChecks()}
|
onRefresh={() => loadQualityChecks()}
|
||||||
|
onOpenEvidenceMap={openQualityEvidenceOnMap}
|
||||||
|
evidenceLoading={qualityEvidenceLoading}
|
||||||
|
evidenceError={qualityEvidenceError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface GeoMapProps {
|
|||||||
areaData?: GeoJSON.FeatureCollection | null
|
areaData?: GeoJSON.FeatureCollection | null
|
||||||
selectedFeature?: GeoJSON.Feature | null
|
selectedFeature?: GeoJSON.Feature | null
|
||||||
selectionData?: GeoJSON.FeatureCollection | null
|
selectionData?: GeoJSON.FeatureCollection | null
|
||||||
|
qaEvidenceData?: GeoJSON.FeatureCollection | null
|
||||||
selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null
|
selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null
|
||||||
bboxSelectionMode?: boolean
|
bboxSelectionMode?: boolean
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
@@ -97,6 +98,7 @@ function GeoMap({
|
|||||||
areaData = null,
|
areaData = null,
|
||||||
selectedFeature = null,
|
selectedFeature = null,
|
||||||
selectionData = null,
|
selectionData = null,
|
||||||
|
qaEvidenceData = null,
|
||||||
selectionBbox = null,
|
selectionBbox = null,
|
||||||
bboxSelectionMode = false,
|
bboxSelectionMode = false,
|
||||||
visible = true,
|
visible = true,
|
||||||
@@ -148,7 +150,18 @@ function GeoMap({
|
|||||||
onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat])
|
onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat])
|
||||||
return
|
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) {
|
if (layers.length === 0) {
|
||||||
onFeatureSelectRef.current?.(null)
|
onFeatureSelectRef.current?.(null)
|
||||||
return
|
return
|
||||||
@@ -463,6 +476,74 @@ function GeoMap({
|
|||||||
})
|
})
|
||||||
}, [selectionData, mapStyleReady])
|
}, [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 <div className="map-container" ref={containerRef} />
|
return <div className="map-container" ref={containerRef} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ interface MapWorkspaceProps {
|
|||||||
selectedMapAreaId: string
|
selectedMapAreaId: string
|
||||||
areaFeatureCollection: GeoJSON.FeatureCollection | null
|
areaFeatureCollection: GeoJSON.FeatureCollection | null
|
||||||
mapFeatureCollection: GeoJSON.FeatureCollection | null
|
mapFeatureCollection: GeoJSON.FeatureCollection | null
|
||||||
|
qualityEvidenceGeoJson?: GeoJSON.FeatureCollection | null
|
||||||
|
qualityEvidenceFeatureCount?: number
|
||||||
|
qualityEvidenceLoading?: boolean
|
||||||
|
qualityEvidenceError?: string | null
|
||||||
|
qualityEvidenceWarnings?: string[]
|
||||||
mapLayerLabel: string
|
mapLayerLabel: string
|
||||||
mapLayerSourceLabel: string
|
mapLayerSourceLabel: string
|
||||||
mapLayerProvenance: string
|
mapLayerProvenance: string
|
||||||
@@ -219,6 +224,7 @@ interface MapWorkspaceProps {
|
|||||||
onSelectMapQaReferenceDataset: (datasetId: string) => void
|
onSelectMapQaReferenceDataset: (datasetId: string) => void
|
||||||
onRunMapSelectionQa: () => void
|
onRunMapSelectionQa: () => void
|
||||||
onOpenMapSelectionQualityEvidence: () => void
|
onOpenMapSelectionQualityEvidence: () => void
|
||||||
|
onClearQualityEvidence?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapWorkspace({
|
export function MapWorkspace({
|
||||||
@@ -226,6 +232,11 @@ export function MapWorkspace({
|
|||||||
selectedMapAreaId,
|
selectedMapAreaId,
|
||||||
areaFeatureCollection,
|
areaFeatureCollection,
|
||||||
mapFeatureCollection,
|
mapFeatureCollection,
|
||||||
|
qualityEvidenceGeoJson = null,
|
||||||
|
qualityEvidenceFeatureCount = 0,
|
||||||
|
qualityEvidenceLoading = false,
|
||||||
|
qualityEvidenceError = null,
|
||||||
|
qualityEvidenceWarnings = [],
|
||||||
mapLayerLabel,
|
mapLayerLabel,
|
||||||
mapLayerSourceLabel,
|
mapLayerSourceLabel,
|
||||||
mapLayerProvenance,
|
mapLayerProvenance,
|
||||||
@@ -269,6 +280,7 @@ export function MapWorkspace({
|
|||||||
onSelectMapQaReferenceDataset,
|
onSelectMapQaReferenceDataset,
|
||||||
onRunMapSelectionQa,
|
onRunMapSelectionQa,
|
||||||
onOpenMapSelectionQualityEvidence,
|
onOpenMapSelectionQualityEvidence,
|
||||||
|
onClearQualityEvidence,
|
||||||
}: MapWorkspaceProps): JSX.Element {
|
}: MapWorkspaceProps): JSX.Element {
|
||||||
const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
|
const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
|
||||||
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
|
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
|
||||||
@@ -398,6 +410,11 @@ export function MapWorkspace({
|
|||||||
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No layer rendered'}</strong>
|
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No layer rendered'}</strong>
|
||||||
<small>{mapLayerProvenance}</small>
|
<small>{mapLayerProvenance}</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>QA/QC evidence</span>
|
||||||
|
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} evidence features` : 'No evidence overlay'}</strong>
|
||||||
|
<small>{qualityEvidenceLoading ? 'Loading persisted evidence' : 'Matches, false positives and false negatives'}</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="map-control-surface" aria-label="Map workspace controls">
|
<div className="map-control-surface" aria-label="Map workspace controls">
|
||||||
@@ -483,8 +500,36 @@ export function MapWorkspace({
|
|||||||
<span>Draw state</span>
|
<span>Draw state</span>
|
||||||
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No active vector or result layer'}</strong>
|
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No active vector or result layer'}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>QA evidence overlay</span>
|
||||||
|
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} rendered` : 'off'}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{qualityEvidenceGeoJson || qualityEvidenceError || qualityEvidenceWarnings.length > 0 ? (
|
||||||
|
<div className="qa-evidence-map-status" aria-label="QA/QC evidence map overlay status">
|
||||||
|
<div>
|
||||||
|
<span>QA/QC evidence overlay</span>
|
||||||
|
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} persisted features` : 'Not loaded'}</strong>
|
||||||
|
{qualityEvidenceError ? <p className="error">{qualityEvidenceError}</p> : null}
|
||||||
|
{qualityEvidenceWarnings.length > 0 ? (
|
||||||
|
<p className="muted">{qualityEvidenceWarnings.length} evidence id{qualityEvidenceWarnings.length === 1 ? '' : 's'} could not be resolved.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="qa-evidence-legend" aria-label="QA/QC evidence overlay legend">
|
||||||
|
<span><i className="qa-evidence-swatch qa-evidence-swatch-match-candidate" /> Match candidate</span>
|
||||||
|
<span><i className="qa-evidence-swatch qa-evidence-swatch-match-reference" /> Match reference</span>
|
||||||
|
<span><i className="qa-evidence-swatch qa-evidence-swatch-false-positive" /> False positive</span>
|
||||||
|
<span><i className="qa-evidence-swatch qa-evidence-swatch-false-negative" /> False negative</span>
|
||||||
|
</div>
|
||||||
|
{onClearQualityEvidence ? (
|
||||||
|
<button className="secondary-action" type="button" onClick={onClearQualityEvidence}>
|
||||||
|
Clear QA evidence
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!mapFeatureCollection ? (
|
{!mapFeatureCollection ? (
|
||||||
<div className="empty-state map-empty-state">
|
<div className="empty-state map-empty-state">
|
||||||
@@ -514,6 +559,7 @@ export function MapWorkspace({
|
|||||||
areaData={areaFeatureCollection}
|
areaData={areaFeatureCollection}
|
||||||
selectedFeature={selectedFeature}
|
selectedFeature={selectedFeature}
|
||||||
selectionData={mapSelectionResult?.geojson ?? null}
|
selectionData={mapSelectionResult?.geojson ?? null}
|
||||||
|
qaEvidenceData={qualityEvidenceGeoJson}
|
||||||
selectionBbox={mapSelectionBbox}
|
selectionBbox={mapSelectionBbox}
|
||||||
bboxSelectionMode={bboxSelectionMode}
|
bboxSelectionMode={bboxSelectionMode}
|
||||||
visible={mapLayerVisible}
|
visible={mapLayerVisible}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ interface QualityResultsPanelProps {
|
|||||||
candidateDatasets: DatasetCreateResponse[]
|
candidateDatasets: DatasetCreateResponse[]
|
||||||
referenceDatasets: DatasetCreateResponse[]
|
referenceDatasets: DatasetCreateResponse[]
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
|
onOpenEvidenceMap?: (qualityCheckId: string) => void
|
||||||
|
evidenceLoading?: boolean
|
||||||
|
evidenceError?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function qualityMetricLabel(metricKey: string): string {
|
function qualityMetricLabel(metricKey: string): string {
|
||||||
@@ -95,6 +98,9 @@ export function QualityResultsPanel({
|
|||||||
candidateDatasets,
|
candidateDatasets,
|
||||||
referenceDatasets,
|
referenceDatasets,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onOpenEvidenceMap,
|
||||||
|
evidenceLoading = false,
|
||||||
|
evidenceError = null,
|
||||||
}: QualityResultsPanelProps): JSX.Element {
|
}: QualityResultsPanelProps): JSX.Element {
|
||||||
const [showAllQualityChecks, setShowAllQualityChecks] = useState(false)
|
const [showAllQualityChecks, setShowAllQualityChecks] = useState(false)
|
||||||
const [qualityStatusFilter, setQualityStatusFilter] = useState('all')
|
const [qualityStatusFilter, setQualityStatusFilter] = useState('all')
|
||||||
@@ -204,7 +210,21 @@ export function QualityResultsPanel({
|
|||||||
>
|
>
|
||||||
Inspect latest check
|
Inspect latest check
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-action"
|
||||||
|
onClick={() => latestCheck?.id && onOpenEvidenceMap?.(latestCheck.id)}
|
||||||
|
disabled={!latestCheck || evidenceLoading || !onOpenEvidenceMap}
|
||||||
|
>
|
||||||
|
{evidenceLoading ? 'Loading map evidence...' : 'Show latest on map'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{evidenceError ? (
|
||||||
|
<div className="result-state result-state-error">
|
||||||
|
<strong>QA/QC evidence overlay could not be loaded.</strong>
|
||||||
|
<p>{evidenceError}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{selectedQualityCheck ? (
|
{selectedQualityCheck ? (
|
||||||
<>
|
<>
|
||||||
<div className="quality-drilldown-grid">
|
<div className="quality-drilldown-grid">
|
||||||
@@ -254,6 +274,14 @@ export function QualityResultsPanel({
|
|||||||
<span>Map evidence handoff</span>
|
<span>Map evidence handoff</span>
|
||||||
<strong>{selectedCandidateName} / {selectedReferenceName}</strong>
|
<strong>{selectedCandidateName} / {selectedReferenceName}</strong>
|
||||||
<p>Use the candidate and reference datasets as map layers for spatial review.</p>
|
<p>Use the candidate and reference datasets as map layers for spatial review.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-action"
|
||||||
|
onClick={() => onOpenEvidenceMap?.(selectedQualityCheck.id)}
|
||||||
|
disabled={evidenceLoading || !onOpenEvidenceMap}
|
||||||
|
>
|
||||||
|
{evidenceLoading ? 'Loading overlay...' : 'Show evidence overlay'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="quality-feature-evidence-grid" aria-label="Feature-level QA/QC evidence">
|
<div className="quality-feature-evidence-grid" aria-label="Feature-level QA/QC evidence">
|
||||||
@@ -440,6 +468,14 @@ export function QualityResultsPanel({
|
|||||||
<button type="button" className="secondary-action" onClick={() => setSelectedQualityCheckId(check.id)}>
|
<button type="button" className="secondary-action" onClick={() => setSelectedQualityCheckId(check.id)}>
|
||||||
Inspect check
|
Inspect check
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-action"
|
||||||
|
onClick={() => onOpenEvidenceMap?.(check.id)}
|
||||||
|
disabled={evidenceLoading || !onOpenEvidenceMap}
|
||||||
|
>
|
||||||
|
Show evidence on map
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="quality-score-row">
|
<div className="quality-score-row">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { qaApi } from '../services/api'
|
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'
|
import { formatError } from '../lib/formatError'
|
||||||
|
|
||||||
interface QualityWorkflowOptions {
|
interface QualityWorkflowOptions {
|
||||||
@@ -18,6 +18,9 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
|
|||||||
const [qaError, setQaError] = useState<string | null>(null)
|
const [qaError, setQaError] = useState<string | null>(null)
|
||||||
const [qualityChecks, setQualityChecks] = useState<QualityCheckRead[]>([])
|
const [qualityChecks, setQualityChecks] = useState<QualityCheckRead[]>([])
|
||||||
const [qualityChecksError, setQualityChecksError] = useState<string | null>(null)
|
const [qualityChecksError, setQualityChecksError] = useState<string | null>(null)
|
||||||
|
const [qualityEvidenceGeoJson, setQualityEvidenceGeoJson] = useState<QualityEvidenceGeoJsonResponse | null>(null)
|
||||||
|
const [qualityEvidenceLoading, setQualityEvidenceLoading] = useState(false)
|
||||||
|
const [qualityEvidenceError, setQualityEvidenceError] = useState<string | null>(null)
|
||||||
|
|
||||||
const loadQualityChecks = async (projectId = selectedProjectId): Promise<QualityCheckRead[] | void> => {
|
const loadQualityChecks = async (projectId = selectedProjectId): Promise<QualityCheckRead[] | void> => {
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
@@ -34,6 +37,30 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadQualityEvidenceGeoJson = async (qualityCheckId: string, projectId = selectedProjectId): Promise<QualityEvidenceGeoJsonResponse | null> => {
|
||||||
|
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 () => {
|
const runQaComparison = async () => {
|
||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
setQaError('Select a project first')
|
setQaError('Select a project first')
|
||||||
@@ -102,7 +129,12 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
|
|||||||
qaError,
|
qaError,
|
||||||
qualityChecks,
|
qualityChecks,
|
||||||
qualityChecksError,
|
qualityChecksError,
|
||||||
|
qualityEvidenceGeoJson,
|
||||||
|
qualityEvidenceLoading,
|
||||||
|
qualityEvidenceError,
|
||||||
loadQualityChecks,
|
loadQualityChecks,
|
||||||
|
loadQualityEvidenceGeoJson,
|
||||||
|
clearQualityEvidenceGeoJson,
|
||||||
runQaComparison,
|
runQaComparison,
|
||||||
setQaCandidateDatasetId,
|
setQaCandidateDatasetId,
|
||||||
setQaReferenceDatasetId,
|
setQaReferenceDatasetId,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { apiGet, apiPost } from './client'
|
import { apiGet, apiPost } from './client'
|
||||||
import type { QaComparisonRequest, JobRead, QualityCheckListResponse } from '../../types'
|
import type { QaComparisonRequest, JobRead, QualityCheckListResponse, QualityEvidenceGeoJsonResponse } from '../../types'
|
||||||
|
|
||||||
export const qaApi = {
|
export const qaApi = {
|
||||||
runQa: (payload: QaComparisonRequest): Promise<JobRead> =>
|
runQa: (payload: QaComparisonRequest): Promise<JobRead> =>
|
||||||
apiPost<JobRead>('/api/v1/qa/detections-vs-reference', payload),
|
apiPost<JobRead>('/api/v1/qa/detections-vs-reference', payload),
|
||||||
listQualityChecks: (projectId: string): Promise<QualityCheckListResponse> =>
|
listQualityChecks: (projectId: string): Promise<QualityCheckListResponse> =>
|
||||||
apiGet<QualityCheckListResponse>(`/api/v1/projects/${projectId}/quality-checks`),
|
apiGet<QualityCheckListResponse>(`/api/v1/projects/${projectId}/quality-checks`),
|
||||||
|
getQualityEvidenceGeoJson: (projectId: string, qualityCheckId: string): Promise<QualityEvidenceGeoJsonResponse> =>
|
||||||
|
apiGet<QualityEvidenceGeoJsonResponse>(`/api/v1/projects/${projectId}/quality-checks/${qualityCheckId}/evidence/geojson`),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2564,11 +2564,81 @@ button.entity-card {
|
|||||||
|
|
||||||
.layer-provenance-rail {
|
.layer-provenance-rail {
|
||||||
display: grid;
|
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;
|
gap: 0.65rem;
|
||||||
margin-bottom: 0;
|
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,
|
.layer-provenance-rail > div,
|
||||||
.feature-property-chip {
|
.feature-property-chip {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -600,6 +600,9 @@ export interface QaComparisonResult {
|
|||||||
iou_threshold: number
|
iou_threshold: number
|
||||||
unsupported_geometry: boolean
|
unsupported_geometry: boolean
|
||||||
unsupported_geometries: string[]
|
unsupported_geometries: string[]
|
||||||
|
match_evidence?: Record<string, unknown>[]
|
||||||
|
false_positive_evidence?: Record<string, unknown>[]
|
||||||
|
false_negative_evidence?: Record<string, unknown>[]
|
||||||
generated_at: string
|
generated_at: string
|
||||||
quality_check_id?: string
|
quality_check_id?: string
|
||||||
}
|
}
|
||||||
@@ -640,6 +643,17 @@ export interface QualityCheckListResponse {
|
|||||||
offset: number
|
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 type ExportKind = 'dataset' | 'detection_run' | 'segmentation_run' | 'vector_selection'
|
||||||
|
|
||||||
export interface ExportRead {
|
export interface ExportRead {
|
||||||
|
|||||||
Reference in New Issue
Block a user