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 # 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) ## 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. - 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 sqlalchemy.orm import Session
from app.core.errors import AppError 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.schemas.export import ExportContentResponse, ExportCreateResponse, ExportListResponse, ExportRead
from app.services.dataset_service import DatasetService from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService from app.services.detection_service import DetectionService
@@ -122,6 +122,7 @@ class ExportService:
"dataset_count": len(content["datasets"]), "dataset_count": len(content["datasets"]),
"quality_check_count": len(content["quality_checks"]), "quality_check_count": len(content["quality_checks"]),
"export_count": len(content["exports"]), "export_count": len(content["exports"]),
"readiness_state": content["readiness_summary"]["overall_state"],
} }
export = ExportService._write_json_export( export = ExportService._write_json_export(
db, db,
@@ -150,6 +151,7 @@ class ExportService:
"dataset_count": len(summary["datasets"]), "dataset_count": len(summary["datasets"]),
"quality_check_count": len(summary["quality_checks"]), "quality_check_count": len(summary["quality_checks"]),
"export_count": len(summary["exports"]), "export_count": len(summary["exports"]),
"readiness_state": summary["readiness_summary"]["overall_state"],
"format": "html", "format": "html",
} }
export = ExportService._write_text_export( export = ExportService._write_text_export(
@@ -282,6 +284,7 @@ class ExportService:
@staticmethod @staticmethod
def _project_summary(db: Session, project: Project) -> dict[str, Any]: 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() datasets = db.query(Dataset).filter(Dataset.project_id == project.id).order_by(Dataset.created_at.desc()).all()
quality_checks = ( quality_checks = (
db.query(QualityCheck) db.query(QualityCheck)
@@ -290,7 +293,7 @@ class ExportService:
.all() .all()
) )
exports = db.query(Export).filter(Export.project_id == project.id).order_by(Export.created_at.desc()).all() exports = db.query(Export).filter(Export.project_id == project.id).order_by(Export.created_at.desc()).all()
return { summary = {
"project": { "project": {
"id": str(project.id), "id": str(project.id),
"name": project.name, "name": project.name,
@@ -298,6 +301,16 @@ class ExportService:
"region": project.region, "region": project.region,
"status": project.status, "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": [ "datasets": [
{ {
"id": str(dataset.id), "id": str(dataset.id),
@@ -337,6 +350,77 @@ class ExportService:
for export in exports 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 @staticmethod
def _render_project_report_html(summary: dict[str, Any]) -> str: def _render_project_report_html(summary: dict[str, Any]) -> str:
@@ -344,6 +428,17 @@ class ExportService:
datasets = summary["datasets"] datasets = summary["datasets"]
quality_checks = summary["quality_checks"] quality_checks = summary["quality_checks"]
exports = summary["exports"] 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( dataset_rows = "\n".join(
"<tr>" "<tr>"
f"<td>{escape(str(item['name']))}</td>" 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, td {{ border: 1px solid #cbd5e1; padding: 0.5rem; text-align: left; }}
th {{ background: #e2e8f0; }} th {{ background: #e2e8f0; }}
.muted {{ color: #475569; }} .muted {{ color: #475569; }}
.status-ready {{ color: #166534; font-weight: 700; }}
.status-needs_attention {{ color: #92400e; font-weight: 700; }}
</style> </style>
</head> </head>
<body> <body>
@@ -391,6 +488,12 @@ class ExportService:
<p>Region: {escape(str(project["region"]))}</p> <p>Region: {escape(str(project["region"]))}</p>
<p>Status: {escape(str(project["status"]))}</p> <p>Status: {escape(str(project["status"]))}</p>
<p>Description: {escape(str(project["description"] or "n/a"))}</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> <h2>Datasets ({len(datasets)})</h2>
<table> <table>
<thead><tr><th>Name</th><th>Type</th><th>Role</th><th>Status</th><th>Features</th></tr></thead> <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> <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> <tbody>{export_rows or '<tr><td colspan="3">No exports</td></tr>'}</tbody>
</table> </table>
<h2>Known Limitations</h2>
<ul>{limitation_items}</ul>
</body> </body>
</html> </html>
""" """
@@ -8,7 +8,7 @@ from fastapi.testclient import TestClient
from app.core.errors import AppError from app.core.errors import AppError
from app.main import app 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.schemas.export import ExportCreateResponse
from app.services.export_service import ExportService from app.services.export_service import ExportService
from app.services.storage_service import StorageService 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() dataset_id = uuid4()
quality_check_id = uuid4() quality_check_id = uuid4()
project = Project(id=project_id, name="Demo", region="Kempen", status="active") 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( dataset = Dataset(
id=dataset_id, id=dataset_id,
project_id=project_id, project_id=project_id,
@@ -154,6 +155,7 @@ def test_project_metadata_export_persists_json_summary(tmp_path, monkeypatch) ->
db = FakeSession( db = FakeSession(
{ {
(Project, project_id): project, (Project, project_id): project,
(Area, area.id): area,
(Dataset, dataset_id): dataset, (Dataset, dataset_id): dataset,
(QualityCheck, quality_check_id): quality_check, (QualityCheck, quality_check_id): quality_check,
(Export, previous_export_id): previous_export, (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")) payload = json.loads(export_path.read_text(encoding="utf-8"))
assert response.export_type == "project_metadata_json" assert response.export_type == "project_metadata_json"
assert payload["project"]["id"] == str(project_id) 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["datasets"][0]["id"] == str(dataset_id)
assert payload["quality_checks"][0]["id"] == str(quality_check_id) assert payload["quality_checks"][0]["id"] == str(quality_check_id)
assert payload["exports"][0]["id"] == str(previous_export_id) assert payload["exports"][0]["id"] == str(previous_export_id)
assert response.metadata_json["export_count"] == 1 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: def test_project_report_export_persists_html_artifact(tmp_path, monkeypatch) -> None:
project_id = uuid4() project_id = uuid4()
dataset_id = uuid4() dataset_id = uuid4()
project = Project(id=project_id, name="Demo <Kempen>", description="QA report", region="Kempen", status="active") 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( dataset = Dataset(
id=dataset_id, id=dataset_id,
project_id=project_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), created_at=datetime.now(timezone.utc),
) )
export_path = tmp_path / "report.html" 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)) monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_project_report(db, project_id) 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") html = export_path.read_text(encoding="utf-8")
assert response.export_type == "project_report_html" assert response.export_type == "project_report_html"
assert response.metadata_json["format"] == "html" assert response.metadata_json["format"] == "html"
assert response.metadata_json["readiness_state"] == "ready"
assert "<!doctype html>" in html assert "<!doctype html>" in html
assert "Demo &lt;Kempen&gt;" 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 "reference.geojson" in html
assert "Export History (1)" in html assert "Export History (1)" in html
assert "project_metadata_json" 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` ### POST `/api/v1/exports/report`
Creates a lightweight HTML project report artifact from persisted project, Creates a lightweight HTML project report artifact from persisted project,
dataset, QA/QC summary and export history state. This does not create a PDF dataset, V1 readiness summary, QA/QC summary, known limitations and export
and does not introduce a report designer. history state. This does not create a PDF and does not introduce a report
designer.
```json ```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) ## Sprint 22 V1 workbench status strip (2026-06-17)
Changed: 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] 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] Persisted export foundation for vector/detection/segmentation GeoJSON and project metadata JSON.
- [x] Lightweight HTML project report artifact export. - [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] 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] Compact V1 workbench status strip for project, AOI, datasets, map, QA/QC and exports.
- [x] Live Docker/PostGIS validation on Tower/Unraid. - [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. - 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. - 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 ## Release hardening updates
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks. - Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.