from __future__ import annotations import json import re import uuid from html import escape from pathlib import Path from typing import Any from sqlalchemy.orm import Session from app.core.errors import AppError from app.models import AnalysisRun, Area, Dataset, Export, Project, QualityCheck from app.schemas.dhmv import TerrainPartitionSelectionRequest, TerrainSelectionRequest from app.schemas.export import ( ExportContentResponse, ExportCreateResponse, ExportListResponse, ExportRead, MapResultExportRequest, ) from app.schemas.flood_hazard import FloodHazardPartitionSelectionRequest, FloodHazardSelectionRequest from app.schemas.temporal import TemporalComparisonRequest from app.schemas.thematic_raster import ThematicRasterSelectionRequest from app.services.dataset_service import DatasetService from app.services.detection_service import DetectionService from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService from app.services.segmentation_service import SegmentationService from app.services.storage_service import StorageService from app.services.temporal_analysis_service import TemporalAnalysisService from app.services.terrain_analysis_service import TerrainAnalysisService from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService from app.services.vector_feature_service import VectorFeatureService class ExportService: @staticmethod def export_map_result( db: Session, payload: MapResultExportRequest, ) -> ExportCreateResponse: if payload.mode == "evolution": comparison = TemporalAnalysisService.compare( db, project_id=payload.project_id, payload=TemporalComparisonRequest( earlier_dataset_id=payload.earlier_dataset_id, later_dataset_id=payload.later_dataset_id, bbox=payload.bbox, area_id=payload.area_id, ), ) content = comparison.model_dump(mode="json") target_id = str(payload.later_dataset_id) filename = ExportService._filename( payload.name, f"{target_id}-evolution.json", ".json", ) export_path = StorageService.dataset_export_path( str(payload.project_id), target_id, filename, ) metadata = { "source": "map_evolution", "project_id": str(payload.project_id), "earlier_dataset_id": str(payload.earlier_dataset_id), "later_dataset_id": str(payload.later_dataset_id), "selection_bbox": payload.bbox.model_dump(mode="json"), "selection_area_id": str(payload.area_id) if payload.area_id else None, "theme_id": payload.theme_id, "server_recomputed": True, } export = ExportService._write_json_export( db, project_id=payload.project_id, analysis_run_id=None, export_type="map_evolution_json", storage_path=export_path, content=content, metadata=metadata, ) return ExportService._create_response(export) dataset = db.get(Dataset, payload.dataset_id) if not dataset or dataset.project_id != payload.project_id: raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404) if dataset.dataset_type in DatasetService.VECTOR_TYPES: if payload.partitioned: return ExportService.export_partitioned_vector_selection_geojson( db, dataset, payload.bbox.model_dump(mode="json"), partition_scope_key=payload.partition_scope_key or "", area_id=payload.area_id, name=payload.name, limit=1000, ) return ExportService.export_vector_selection_geojson( db, dataset.id, payload.bbox.model_dump(mode="json"), area_id=payload.area_id, name=payload.name, limit=1000, ) if dataset.dataset_type != "raster": raise AppError( code="INVALID_DATASET_TYPE", message="Map-result export requires a vector or governed raster dataset", details={"dataset_type": dataset.dataset_type}, status_code=400, ) if dataset.source_name == "digitaal_vlaanderen_dhmv": result = ( TerrainAnalysisService.analyze_partitions( db, payload.project_id, TerrainPartitionSelectionRequest( bbox=payload.bbox, area_id=payload.area_id, product_key=payload.product_key or "dtm_1m", ), ) if payload.partitioned else TerrainAnalysisService.analyze( db, payload.project_id, dataset.id, TerrainSelectionRequest(bbox=payload.bbox, area_id=payload.area_id), ) ) elif dataset.source_name == "vmm_flood_hazard": result = ( FloodHazardAnalysisService.analyze_partitions( db, payload.project_id, FloodHazardPartitionSelectionRequest( bbox=payload.bbox, area_id=payload.area_id, product_key=payload.product_key or "pluviaal_current_t100", ), ) if payload.partitioned else FloodHazardAnalysisService.analyze( db, payload.project_id, dataset.id, FloodHazardSelectionRequest(bbox=payload.bbox, area_id=payload.area_id), ) ) elif dataset.source_name == "department_omgeving_thematic_raster": result = ThematicRasterAnalysisService.analyze( db, payload.project_id, dataset.id, ThematicRasterSelectionRequest(bbox=payload.bbox, area_id=payload.area_id), ) else: raise AppError( code="MAP_RESULT_EXPORT_UNSUPPORTED", message="This raster source does not expose a governed map-result export", details={"source_name": dataset.source_name}, status_code=400, ) content = { "mode": "current", "theme_id": payload.theme_id, "dataset": { "id": str(dataset.id), "name": dataset.name, "source_name": dataset.source_name, }, "result": result, } filename = ExportService._filename( payload.name, f"{dataset.id}-map-analysis.json", ".json", ) export_path = StorageService.dataset_export_path( str(payload.project_id), str(dataset.id), filename, ) metadata = { "source": "map_analysis", "project_id": str(payload.project_id), "dataset_id": str(dataset.id), "selection_bbox": payload.bbox.model_dump(mode="json"), "selection_area_id": str(payload.area_id) if payload.area_id else None, "theme_id": payload.theme_id, "partitioned": payload.partitioned, "product_key": payload.product_key, "server_recomputed": True, } export = ExportService._write_json_export( db, project_id=payload.project_id, analysis_run_id=None, export_type="map_analysis_json", storage_path=export_path, content=content, metadata=metadata, ) return ExportService._create_response(export) @staticmethod def export_partitioned_vector_selection_geojson( db: Session, dataset: Dataset, bbox: dict[str, Any], *, partition_scope_key: str, area_id: uuid.UUID | None = None, limit: int = 1000, name: str | None = None, ) -> ExportCreateResponse: if dataset.source_name != "vmm_vha_bathymetry_profiles" or partition_scope_key != "flanders": raise AppError( code="PARTITIONED_VECTOR_EXPORT_UNSUPPORTED", message="This vector source does not expose a governed partitioned export", details={ "source_name": dataset.source_name, "partition_scope_key": partition_scope_key, }, status_code=400, ) selection_geometry = None partition_area_id = None if area_id is not None: area = db.get(Area, area_id) if not area or area.project_id != dataset.project_id: raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) selection_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area( bbox, area.geometry, ) if str(area.name or "").lower().startswith("gemeente "): partition_area_id = area.id selection = VectorFeatureService.select_partitioned_features_by_bbox( db, project_id=dataset.project_id, source_name=dataset.source_name, partition_scope_key=partition_scope_key, bbox=bbox, limit=limit, selection_geometry=selection_geometry, selection_area_id=area_id, partition_area_id=partition_area_id, ) filename = ExportService._filename(name, "bathymetry-profile-selection.geojson", ".geojson") export_path = StorageService.dataset_export_path( str(dataset.project_id), str(dataset.id), filename, ) metadata = { "source": "partitioned_vector_selection", "project_id": str(dataset.project_id), "representative_dataset_id": str(dataset.id), "dataset_ids": [str(value) for value in selection["dataset_ids"]], "source_name": dataset.source_name, "partition_scope_key": partition_scope_key, "partition_count": selection["partition_count"], "available_partition_count": selection["available_partition_count"], "selection_bbox": selection["selection_bbox"], "selection_area_id": selection.get("selection_area_id"), "feature_count": selection["feature_count"], "total_feature_count": selection["total_feature_count"], "limit": selection["limit"], "truncated": selection["truncated"], "source_table": "vector_features", "server_recomputed": True, } export = ExportService._write_json_export( db, project_id=dataset.project_id, analysis_run_id=None, export_type="partitioned_vector_selection_geojson", storage_path=export_path, content=selection["geojson"], metadata=metadata, ) return ExportService._create_response(export) @staticmethod def export_vector_selection_geojson( db: Session, dataset_id: uuid.UUID, bbox: dict[str, Any], area_id: uuid.UUID | None = None, limit: int = 250, name: str | None = None, ) -> ExportCreateResponse: dataset = db.get(Dataset, dataset_id) if not dataset: raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404) if dataset.dataset_type not in DatasetService.VECTOR_TYPES: raise AppError( code="INVALID_DATASET_TYPE", message="Vector selection export requires a vector dataset", details={"dataset_type": dataset.dataset_type}, status_code=400, ) selection_kwargs: dict[str, Any] = { "dataset_id": dataset_id, "bbox": bbox, "limit": limit, } if area_id is not None: area = db.get(Area, area_id) if not area or area.project_id != dataset.project_id: raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) selection_geometry, covers_full_area = VectorFeatureService.constrain_bbox_to_area( bbox, area.geometry, ) full_dataset_area = covers_full_area and VectorFeatureService.can_use_full_area_fast_path( dataset, area.id, ) preclipped_partition_filter = VectorFeatureService.preclipped_partition_filter( dataset, getattr(area, "name", None), ) selection_kwargs.update( selection_geometry=selection_geometry, selection_area_id=area.id, full_dataset_area=full_dataset_area, preclipped_partition_filter=preclipped_partition_filter, ) selection = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs) filename = ExportService._filename(name, f"{dataset.id}-selection.geojson", ".geojson") export_path = StorageService.dataset_export_path(str(dataset.project_id), str(dataset.id), filename) metadata = { "source": "vector_selection", "project_id": str(dataset.project_id), "dataset_id": str(dataset.id), "dataset_type": dataset.dataset_type, "selection_bbox": selection["selection_bbox"], "selection_area_id": selection.get("selection_area_id"), "feature_count": selection["feature_count"], "limit": selection["limit"], "truncated": selection["truncated"], "source_table": "vector_features", } export = ExportService._write_json_export( db, project_id=dataset.project_id, analysis_run_id=None, export_type="vector_selection_geojson", storage_path=export_path, content=selection["geojson"], metadata=metadata, ) return ExportService._create_response(export) @staticmethod def export_dataset_geojson(db: Session, dataset_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse: dataset = db.get(Dataset, dataset_id) if not dataset: raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404) if dataset.dataset_type not in DatasetService.VECTOR_TYPES: raise AppError( code="INVALID_DATASET_TYPE", message="GeoJSON dataset export requires a vector dataset", details={"dataset_type": dataset.dataset_type}, status_code=400, ) feature_collection = DatasetService.get_dataset_geojson(db, dataset_id) filename = ExportService._filename(name, f"{dataset.id}.geojson", ".geojson") export_path = StorageService.dataset_export_path(str(dataset.project_id), str(dataset.id), filename) metadata = { "source": "dataset", "dataset_id": str(dataset.id), "project_id": str(dataset.project_id), "dataset_type": dataset.dataset_type, "feature_count": len(feature_collection.get("features", [])), } export = ExportService._write_json_export( db, project_id=dataset.project_id, analysis_run_id=None, export_type="dataset_geojson", storage_path=export_path, content=feature_collection, metadata=metadata, ) return ExportService._create_response(export) @staticmethod def export_detection_run_geojson(db: Session, analysis_run_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse: run = db.get(AnalysisRun, analysis_run_id) if not run or run.analysis_type != "detection": raise AppError(code="DETECTION_RUN_NOT_FOUND", message="Detection run not found", status_code=404) feature_collection = DetectionService.detections_to_geojson(db, analysis_run_id=analysis_run_id) filename = ExportService._filename(name, f"{run.id}-detections.geojson", ".geojson") export_path = StorageService.dataset_export_path(str(run.project_id), str(run.dataset_id or run.id), filename) metadata = { "source": "detection_run", "analysis_run_id": str(run.id), "project_id": str(run.project_id), "dataset_id": str(run.dataset_id) if run.dataset_id else None, "feature_count": len(feature_collection.get("features", [])), } export = ExportService._write_json_export( db, project_id=run.project_id, analysis_run_id=run.id, export_type="detection_geojson", storage_path=export_path, content=feature_collection, metadata=metadata, ) return ExportService._create_response(export) @staticmethod def export_segmentation_run_geojson(db: Session, analysis_run_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse: run = db.get(AnalysisRun, analysis_run_id) if not run or run.analysis_type != "segmentation": raise AppError(code="SEGMENTATION_RUN_NOT_FOUND", message="Segmentation run not found", status_code=404) feature_collection = SegmentationService.segmentations_to_geojson(db, analysis_run_id=analysis_run_id) filename = ExportService._filename(name, f"{run.id}-segmentations.geojson", ".geojson") export_path = StorageService.dataset_export_path(str(run.project_id), str(run.dataset_id or run.id), filename) metadata = { "source": "segmentation_run", "analysis_run_id": str(run.id), "project_id": str(run.project_id), "dataset_id": str(run.dataset_id) if run.dataset_id else None, "feature_count": len(feature_collection.get("features", [])), } export = ExportService._write_json_export( db, project_id=run.project_id, analysis_run_id=run.id, export_type="segmentation_geojson", storage_path=export_path, content=feature_collection, metadata=metadata, ) return ExportService._create_response(export) @staticmethod def export_project_metadata(db: Session, project_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse: project = db.get(Project, project_id) if not project: raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) content = ExportService._project_summary(db, project) filename = ExportService._filename(name, f"{project.id}-metadata.json", ".json") export_path = StorageService.dataset_export_path(str(project.id), "project", filename) metadata = { "source": "project_metadata", "project_id": str(project.id), "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, project_id=project.id, analysis_run_id=None, export_type="project_metadata_json", storage_path=export_path, content=content, metadata=metadata, ) return ExportService._create_response(export) @staticmethod def export_project_report(db: Session, project_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse: project = db.get(Project, project_id) if not project: raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) summary = ExportService._project_summary(db, project) html = ExportService._render_project_report_html(summary) filename = ExportService._filename(name, f"{project.id}-report.html", ".html") export_path = StorageService.dataset_export_path(str(project.id), "project", filename) metadata = { "source": "project_report", "project_id": str(project.id), "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( db, project_id=project.id, analysis_run_id=None, export_type="project_report_html", storage_path=export_path, content=html, metadata=metadata, ) return ExportService._create_response(export) @staticmethod def list_project_exports(db: Session, project_id: uuid.UUID, limit: int = 50, offset: int = 0) -> ExportListResponse: if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) query = db.query(Export).filter(Export.project_id == project_id).order_by(Export.created_at.desc()) rows = query.offset(offset).limit(limit).all() total = query.count() return ExportListResponse( items=[ExportRead.model_validate(row) for row in rows], total=total, limit=limit, offset=offset, ) @staticmethod def get_export(db: Session, export_id: uuid.UUID) -> ExportRead: export = db.get(Export, export_id) if not export: raise AppError(code="EXPORT_NOT_FOUND", message="Export not found", status_code=404) return ExportRead.model_validate(export) @staticmethod def get_export_content(db: Session, export_id: uuid.UUID) -> ExportContentResponse: export = db.get(Export, export_id) if not export: raise AppError(code="EXPORT_NOT_FOUND", message="Export not found", status_code=404) path = ExportService.get_export_download_path(db, export_id) if export.export_type == "project_report_html" or path.suffix.lower() in {".html", ".htm"}: raise AppError( code="EXPORT_CONTENT_UNSUPPORTED", message="Export content preview is only available for JSON and GeoJSON artifacts. Download HTML report artifacts instead.", details={"export_type": export.export_type}, status_code=415, ) try: content = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise AppError(code="EXPORT_CONTENT_INVALID", message="Export artifact is not valid JSON", status_code=422) from exc return ExportContentResponse(export_id=export.id, export_type=export.export_type, content=content) @staticmethod def get_export_download_path(db: Session, export_id: uuid.UUID) -> Path: export = db.get(Export, export_id) if not export: raise AppError(code="EXPORT_NOT_FOUND", message="Export not found", status_code=404) path = Path(export.storage_path) if not path.exists() or not path.is_file(): raise AppError( code="EXPORT_CONTENT_NOT_FOUND", message="Export artifact is missing from storage", details={"storage_path": export.storage_path}, status_code=404, ) return path @staticmethod def _write_json_export( db: Session, *, project_id: uuid.UUID, analysis_run_id: uuid.UUID | None, export_type: str, storage_path: str, content: dict[str, Any], metadata: dict[str, Any], ) -> Export: path = Path(storage_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8") return ExportService._persist_export( db, project_id=project_id, analysis_run_id=analysis_run_id, export_type=export_type, storage_path=str(path), metadata=metadata, ) @staticmethod def _write_text_export( db: Session, *, project_id: uuid.UUID, analysis_run_id: uuid.UUID | None, export_type: str, storage_path: str, content: str, metadata: dict[str, Any], ) -> Export: path = Path(storage_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") return ExportService._persist_export( db, project_id=project_id, analysis_run_id=analysis_run_id, export_type=export_type, storage_path=str(path), metadata=metadata, ) @staticmethod def _persist_export( db: Session, *, project_id: uuid.UUID, analysis_run_id: uuid.UUID | None, export_type: str, storage_path: str, metadata: dict[str, Any], ) -> Export: export = Export( id=uuid.uuid4(), project_id=project_id, analysis_run_id=analysis_run_id, export_type=export_type, storage_path=storage_path, metadata_json=metadata, ) db.add(export) db.commit() db.refresh(export) return export @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) .filter(QualityCheck.project_id == project.id) .order_by(QualityCheck.created_at.desc()) .all() ) exports = db.query(Export).filter(Export.project_id == project.id).order_by(Export.created_at.desc()).all() summary = { "project": { "id": str(project.id), "name": project.name, "description": project.description, "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), "name": dataset.name, "dataset_type": dataset.dataset_type, "dataset_role": dataset.dataset_role, "source_name": dataset.source_name, "reference_layer_name": dataset.reference_layer_name, "status": dataset.status, "crs": dataset.crs, "bounds_json": dataset.bounds_json, "feature_count": (dataset.metadata_json or {}).get("feature_count"), } for dataset in datasets ], "quality_checks": [ { "id": str(check.id), "analysis_run_id": str(check.analysis_run_id) if check.analysis_run_id else None, "candidate_dataset_id": str(check.candidate_dataset_id) if check.candidate_dataset_id else None, "reference_dataset_id": str(check.reference_dataset_id), "check_type": check.check_type, "status": check.status, "score": check.score, } for check in quality_checks ], "exports": [ { "id": str(export.id), "analysis_run_id": str(export.analysis_run_id) if export.analysis_run_id else None, "export_type": export.export_type, "storage_path": export.storage_path, "metadata_json": export.metadata_json, "created_at": export.created_at.isoformat() if export.created_at else None, } 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: project = summary["project"] datasets = summary["datasets"] quality_checks = summary["quality_checks"] exports = summary["exports"] readiness_summary = summary["readiness_summary"] known_limitations = summary["known_limitations"] counts = readiness_summary["counts"] overall_state = str(readiness_summary["overall_state"]) overall_state_class = ExportService._html_class_token(overall_state) generated_context = "Generated from persisted GeoIntel state" scorecards = [ ("Areas", counts.get("area_count", 0)), ("Datasets", f"{counts.get('ready_dataset_count', 0)}/{counts.get('dataset_count', 0)} ready"), ("Reference", counts.get("reference_dataset_count", 0)), ("QA/QC", counts.get("quality_check_count", 0)), ("Exports", counts.get("export_count", 0)), ] scorecard_html = "\n".join( "
" f"{escape(str(label))}" f"{escape(str(value))}" "
" for label, value in scorecards ) 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']))}" f"{escape(str(item['dataset_type']))}" f"{escape(str(item['dataset_role']))}" f"{escape(str(item['status']))}" f"{escape(str(item['feature_count'] if item['feature_count'] is not None else 'n/a'))}" f"{escape(str(item.get('source_name') or 'n/a'))}" f"{escape(str(item.get('crs') or 'n/a'))}" "" for item in datasets ) quality_rows = "\n".join( "" f"{escape(str(item['check_type']))}" f"{escape(str(item['status']))}" f"{escape(str(item['score'] if item['score'] is not None else 'n/a'))}" f"{escape(str(item['reference_dataset_id']))}" "" for item in quality_checks ) export_rows = "\n".join( "" f"{escape(str(item['export_type']))}" f"{escape(str(item['storage_path']))}" f"{escape(str(item['created_at'] or 'n/a'))}" "" for item in exports ) return f""" GeoIntel Project Report - {escape(str(project["name"]))}

    GeoIntel project report artifact

    {escape(str(project["name"]))}

    {generated_context}

    Region: {escape(str(project["region"]))} ยท Status: {escape(str(project["status"]))}

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

    {escape(overall_state)}
    {scorecard_html}

    Release handoff

    V1 Readiness Summary

    Overall state: {escape(overall_state)}

    {readiness_rows}
    AreaStateDetail

    Data handoff

    Dataset inventory ({len(datasets)})

    {dataset_rows or ''}
    NameTypeRoleStatusFeaturesSourceCRS
    No datasets

    Quality handoff

    QA/QC evidence ({len(quality_checks)})

    {quality_rows or ''}
    CheckStatusScoreReference dataset
    No QA/QC results

    Artifact handoff

    Artifact history ({len(exports)})

    Export History ({len(exports)})

    {export_rows or ''}
    TypeStorage pathCreated
    No exports

    Scope guardrails

    Known Limitations

    """ @staticmethod def _html_class_token(value: str) -> str: token = re.sub(r"[^a-zA-Z0-9_-]+", "_", value.strip().lower()).strip("_") return token or "unknown" @staticmethod def _create_response(export: Export) -> ExportCreateResponse: return ExportCreateResponse( export_id=export.id, path=export.storage_path, status="ready", export_type=export.export_type, metadata_json=export.metadata_json, ) @staticmethod def _filename(name: str | None, fallback: str, suffix: str) -> str: raw_name = name or fallback cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw_name).strip("._") if not cleaned: cleaned = fallback if not cleaned.lower().endswith(suffix): cleaned = f"{cleaned}{suffix}" return cleaned