Files
geointel/backend/app/services/detection_review_service.py
T
Codex d22abe8e7b
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
feat: add measured detection review loop
2026-07-15 03:00:08 +02:00

253 lines
11 KiB
Python

from __future__ import annotations
from collections import Counter
from typing import Any
from uuid import UUID
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models import Detection, DetectionReview, QualityCheck, VectorFeature
from app.schemas.detection_review import (
DetectionReviewList,
DetectionReviewRead,
DetectionReviewSummary,
DetectionReviewUpsert,
)
class DetectionReviewService:
ALLOWED_DECISIONS = {
"false_positive": {
"confirmed_model_false_positive",
"reference_gap_or_change",
"qa_alignment_mismatch",
"uncertain",
"unreviewed",
},
"false_negative": {
"confirmed_model_false_negative",
"reference_gap_or_change",
"qa_alignment_mismatch",
"imagery_obscured_or_uncertain",
"uncertain",
"unreviewed",
},
}
@staticmethod
def _quality_check(db: Session, project_id: UUID, quality_check_id: UUID) -> QualityCheck:
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)
if quality_check.check_type != "detections_vs_reference":
raise AppError(
code="DETECTION_REVIEW_UNSUPPORTED",
message="Only persisted detection-versus-reference quality checks can be reviewed",
status_code=422,
)
return quality_check
@staticmethod
def _evidence_items(quality_check: QualityCheck) -> list[dict[str, str]]:
findings = quality_check.findings_json or {}
items: list[dict[str, str]] = []
for role, key, id_key in (
("false_positive", "false_positive_evidence", "candidate_feature_id"),
("false_negative", "false_negative_evidence", "reference_feature_id"),
):
evidence_rows = findings.get(key)
if not isinstance(evidence_rows, list):
continue
for evidence in evidence_rows:
if not isinstance(evidence, dict):
continue
value = str(evidence.get(id_key) or "").strip()
if value:
items.append({"evidence_role": role, "evidence_feature_id": value})
return items
@staticmethod
def _uuid(value: str) -> UUID | None:
try:
return UUID(value)
except (TypeError, ValueError):
return None
@staticmethod
def _review_index(db: Session, quality_check_id: UUID) -> dict[tuple[str, str], DetectionReview]:
rows = db.query(DetectionReview).filter(DetectionReview.quality_check_id == quality_check_id).all()
return {(row.evidence_role, row.evidence_feature_id): row for row in rows}
@staticmethod
def _summary(evidence: list[dict[str, str]], reviews: dict[tuple[str, str], DetectionReview]) -> DetectionReviewSummary:
evidence_keys = {(item["evidence_role"], item["evidence_feature_id"]) for item in evidence}
decisions = Counter(
reviews[key].decision if key in reviews else "unreviewed"
for key in evidence_keys
)
reviewed = sum(count for decision, count in decisions.items() if decision != "unreviewed")
false_positive_total = sum(1 for item in evidence if item["evidence_role"] == "false_positive")
false_negative_total = sum(1 for item in evidence if item["evidence_role"] == "false_negative")
return DetectionReviewSummary(
total=len(evidence),
reviewed=reviewed,
remaining=max(len(evidence) - reviewed, 0),
false_positive_total=false_positive_total,
false_negative_total=false_negative_total,
decision_counts=dict(sorted(decisions.items())),
)
@staticmethod
def _read_item(
db: Session,
quality_check: QualityCheck,
evidence: dict[str, str],
review: DetectionReview | None,
) -> DetectionReviewRead:
role = evidence["evidence_role"]
feature_id = evidence["evidence_feature_id"]
feature_uuid = DetectionReviewService._uuid(feature_id)
detection = db.get(Detection, feature_uuid) if role == "false_positive" and feature_uuid else None
reference = db.get(VectorFeature, feature_uuid) if role == "false_negative" and feature_uuid else None
return DetectionReviewRead(
id=review.id if review else None,
project_id=quality_check.project_id,
quality_check_id=quality_check.id,
analysis_run_id=quality_check.analysis_run_id,
evidence_role=role,
evidence_feature_id=feature_id,
detection_id=detection.id if detection else review.detection_id if review else None,
reference_feature_id=reference.id if reference else review.reference_feature_id if review else None,
decision=review.decision if review else "unreviewed",
notes=review.notes if review else None,
reviewed_by=review.reviewed_by if review else None,
confidence=detection.confidence if detection else None,
class_name=(detection.class_name if detection else reference.feature_class if reference else None),
source_tile_path=detection.source_tile_path if detection else None,
created_at=review.created_at if review else None,
updated_at=review.updated_at if review else None,
)
@staticmethod
def list_reviews(
db: Session,
*,
project_id: UUID,
quality_check_id: UUID,
evidence_role: str | None = None,
decision: str | None = None,
reviewed: bool | None = None,
limit: int = 50,
offset: int = 0,
) -> DetectionReviewList:
quality_check = DetectionReviewService._quality_check(db, project_id, quality_check_id)
evidence = DetectionReviewService._evidence_items(quality_check)
reviews = DetectionReviewService._review_index(db, quality_check_id)
filtered = [item for item in evidence if evidence_role is None or item["evidence_role"] == evidence_role]
if decision is not None:
filtered = [
item
for item in filtered
if (reviews.get((item["evidence_role"], item["evidence_feature_id"])).decision
if reviews.get((item["evidence_role"], item["evidence_feature_id"]))
else "unreviewed")
== decision
]
if reviewed is not None:
filtered = [
item
for item in filtered
if (
(reviews.get((item["evidence_role"], item["evidence_feature_id"])).decision
if reviews.get((item["evidence_role"], item["evidence_feature_id"]))
else "unreviewed")
!= "unreviewed"
)
== reviewed
]
page = filtered[offset : offset + limit]
return DetectionReviewList(
items=[
DetectionReviewService._read_item(
db,
quality_check,
item,
reviews.get((item["evidence_role"], item["evidence_feature_id"])),
)
for item in page
],
total=len(filtered),
limit=limit,
offset=offset,
summary=DetectionReviewService._summary(evidence, reviews),
)
@staticmethod
def upsert_review(
db: Session,
*,
project_id: UUID,
quality_check_id: UUID,
payload: DetectionReviewUpsert,
) -> DetectionReviewRead:
quality_check = DetectionReviewService._quality_check(db, project_id, quality_check_id)
if payload.decision not in DetectionReviewService.ALLOWED_DECISIONS[payload.evidence_role]:
raise AppError(
code="INVALID_DETECTION_REVIEW_DECISION",
message="The review decision is not valid for this evidence role",
details={"evidence_role": payload.evidence_role, "decision": payload.decision},
status_code=422,
)
evidence = DetectionReviewService._evidence_items(quality_check)
evidence_key = (payload.evidence_role, payload.evidence_feature_id)
if evidence_key not in {(item["evidence_role"], item["evidence_feature_id"]) for item in evidence}:
raise AppError(
code="DETECTION_REVIEW_EVIDENCE_NOT_FOUND",
message="The evidence feature does not belong to this quality check",
status_code=404,
)
feature_uuid = DetectionReviewService._uuid(payload.evidence_feature_id)
detection = db.get(Detection, feature_uuid) if payload.evidence_role == "false_positive" and feature_uuid else None
reference = db.get(VectorFeature, feature_uuid) if payload.evidence_role == "false_negative" and feature_uuid else None
if payload.evidence_role == "false_positive" and (not detection or detection.analysis_run_id != quality_check.analysis_run_id):
raise AppError(code="DETECTION_REVIEW_EVIDENCE_NOT_FOUND", message="Persisted detection evidence was not found", status_code=404)
if payload.evidence_role == "false_negative" and (not reference or reference.dataset_id != quality_check.reference_dataset_id):
raise AppError(code="DETECTION_REVIEW_EVIDENCE_NOT_FOUND", message="Persisted reference evidence was not found", status_code=404)
review = (
db.query(DetectionReview)
.filter(
DetectionReview.quality_check_id == quality_check_id,
DetectionReview.evidence_role == payload.evidence_role,
DetectionReview.evidence_feature_id == payload.evidence_feature_id,
)
.first()
)
if review is None:
review = DetectionReview(
project_id=project_id,
quality_check_id=quality_check_id,
analysis_run_id=quality_check.analysis_run_id,
evidence_role=payload.evidence_role,
evidence_feature_id=payload.evidence_feature_id,
detection_id=detection.id if detection else None,
reference_feature_id=reference.id if reference else None,
decision=payload.decision,
notes=payload.notes.strip() if payload.notes and payload.notes.strip() else None,
reviewed_by=payload.reviewed_by.strip(),
)
else:
review.decision = payload.decision
review.notes = payload.notes.strip() if payload.notes and payload.notes.strip() else None
review.reviewed_by = payload.reviewed_by.strip()
db.add(review)
db.commit()
db.refresh(review)
return DetectionReviewService._read_item(
db,
quality_check,
{"evidence_role": payload.evidence_role, "evidence_feature_id": payload.evidence_feature_id},
review,
)