feat: close measured model review evidence
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 03:34:04 +02:00
parent 2a9b48d2b5
commit 919e102069
11 changed files with 512 additions and 9 deletions
+17
View File
@@ -964,6 +964,23 @@ geometry and green is a matched reference. Selection is deterministic and
stratified by AOI and geodetic area bucket. Every CSV decision starts as
`unreviewed`; no positive-training example is inferred.
Validate the completed false-negative decisions symmetrically with the
false-positive workflow:
```bash
docker exec geointel /opt/geointel/venv/bin/python \
/app/scripts/validate_detection_false_negative_review_decisions.py \
--review-summary /app/storage/operator-data/model-review/small-building-candidate/false-negative-visual-review/detection_false_negative_review_summary.json \
--decisions-csv /app/storage/operator-data/model-review/small-building-candidate/false-negative-visual-review/false_negative_review_decisions.csv \
--output-dir /app/storage/operator-data/model-review/small-building-candidate/false-negative-visual-review/validated \
--require-complete
```
The validator exits with code `2` while any row remains `unreviewed`. It emits
only explicit `confirmed_model_false_negative` geometries; alignment,
reference-gap and uncertain imagery decisions never become positive training
labels.
Reference features that do not intersect any persisted inference tile are not
silently counted as reviewable model misses. They are reported separately in
`false_negatives_outside_tile_coverage.geojson` with
+1
View File
@@ -64,6 +64,7 @@ ${PYTHON_BIN} -m py_compile scripts/audit_detection_false_positive_evidence.py
${PYTHON_BIN} -m py_compile scripts/render_detection_false_positive_review_contact_sheets.py
${PYTHON_BIN} -m py_compile scripts/render_detection_false_negative_review_contact_sheets.py
${PYTHON_BIN} -m py_compile scripts/validate_detection_false_positive_review_decisions.py
${PYTHON_BIN} -m py_compile scripts/validate_detection_false_negative_review_decisions.py
${PYTHON_BIN} -m py_compile scripts/activate_promoted_yolo_candidate.py
${PYTHON_BIN} -m py_compile scripts/cleanup_demo_artifacts.py
${PYTHON_BIN} -m py_compile backend/scripts/cleanup_demo_artifacts.py
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Validate explicit operator decisions for detection QA false-negatives."""
from __future__ import annotations
import argparse
import csv
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
JSON_NAME = "detection_false_negative_review_validation.json"
MARKDOWN_NAME = "detection_false_negative_review_validation.md"
CONFIRMED_NAME = "confirmed_model_false_negatives.geojson"
DECISIONS = (
"confirmed_model_false_negative",
"qa_alignment_mismatch",
"reference_gap_or_change",
"imagery_obscured_or_uncertain",
"unreviewed",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate manual false-negative review decisions without inferring labels."
)
parser.add_argument("--review-summary", required=True, type=Path)
parser.add_argument("--decisions-csv", required=True, type=Path)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument(
"--require-complete",
action="store_true",
help="Return a non-zero exit code while any selected record remains unreviewed.",
)
return parser.parse_args()
def load_json(path: Path) -> dict[str, Any]:
if not path.is_file():
raise SystemExit(f"Review summary is not readable: {path}")
payload = json.loads(path.read_text(encoding="utf-8-sig"))
if not isinstance(payload, dict):
raise SystemExit(f"Review summary must be a JSON object: {path}")
return payload
def load_decisions(path: Path) -> list[dict[str, str]]:
if not path.is_file():
raise SystemExit(f"Review decisions CSV is not readable: {path}")
with path.open(newline="", encoding="utf-8-sig") as handle:
reader = csv.DictReader(handle)
required = {"reference_feature_id", "review_decision", "review_notes"}
missing = required - set(reader.fieldnames or [])
if missing:
raise SystemExit(
"Review decisions CSV lacks required columns: "
+ ", ".join(sorted(missing))
)
return [dict(row) for row in reader]
def validate(
summary: dict[str, Any], rows: list[dict[str, str]]
) -> tuple[dict[str, Any], dict[str, Any]]:
features = summary.get("selected_features") or []
if not isinstance(features, list) or not all(
isinstance(item, dict) for item in features
):
raise SystemExit("Review summary selected_features must be a list of objects")
expected: dict[str, dict[str, Any]] = {}
for feature in features:
reference_id = str(feature.get("reference_feature_id") or "").strip()
if not reference_id or reference_id in expected:
raise SystemExit(
"Review summary contains a missing or duplicate reference_feature_id"
)
expected[reference_id] = feature
provided: dict[str, dict[str, str]] = {}
for row in rows:
reference_id = str(row.get("reference_feature_id") or "").strip()
if not reference_id or reference_id in provided:
raise SystemExit(
"Review decisions contain a missing or duplicate reference_feature_id"
)
decision = str(row.get("review_decision") or "").strip()
if decision not in DECISIONS:
raise SystemExit(
f"Invalid review decision for {reference_id}: {decision}. "
+ "Allowed values: "
+ ", ".join(DECISIONS)
)
provided[reference_id] = row
missing_ids = set(expected) - set(provided)
extra_ids = set(provided) - set(expected)
if missing_ids or extra_ids:
details = []
if missing_ids:
details.append("missing: " + ", ".join(sorted(missing_ids)))
if extra_ids:
details.append("unexpected: " + ", ".join(sorted(extra_ids)))
raise SystemExit(
"Review decisions do not match the selected evidence ("
+ "; ".join(details)
+ ")"
)
counts = {decision: 0 for decision in DECISIONS}
confirmed_features: list[dict[str, Any]] = []
for reference_id, feature in expected.items():
row = provided[reference_id]
decision = row["review_decision"].strip()
counts[decision] += 1
if decision != "confirmed_model_false_negative":
continue
source_properties = feature.get("properties") or {}
properties = (
dict(source_properties) if isinstance(source_properties, dict) else {}
)
properties.update(
{
"reference_feature_id": reference_id,
"sample_slug": feature.get("sample_slug"),
"area_m2": feature.get("area_m2"),
"area_bucket": feature.get("area_bucket"),
"source_tile_path": feature.get("source_tile_path"),
"review_decision": decision,
"review_notes": row.get("review_notes", "").strip(),
}
)
confirmed_features.append(
{
"type": "Feature",
"id": f"confirmed_model_false_negative:{reference_id}",
"geometry": feature.get("geometry"),
"properties": properties,
}
)
status = "complete" if counts["unreviewed"] == 0 else "review_required"
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"schema_version": 1,
"status": status,
"review_summary_path": str(summary.get("portfolio_path") or ""),
"selected_feature_count": len(features),
"decision_counts": counts,
"confirmed_model_false_negative_count": len(confirmed_features),
"safety_rule": (
"Only explicit confirmed_model_false_negative decisions are exported; "
"QA false-negatives are never inferred as model training labels."
),
}
return report, {"type": "FeatureCollection", "features": confirmed_features}
def write_markdown(report: dict[str, Any], output_dir: Path) -> None:
lines = [
"# Detection false-negative review validation",
"",
f"- Status: `{report['status']}`",
f"- Selected records: {report['selected_feature_count']}",
f"- Confirmed model false-negatives: {report['confirmed_model_false_negative_count']}",
"",
"## Decision counts",
"",
]
lines.extend(
f"- `{decision}`: {count}"
for decision, count in report["decision_counts"].items()
)
lines.extend(["", f"> {report['safety_rule']}", ""])
(output_dir / MARKDOWN_NAME).write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
args = parse_args()
summary_path = args.review_summary.expanduser().resolve()
decisions_path = args.decisions_csv.expanduser().resolve()
output_dir = args.output_dir.expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
report, confirmed = validate(load_json(summary_path), load_decisions(decisions_path))
report["review_summary_path"] = str(summary_path)
report["decisions_csv_path"] = str(decisions_path)
(output_dir / JSON_NAME).write_text(
json.dumps(report, indent=2, sort_keys=True), encoding="utf-8"
)
(output_dir / CONFIRMED_NAME).write_text(
json.dumps(confirmed, indent=2, sort_keys=True), encoding="utf-8"
)
write_markdown(report, output_dir)
print(f"False-negative review status: {report['status']}")
print(f"Validation: {output_dir / JSON_NAME}")
print(f"Confirmed evidence: {output_dir / CONFIRMED_NAME}")
if args.require_complete and report["status"] != "complete":
print("Review remains incomplete", flush=True)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())