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
+7
View File
@@ -7,6 +7,13 @@
# Changelog
## Sprint 23 V1 report handoff summary (2026-06-17)
- Added V1 readiness summary data to project metadata exports.
- Added a V1 Readiness Summary and Known Limitations section to lightweight HTML project reports.
- The summary covers project, AOI, dataset readiness, QA/QC and export history using persisted state.
- No new report designer, PDF generation, provider fetching, AI inference, migrations or API route changes were introduced.
## Sprint 22 V1 workbench status strip (2026-06-17)
- Added a compact frontend status strip for project, AOI, datasets, active map layer, QA/QC and exports.
+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
+3 -2
View File
@@ -1026,8 +1026,9 @@ Export annotations/detections to YOLO format.
### POST `/api/v1/exports/report`
Creates a lightweight HTML project report artifact from persisted project,
dataset, QA/QC summary and export history state. This does not create a PDF
and does not introduce a report designer.
dataset, V1 readiness summary, QA/QC summary, known limitations and export
history state. This does not create a PDF and does not introduce a report
designer.
```json
{
+16
View File
@@ -1,3 +1,19 @@
## Sprint 23 V1 report handoff summary (2026-06-17)
Changed:
- Added V1 readiness summary data to project metadata exports.
- Added V1 Readiness Summary and Known Limitations sections to lightweight HTML project report exports.
- Included persisted AOI, dataset readiness, QA/QC and export-history counts in the handoff summary.
- Updated export tests, API contract docs, frontend README, TODO and changelog.
Tested:
- Passed: backend compile, export tests through full readiness, full backend pytest, frontend typecheck/build, Alembic heads, Alembic SQL upgrade and live smoke syntax check.
Known limitations:
- Report export remains a lightweight HTML artifact, not a PDF designer or custom report builder.
Next recommended pass:
- Add optional cleanup tooling for stale demo/export artifacts if repeated smoke runs keep accumulating local artifacts.
## Sprint 22 V1 workbench status strip (2026-06-17)
Changed:
+1
View File
@@ -30,6 +30,7 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Project-scoped QA/QC result listing and frontend QA/QC Results panel.
- [x] Persisted export foundation for vector/detection/segmentation GeoJSON and project metadata JSON.
- [x] Lightweight HTML project report artifact export.
- [x] V1 readiness handoff summary in project metadata/report exports.
- [x] Browser-facing demo/export workflow smoke script with connected V1 state checks.
- [x] Compact V1 workbench status strip for project, AOI, datasets, map, QA/QC and exports.
- [x] Live Docker/PostGIS validation on Tower/Unraid.
+6
View File
@@ -164,6 +164,12 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
- The status strip is implemented in `src/components/WorkbenchStatusStrip.tsx` and remains driven by `App.tsx` orchestration state.
- MapLibre source/layer updates now wait for style readiness to avoid runtime blank-screen failures during first render.
## Sprint 23 additions
- Project report HTML exports now include a V1 readiness summary.
- The report summary covers project, AOI, datasets, QA/QC, exports and known limitations.
- Export Center behavior is unchanged; the richer handoff content is produced by the existing project report export action.
## Release hardening updates
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.