Add V1 report handoff summary
This commit is contained in:
@@ -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>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user