From a4bcbea9f1e16fc839bbc0b8f83b8769dcf4891b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 01:30:53 +0200 Subject: [PATCH] Add V1 report handoff summary --- CHANGELOG.md | 7 ++ backend/app/services/export_service.py | 109 +++++++++++++++++- .../tests/test_sprint17_export_foundation.py | 33 +++++- docs/API_CONTRACTS.md | 5 +- docs/CODEX_EXECUTION_LOG.md | 16 +++ docs/TODO.md | 1 + frontend/README.md | 6 + 7 files changed, 171 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce361cbe..9903d059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 1af38204..163a108d 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -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( + "" + f"{escape(str(item['label']))}" + f"{escape(str(item['state']))}" + f"{escape(str(item['detail']))}" + "" + for item in readiness_summary["items"] + ) + limitation_items = "\n".join(f"
  • {escape(str(item))}
  • " for item in known_limitations) dataset_rows = "\n".join( "" f"{escape(str(item['name']))}" @@ -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; }} @@ -391,6 +488,12 @@ class ExportService:

    Region: {escape(str(project["region"]))}

    Status: {escape(str(project["status"]))}

    Description: {escape(str(project["description"] or "n/a"))}

    +

    V1 Readiness Summary

    +

    Overall state: {escape(str(readiness_summary["overall_state"]))}

    + + + {readiness_rows} +
    AreaStateDetail

    Datasets ({len(datasets)})

    @@ -406,6 +509,8 @@ class ExportService: {export_rows or ''}
    NameTypeRoleStatusFeatures
    TypeStorage pathCreated
    No exports
    +

    Known Limitations

    + """ diff --git a/backend/tests/test_sprint17_export_foundation.py b/backend/tests/test_sprint17_export_foundation.py index 0e74f842..3c49e993 100644 --- a/backend/tests/test_sprint17_export_foundation.py +++ b/backend/tests/test_sprint17_export_foundation.py @@ -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 ", 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 "" in html assert "Demo <Kempen>" 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 diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 2364de83..67ad86f1 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -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 { diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 1d869277..d7d95037 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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: diff --git a/docs/TODO.md b/docs/TODO.md index 9232e8d6..552fbbe9 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. diff --git a/frontend/README.md b/frontend/README.md index 771f9f00..be8dfd58 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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.