fix(results): gate operational AI exports on QA
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user