fix(results): gate operational AI exports on QA
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-08-09 11:31:51 +02:00
parent b76cd1837b
commit 0209167cfd
10 changed files with 313 additions and 8 deletions
+6 -1
View File
@@ -40,7 +40,12 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
)
if payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
return envelope(
ExportService.export_detection_run_geojson(db, payload.analysis_run_id, payload.name).model_dump(mode="json")
ExportService.export_detection_run_geojson(
db,
payload.analysis_run_id,
payload.name,
intended_use=payload.intended_use,
).model_dump(mode="json")
)
if payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
return envelope(
+4
View File
@@ -10,6 +10,7 @@ from app.schemas.operations import VectorSelectionBBox
ExportKind = Literal["dataset", "detection_run", "segmentation_run", "vector_selection"]
DetectionExportIntendedUse = Literal["review", "operational"]
MapResultMode = Literal["current", "evolution"]
@@ -21,6 +22,7 @@ class GeoJsonExportRequest(BaseModel):
name: str | None = None
bbox: VectorSelectionBBox | None = None
limit: int = 250
intended_use: DetectionExportIntendedUse = "review"
@model_validator(mode="after")
def validate_target(self) -> "GeoJsonExportRequest":
@@ -33,6 +35,8 @@ class GeoJsonExportRequest(BaseModel):
raise ValueError("bbox is required for vector selection GeoJSON exports")
if self.export_kind in {"detection_run", "segmentation_run"} and self.analysis_run_id is None:
raise ValueError("analysis_run_id is required for run GeoJSON exports")
if self.intended_use == "operational" and self.export_kind != "detection_run":
raise ValueError("operational intended_use is supported only for detection run exports")
return self
+102 -1
View File
@@ -35,6 +35,85 @@ from app.services.vector_feature_service import VectorFeatureService
class ExportService:
@staticmethod
def _detection_export_trust(db: Session, run: AnalysisRun) -> dict[str, Any]:
"""Classify persisted AI output without turning confidence into truth."""
checks = (
db.query(QualityCheck)
.filter(
QualityCheck.analysis_run_id == run.id,
QualityCheck.check_type == "detections_vs_reference",
)
.order_by(QualityCheck.created_at.desc())
.all()
)
quality_check = checks[0] if checks else None
reasons: list[str] = []
reference_dataset = None
if quality_check is None:
reasons.append("authoritative_qa_missing")
else:
findings = quality_check.findings_json if isinstance(quality_check.findings_json, dict) else {}
coverage = findings.get("coverage") if isinstance(findings.get("coverage"), dict) else {}
temporal = findings.get("temporal_compatibility") if isinstance(findings.get("temporal_compatibility"), dict) else {}
warnings = findings.get("warnings") if isinstance(findings.get("warnings"), list) else []
if quality_check.status != "ok":
reasons.append("quality_check_not_ok")
if findings.get("unsupported_geometry") is True:
reasons.append("unsupported_geometry")
false_positives = findings.get("false_positives")
false_negatives = findings.get("false_negatives")
if (
isinstance(false_positives, bool)
or not isinstance(false_positives, (int, float))
or false_positives != 0
):
reasons.append("false_positives_present")
if (
isinstance(false_negatives, bool)
or not isinstance(false_negatives, (int, float))
or false_negatives != 0
):
reasons.append("false_negatives_present")
if warnings:
reasons.append("quality_warnings_present")
if coverage.get("applied") is not True:
reasons.append("inference_coverage_not_proven")
if temporal.get("status") != "compatible":
reasons.append("temporal_compatibility_not_proven")
reference_dataset = db.get(Dataset, quality_check.reference_dataset_id)
if reference_dataset is None:
reasons.append("reference_dataset_missing")
else:
try:
DatasetConsumptionGate.assert_eligible(
reference_dataset,
purpose="reference_validation",
reference_task="building_validation",
)
except AppError:
reasons.append("reference_not_authoritative_for_buildings")
operational_use_allowed = not reasons
return {
"schema_version": "geointel.result-trust/v1",
"classification": (
"authoritative_reference_checked_ai_output"
if operational_use_allowed
else "unverified_ai_review_output"
),
"authoritative": False,
"operational_use_allowed": operational_use_allowed,
"operator_review_required": True,
"quality_check_id": str(quality_check.id) if quality_check else None,
"reference_dataset_id": str(reference_dataset.id) if reference_dataset else None,
"blocking_reasons": sorted(set(reasons)),
"limitation": (
"AI output is not ground truth. Operational use is bounded to the exact source, AOI, model and reference QA evidence."
),
}
@staticmethod
def _assert_run_source_dataset_exportable(db: Session, run: AnalysisRun) -> Dataset:
"""Block an output export when its persisted source dataset is unsafe."""
@@ -429,13 +508,33 @@ class ExportService:
return ExportService._create_response(export)
@staticmethod
def export_detection_run_geojson(db: Session, analysis_run_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse:
def export_detection_run_geojson(
db: Session,
analysis_run_id: uuid.UUID,
name: str | None = None,
*,
intended_use: str = "review",
) -> ExportCreateResponse:
run = db.get(AnalysisRun, analysis_run_id)
if not run or run.analysis_type != "detection":
raise AppError(code="DETECTION_RUN_NOT_FOUND", message="Detection run not found", status_code=404)
ExportService._assert_run_source_dataset_exportable(db, run)
trust = ExportService._detection_export_trust(db, run)
if intended_use == "operational" and not trust["operational_use_allowed"]:
raise AppError(
code="DETECTION_OPERATIONAL_EXPORT_BLOCKED",
message="Operational detection export requires complete authoritative QA with no remaining errors or warnings.",
details=trust,
status_code=409,
)
feature_collection = DetectionService.detections_to_geojson(db, analysis_run_id=analysis_run_id)
feature_collection["geointel_result"] = trust
for feature in feature_collection.get("features", []):
properties = feature.get("properties") if isinstance(feature, dict) else None
if isinstance(properties, dict):
properties["result_classification"] = trust["classification"]
properties["authoritative"] = False
filename = ExportService._filename(name, f"{run.id}-detections.geojson", ".geojson")
export_path = StorageService.dataset_export_path(str(run.project_id), str(run.dataset_id or run.id), filename)
metadata = {
@@ -444,6 +543,8 @@ class ExportService:
"project_id": str(run.project_id),
"dataset_id": str(run.dataset_id) if run.dataset_id else None,
"feature_count": len(feature_collection.get("features", [])),
"intended_use": intended_use,
"result_trust": trust,
}
export = ExportService._write_json_export(
db,