Add V1 report handoff summary
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 01:30:53 +02:00
parent 014d088866
commit a4bcbea9f1
7 changed files with 171 additions and 6 deletions
+107 -2
View File
@@ -10,7 +10,7 @@ from typing import Any
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models import AnalysisRun, Dataset, Export, Project, QualityCheck
from app.models import AnalysisRun, Area, Dataset, Export, Project, QualityCheck
from app.schemas.export import ExportContentResponse, ExportCreateResponse, ExportListResponse, ExportRead
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
@@ -122,6 +122,7 @@ class ExportService:
"dataset_count": len(content["datasets"]),
"quality_check_count": len(content["quality_checks"]),
"export_count": len(content["exports"]),
"readiness_state": content["readiness_summary"]["overall_state"],
}
export = ExportService._write_json_export(
db,
@@ -150,6 +151,7 @@ class ExportService:
"dataset_count": len(summary["datasets"]),
"quality_check_count": len(summary["quality_checks"]),
"export_count": len(summary["exports"]),
"readiness_state": summary["readiness_summary"]["overall_state"],
"format": "html",
}
export = ExportService._write_text_export(
@@ -282,6 +284,7 @@ class ExportService:
@staticmethod
def _project_summary(db: Session, project: Project) -> dict[str, Any]:
areas = db.query(Area).filter(Area.project_id == project.id).order_by(Area.created_at.desc()).all()
datasets = db.query(Dataset).filter(Dataset.project_id == project.id).order_by(Dataset.created_at.desc()).all()
quality_checks = (
db.query(QualityCheck)
@@ -290,7 +293,7 @@ class ExportService:
.all()
)
exports = db.query(Export).filter(Export.project_id == project.id).order_by(Export.created_at.desc()).all()
return {
summary = {
"project": {
"id": str(project.id),
"name": project.name,
@@ -298,6 +301,16 @@ class ExportService:
"region": project.region,
"status": project.status,
},
"areas": [
{
"id": str(area.id),
"name": area.name,
"original_crs": area.original_crs,
"area_m2": area.area_m2,
"created_at": area.created_at.isoformat() if area.created_at else None,
}
for area in areas
],
"datasets": [
{
"id": str(dataset.id),
@@ -337,6 +350,77 @@ class ExportService:
for export in exports
],
}
summary["readiness_summary"] = ExportService._build_readiness_summary(summary)
summary["known_limitations"] = [
"Report artifact is a lightweight HTML handoff, not a PDF designer.",
"No live GRB/OSM/Sentinel fetching is performed by the report export.",
"AI detections or segmentations are included only when they already exist as persisted records/exports.",
]
return summary
@staticmethod
def _build_readiness_summary(summary: dict[str, Any]) -> dict[str, Any]:
project = summary["project"]
areas = summary["areas"]
datasets = summary["datasets"]
quality_checks = summary["quality_checks"]
exports = summary["exports"]
ready_datasets = [dataset for dataset in datasets if dataset["status"] == "ready"]
vector_datasets = [dataset for dataset in datasets if dataset["dataset_type"] in {"vector", "geojson"}]
raster_datasets = [dataset for dataset in datasets if dataset["dataset_type"] == "raster"]
reference_datasets = [dataset for dataset in datasets if dataset["dataset_role"] == "reference"]
items = [
{
"key": "project",
"label": "Project",
"state": "ready" if project["status"] != "deleted" else "blocked",
"detail": f"{project['name']} ({project['region']})",
},
{
"key": "aoi",
"label": "AOI",
"state": "ready" if areas else "waiting",
"detail": f"{len(areas)} area{'s' if len(areas) != 1 else ''}",
},
{
"key": "datasets",
"label": "Datasets",
"state": "ready" if datasets and len(ready_datasets) == len(datasets) else "waiting" if not datasets else "warning",
"detail": (
f"{len(ready_datasets)}/{len(datasets)} ready; "
f"{len(vector_datasets)} vector, {len(raster_datasets)} raster, {len(reference_datasets)} reference"
),
},
{
"key": "qa",
"label": "QA/QC",
"state": "ready" if quality_checks else "waiting",
"detail": f"{len(quality_checks)} persisted check{'s' if len(quality_checks) != 1 else ''}",
},
{
"key": "exports",
"label": "Exports",
"state": "ready" if exports else "waiting",
"detail": f"{len(exports)} previous export{'s' if len(exports) != 1 else ''}",
},
]
overall_state = "ready" if all(item["state"] == "ready" for item in items) else "needs_attention"
return {
"overall_state": overall_state,
"items": items,
"counts": {
"area_count": len(areas),
"dataset_count": len(datasets),
"ready_dataset_count": len(ready_datasets),
"vector_dataset_count": len(vector_datasets),
"raster_dataset_count": len(raster_datasets),
"reference_dataset_count": len(reference_datasets),
"quality_check_count": len(quality_checks),
"export_count": len(exports),
},
}
@staticmethod
def _render_project_report_html(summary: dict[str, Any]) -> str:
@@ -344,6 +428,17 @@ class ExportService:
datasets = summary["datasets"]
quality_checks = summary["quality_checks"]
exports = summary["exports"]
readiness_summary = summary["readiness_summary"]
known_limitations = summary["known_limitations"]
readiness_rows = "\n".join(
"<tr>"
f"<td>{escape(str(item['label']))}</td>"
f"<td>{escape(str(item['state']))}</td>"
f"<td>{escape(str(item['detail']))}</td>"
"</tr>"
for item in readiness_summary["items"]
)
limitation_items = "\n".join(f"<li>{escape(str(item))}</li>" for item in known_limitations)
dataset_rows = "\n".join(
"<tr>"
f"<td>{escape(str(item['name']))}</td>"
@@ -383,6 +478,8 @@ class ExportService:
th, td {{ border: 1px solid #cbd5e1; padding: 0.5rem; text-align: left; }}
th {{ background: #e2e8f0; }}
.muted {{ color: #475569; }}
.status-ready {{ color: #166534; font-weight: 700; }}
.status-needs_attention {{ color: #92400e; font-weight: 700; }}
</style>
</head>
<body>
@@ -391,6 +488,12 @@ class ExportService:
<p>Region: {escape(str(project["region"]))}</p>
<p>Status: {escape(str(project["status"]))}</p>
<p>Description: {escape(str(project["description"] or "n/a"))}</p>
<h2>V1 Readiness Summary</h2>
<p>Overall state: <span class="status-{escape(str(readiness_summary['overall_state']))}">{escape(str(readiness_summary["overall_state"]))}</span></p>
<table>
<thead><tr><th>Area</th><th>State</th><th>Detail</th></tr></thead>
<tbody>{readiness_rows}</tbody>
</table>
<h2>Datasets ({len(datasets)})</h2>
<table>
<thead><tr><th>Name</th><th>Type</th><th>Role</th><th>Status</th><th>Features</th></tr></thead>
@@ -406,6 +509,8 @@ class ExportService:
<thead><tr><th>Type</th><th>Storage path</th><th>Created</th></tr></thead>
<tbody>{export_rows or '<tr><td colspan="3">No exports</td></tr>'}</tbody>
</table>
<h2>Known Limitations</h2>
<ul>{limitation_items}</ul>
</body>
</html>
"""
@@ -8,7 +8,7 @@ from fastapi.testclient import TestClient
from app.core.errors import AppError
from app.main import app
from app.models import Dataset, Export, Project, QualityCheck
from app.models import Area, Dataset, Export, Project, QualityCheck
from app.schemas.export import ExportCreateResponse
from app.services.export_service import ExportService
from app.services.storage_service import StorageService
@@ -121,6 +121,7 @@ def test_project_metadata_export_persists_json_summary(tmp_path, monkeypatch) ->
dataset_id = uuid4()
quality_check_id = uuid4()
project = Project(id=project_id, name="Demo", region="Kempen", status="active")
area = Area(id=uuid4(), project_id=project_id, name="Demo AOI", original_crs="EPSG:4326", area_m2=100.0)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
@@ -154,6 +155,7 @@ def test_project_metadata_export_persists_json_summary(tmp_path, monkeypatch) ->
db = FakeSession(
{
(Project, project_id): project,
(Area, area.id): area,
(Dataset, dataset_id): dataset,
(QualityCheck, quality_check_id): quality_check,
(Export, previous_export_id): previous_export,
@@ -166,16 +168,22 @@ def test_project_metadata_export_persists_json_summary(tmp_path, monkeypatch) ->
payload = json.loads(export_path.read_text(encoding="utf-8"))
assert response.export_type == "project_metadata_json"
assert payload["project"]["id"] == str(project_id)
assert payload["areas"][0]["name"] == "Demo AOI"
assert payload["readiness_summary"]["overall_state"] == "ready"
assert payload["readiness_summary"]["counts"]["area_count"] == 1
assert payload["known_limitations"]
assert payload["datasets"][0]["id"] == str(dataset_id)
assert payload["quality_checks"][0]["id"] == str(quality_check_id)
assert payload["exports"][0]["id"] == str(previous_export_id)
assert response.metadata_json["export_count"] == 1
assert response.metadata_json["readiness_state"] == "ready"
def test_project_report_export_persists_html_artifact(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
project = Project(id=project_id, name="Demo <Kempen>", description="QA report", region="Kempen", status="active")
area = Area(id=uuid4(), project_id=project_id, name="Demo AOI", original_crs="EPSG:4326", area_m2=100.0)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
@@ -196,7 +204,24 @@ def test_project_report_export_persists_html_artifact(tmp_path, monkeypatch) ->
created_at=datetime.now(timezone.utc),
)
export_path = tmp_path / "report.html"
db = FakeSession({(Project, project_id): project, (Dataset, dataset_id): dataset, (Export, previous_export_id): previous_export})
quality_check = QualityCheck(
id=uuid4(),
project_id=project_id,
reference_dataset_id=dataset_id,
check_type="demo_candidate_vs_reference",
status="ok",
score=0.5,
created_at=datetime.now(timezone.utc),
)
db = FakeSession(
{
(Project, project_id): project,
(Area, area.id): area,
(Dataset, dataset_id): dataset,
(QualityCheck, quality_check.id): quality_check,
(Export, previous_export_id): previous_export,
}
)
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_project_report(db, project_id)
@@ -204,8 +229,12 @@ def test_project_report_export_persists_html_artifact(tmp_path, monkeypatch) ->
html = export_path.read_text(encoding="utf-8")
assert response.export_type == "project_report_html"
assert response.metadata_json["format"] == "html"
assert response.metadata_json["readiness_state"] == "ready"
assert "<!doctype html>" in html
assert "Demo &lt;Kempen&gt;" in html
assert "V1 Readiness Summary" in html
assert "Overall state:" in html
assert "No live GRB/OSM/Sentinel fetching is performed by the report export." in html
assert "reference.geojson" in html
assert "Export History (1)" in html
assert "project_metadata_json" in html