The vector selection export caps its features and recorded truncated on the export record. The file said nothing: an operator downloads mol-selection.geojson, opens 250 buildings in QGIS where the workbench said 1.400, and has nothing to tell them the difference. For a product whose promise is that an export is a reproducible result, a file that looks complete and is not is the sharpest possible violation of it. Every exported FeatureCollection now carries a geointel_provenance foreign member — RFC 7946 requires parsers to ignore unknown members, so QGIS and ogr2ogr are unaffected, and the detection export already used the same convention for its trust classification. It names the source, the dataset and its edition, the selection, and whether the file is complete; completeness is derived from the counts as well as the flag, so a caller that forgets the flag cannot produce a file claiming to hold everything. Applied uniformly to the selection, dataset, detection and segmentation exports, which previously disclosed three different amounts of nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1320 lines
55 KiB
Python
1320 lines
55 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
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.dataset_consumption_gate_service import DatasetConsumptionGate
|
|
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:
|
|
# RFC 7946 allows foreign members on a FeatureCollection and requires
|
|
# parsers to ignore ones they do not know, so the provenance travels with
|
|
# the file without breaking QGIS, ogr2ogr or any other reader.
|
|
PROVENANCE_MEMBER = "geointel_provenance"
|
|
|
|
@staticmethod
|
|
def provenance_member(
|
|
*,
|
|
source: str,
|
|
project_id: uuid.UUID,
|
|
dataset_id: uuid.UUID | None = None,
|
|
analysis_run_id: uuid.UUID | None = None,
|
|
source_name: str | None = None,
|
|
source_version: str | None = None,
|
|
observed_at: Any = None,
|
|
selection_bbox: dict[str, Any] | None = None,
|
|
selection_area_id: uuid.UUID | None = None,
|
|
feature_count: int | None = None,
|
|
total_feature_count: int | None = None,
|
|
truncated: bool = False,
|
|
warnings: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Describe an exported FeatureCollection inside the file itself.
|
|
|
|
A capped export previously recorded ``truncated`` on the export record
|
|
only, so the downloaded file looked complete. Completeness is derived
|
|
from the counts as well as the flag: a caller that forgets to pass the
|
|
flag cannot produce a file that claims to hold everything.
|
|
"""
|
|
|
|
complete = not truncated
|
|
if feature_count is not None and total_feature_count is not None:
|
|
complete = complete and feature_count >= total_feature_count
|
|
|
|
member: dict[str, Any] = {
|
|
"source": source,
|
|
"project_id": str(project_id),
|
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
"complete": complete,
|
|
"completeness_note": None,
|
|
}
|
|
if dataset_id is not None:
|
|
member["dataset_id"] = str(dataset_id)
|
|
if analysis_run_id is not None:
|
|
member["analysis_run_id"] = str(analysis_run_id)
|
|
if source_name:
|
|
member["source_name"] = source_name
|
|
if source_version:
|
|
member["source_version"] = source_version
|
|
if observed_at is not None:
|
|
member["observed_at"] = observed_at.isoformat() if hasattr(observed_at, "isoformat") else str(observed_at)
|
|
if selection_bbox is not None:
|
|
member["selection_bbox"] = selection_bbox
|
|
if selection_area_id is not None:
|
|
member["selection_area_id"] = str(selection_area_id)
|
|
if feature_count is not None:
|
|
member["feature_count"] = feature_count
|
|
if total_feature_count is not None:
|
|
member["total_feature_count"] = total_feature_count
|
|
if warnings:
|
|
member["warnings"] = list(warnings)
|
|
|
|
if not complete:
|
|
written = feature_count if feature_count is not None else "?"
|
|
available = total_feature_count if total_feature_count is not None else "?"
|
|
member["completeness_note"] = (
|
|
f"Dit bestand bevat {written} van {available} objecten uit de selectie. Het is een "
|
|
"begrensde uitsnede, geen volledige export."
|
|
)
|
|
return member
|
|
|
|
@staticmethod
|
|
def attach_provenance(feature_collection: dict[str, Any], member: dict[str, Any]) -> dict[str, Any]:
|
|
feature_collection[ExportService.PROVENANCE_MEMBER] = member
|
|
return feature_collection
|
|
|
|
@staticmethod
|
|
def _detection_export_trust(db: Session, run: AnalysisRun) -> dict[str, Any]:
|
|
"""Classify persisted AI output without turning confidence into truth."""
|
|
|
|
checks = (
|
|
db.query(QualityCheck)
|
|
.filter(
|
|
QualityCheck.analysis_run_id == run.id,
|
|
QualityCheck.check_type == "detections_vs_reference",
|
|
)
|
|
.order_by(QualityCheck.created_at.desc())
|
|
.all()
|
|
)
|
|
quality_check = checks[0] if checks else None
|
|
reasons: list[str] = []
|
|
reference_dataset = None
|
|
if quality_check is None:
|
|
reasons.append("authoritative_qa_missing")
|
|
else:
|
|
findings = quality_check.findings_json if isinstance(quality_check.findings_json, dict) else {}
|
|
coverage = findings.get("coverage") if isinstance(findings.get("coverage"), dict) else {}
|
|
temporal = findings.get("temporal_compatibility") if isinstance(findings.get("temporal_compatibility"), dict) else {}
|
|
warnings = findings.get("warnings") if isinstance(findings.get("warnings"), list) else []
|
|
if quality_check.status != "ok":
|
|
reasons.append("quality_check_not_ok")
|
|
if findings.get("unsupported_geometry") is True:
|
|
reasons.append("unsupported_geometry")
|
|
false_positives = findings.get("false_positives")
|
|
false_negatives = findings.get("false_negatives")
|
|
if (
|
|
isinstance(false_positives, bool)
|
|
or not isinstance(false_positives, (int, float))
|
|
or false_positives != 0
|
|
):
|
|
reasons.append("false_positives_present")
|
|
if (
|
|
isinstance(false_negatives, bool)
|
|
or not isinstance(false_negatives, (int, float))
|
|
or false_negatives != 0
|
|
):
|
|
reasons.append("false_negatives_present")
|
|
if warnings:
|
|
reasons.append("quality_warnings_present")
|
|
if coverage.get("applied") is not True:
|
|
reasons.append("inference_coverage_not_proven")
|
|
if temporal.get("status") != "compatible":
|
|
reasons.append("temporal_compatibility_not_proven")
|
|
reference_dataset = db.get(Dataset, quality_check.reference_dataset_id)
|
|
if reference_dataset is None:
|
|
reasons.append("reference_dataset_missing")
|
|
else:
|
|
try:
|
|
DatasetConsumptionGate.assert_eligible(
|
|
reference_dataset,
|
|
purpose="reference_validation",
|
|
reference_task="building_validation",
|
|
)
|
|
except AppError:
|
|
reasons.append("reference_not_authoritative_for_buildings")
|
|
|
|
operational_use_allowed = not reasons
|
|
return {
|
|
"schema_version": "geointel.result-trust/v1",
|
|
"classification": (
|
|
"authoritative_reference_checked_ai_output"
|
|
if operational_use_allowed
|
|
else "unverified_ai_review_output"
|
|
),
|
|
"authoritative": False,
|
|
"operational_use_allowed": operational_use_allowed,
|
|
"operator_review_required": True,
|
|
"quality_check_id": str(quality_check.id) if quality_check else None,
|
|
"reference_dataset_id": str(reference_dataset.id) if reference_dataset else None,
|
|
"blocking_reasons": sorted(set(reasons)),
|
|
"limitation": (
|
|
"AI output is not ground truth. Operational use is bounded to the exact source, AOI, model and reference QA evidence."
|
|
),
|
|
}
|
|
|
|
@staticmethod
|
|
def _assert_run_source_dataset_exportable(db: Session, run: AnalysisRun) -> Dataset:
|
|
"""Block an output export when its persisted source dataset is unsafe."""
|
|
|
|
if not run.dataset_id:
|
|
raise AppError(
|
|
code="DATASET_PROVENANCE_INCOMPLETE",
|
|
message="Analysis output cannot be exported without a persisted source dataset.",
|
|
status_code=409,
|
|
)
|
|
dataset = db.get(Dataset, run.dataset_id)
|
|
if not dataset or dataset.project_id != run.project_id:
|
|
raise AppError(code="DATASET_NOT_FOUND", message="Analysis source dataset not found", status_code=404)
|
|
DatasetConsumptionGate.assert_eligible(dataset, purpose="export")
|
|
return dataset
|
|
|
|
@staticmethod
|
|
def export_map_result(
|
|
db: Session,
|
|
payload: MapResultExportRequest,
|
|
) -> ExportCreateResponse:
|
|
if payload.mode == "evolution":
|
|
earlier_dataset = db.get(Dataset, payload.earlier_dataset_id)
|
|
later_dataset = db.get(Dataset, payload.later_dataset_id)
|
|
if (
|
|
not earlier_dataset
|
|
or not later_dataset
|
|
or earlier_dataset.project_id != payload.project_id
|
|
or later_dataset.project_id != payload.project_id
|
|
):
|
|
raise AppError(code="DATASET_NOT_FOUND", message="Temporal export dataset not found", status_code=404)
|
|
DatasetConsumptionGate.assert_eligible(earlier_dataset, purpose="export")
|
|
DatasetConsumptionGate.assert_eligible(later_dataset, purpose="export")
|
|
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)
|
|
DatasetConsumptionGate.assert_eligible(dataset, purpose="export")
|
|
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:
|
|
DatasetConsumptionGate.assert_eligible(dataset, purpose="export")
|
|
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,
|
|
)
|
|
DatasetConsumptionGate.assert_eligible(dataset, purpose="export")
|
|
|
|
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)
|
|
summary = selection.get("summary") if isinstance(selection.get("summary"), dict) else {}
|
|
ExportService.attach_provenance(
|
|
selection["geojson"],
|
|
ExportService.provenance_member(
|
|
source="vector_selection",
|
|
project_id=dataset.project_id,
|
|
dataset_id=dataset.id,
|
|
source_name=dataset.source_name,
|
|
source_version=dataset.source_version,
|
|
observed_at=dataset.observed_at,
|
|
selection_bbox=selection["selection_bbox"],
|
|
selection_area_id=area_id,
|
|
feature_count=selection["feature_count"],
|
|
total_feature_count=selection.get("total_feature_count"),
|
|
truncated=selection["truncated"],
|
|
warnings=[
|
|
warning
|
|
for warning in (summary.get("selection_edge_warning"), summary.get("warning"))
|
|
if warning
|
|
],
|
|
),
|
|
)
|
|
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,
|
|
)
|
|
DatasetConsumptionGate.assert_eligible(dataset, purpose="export")
|
|
|
|
feature_collection = DatasetService.get_dataset_geojson(db, dataset_id)
|
|
ExportService.attach_provenance(
|
|
feature_collection,
|
|
ExportService.provenance_member(
|
|
source="dataset",
|
|
project_id=dataset.project_id,
|
|
dataset_id=dataset.id,
|
|
source_name=dataset.source_name,
|
|
source_version=dataset.source_version,
|
|
observed_at=dataset.observed_at,
|
|
feature_count=len(feature_collection.get("features", [])),
|
|
),
|
|
)
|
|
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,
|
|
*,
|
|
intended_use: str = "review",
|
|
) -> 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)
|
|
ExportService._assert_run_source_dataset_exportable(db, run)
|
|
|
|
trust = ExportService._detection_export_trust(db, run)
|
|
if intended_use == "operational" and not trust["operational_use_allowed"]:
|
|
raise AppError(
|
|
code="DETECTION_OPERATIONAL_EXPORT_BLOCKED",
|
|
message="Operational detection export requires complete authoritative QA with no remaining errors or warnings.",
|
|
details=trust,
|
|
status_code=409,
|
|
)
|
|
feature_collection = DetectionService.detections_to_geojson(db, analysis_run_id=analysis_run_id)
|
|
feature_collection["geointel_result"] = trust
|
|
ExportService.attach_provenance(
|
|
feature_collection,
|
|
ExportService.provenance_member(
|
|
source="detection_run",
|
|
project_id=run.project_id,
|
|
dataset_id=run.dataset_id,
|
|
analysis_run_id=run.id,
|
|
feature_count=len(feature_collection.get("features", [])),
|
|
),
|
|
)
|
|
for feature in feature_collection.get("features", []):
|
|
properties = feature.get("properties") if isinstance(feature, dict) else None
|
|
if isinstance(properties, dict):
|
|
properties["result_classification"] = trust["classification"]
|
|
properties["authoritative"] = False
|
|
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", [])),
|
|
"intended_use": intended_use,
|
|
"result_trust": trust,
|
|
}
|
|
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)
|
|
ExportService._assert_run_source_dataset_exportable(db, run)
|
|
|
|
feature_collection = SegmentationService.segmentations_to_geojson(db, analysis_run_id=analysis_run_id)
|
|
ExportService.attach_provenance(
|
|
feature_collection,
|
|
ExportService.provenance_member(
|
|
source="segmentation_run",
|
|
project_id=run.project_id,
|
|
dataset_id=run.dataset_id,
|
|
analysis_run_id=run.id,
|
|
feature_count=len(feature_collection.get("features", [])),
|
|
),
|
|
)
|
|
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(
|
|
"<div class=\"scorecard\">"
|
|
f"<span>{escape(str(label))}</span>"
|
|
f"<strong>{escape(str(value))}</strong>"
|
|
"</div>"
|
|
for label, value in scorecards
|
|
)
|
|
readiness_rows = "\n".join(
|
|
"<tr>"
|
|
f"<td>{escape(str(item['label']))}</td>"
|
|
f"<td><span class=\"readiness-pill readiness-{ExportService._html_class_token(str(item['state']))}\">{escape(str(item['state']))}</span></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>"
|
|
f"<td>{escape(str(item['dataset_type']))}</td>"
|
|
f"<td>{escape(str(item['dataset_role']))}</td>"
|
|
f"<td>{escape(str(item['status']))}</td>"
|
|
f"<td>{escape(str(item['feature_count'] if item['feature_count'] is not None else 'n/a'))}</td>"
|
|
f"<td>{escape(str(item.get('source_name') or 'n/a'))}</td>"
|
|
f"<td>{escape(str(item.get('crs') or 'n/a'))}</td>"
|
|
"</tr>"
|
|
for item in datasets
|
|
)
|
|
quality_rows = "\n".join(
|
|
"<tr>"
|
|
f"<td>{escape(str(item['check_type']))}</td>"
|
|
f"<td>{escape(str(item['status']))}</td>"
|
|
f"<td>{escape(str(item['score'] if item['score'] is not None else 'n/a'))}</td>"
|
|
f"<td>{escape(str(item['reference_dataset_id']))}</td>"
|
|
"</tr>"
|
|
for item in quality_checks
|
|
)
|
|
export_rows = "\n".join(
|
|
"<tr>"
|
|
f"<td>{escape(str(item['export_type']))}</td>"
|
|
f"<td>{escape(str(item['storage_path']))}</td>"
|
|
f"<td>{escape(str(item['created_at'] or 'n/a'))}</td>"
|
|
"</tr>"
|
|
for item in exports
|
|
)
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>GeoIntel Project Report - {escape(str(project["name"]))}</title>
|
|
<style>
|
|
:root {{
|
|
--ink: #132018;
|
|
--muted: #5f6f67;
|
|
--line: #cbd8d0;
|
|
--soft: #f3f8f5;
|
|
--accent: #0f766e;
|
|
--accent-soft: #e3f4ef;
|
|
--warning: #b45309;
|
|
--danger: #991b1b;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
background: #eef4f1;
|
|
color: var(--ink);
|
|
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
|
line-height: 1.45;
|
|
}}
|
|
.report-shell {{
|
|
width: min(1120px, calc(100% - 2rem));
|
|
margin: 0 auto;
|
|
padding: 1.25rem 0 2rem;
|
|
}}
|
|
.report-hero,
|
|
.report-section {{
|
|
page-break-inside: avoid;
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
background: #fff;
|
|
box-shadow: 0 10px 28px rgba(33, 48, 41, 0.08);
|
|
}}
|
|
.report-hero {{
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
gap: 1rem;
|
|
align-items: end;
|
|
padding: 1.2rem;
|
|
background: linear-gradient(135deg, #ffffff, var(--accent-soft));
|
|
}}
|
|
h1, h2, h3, p {{ margin-top: 0; }}
|
|
h1 {{ margin-bottom: 0.35rem; font-size: 2rem; line-height: 1.05; }}
|
|
h2 {{ margin-bottom: 0.65rem; font-size: 1.2rem; }}
|
|
p {{ margin-bottom: 0.55rem; }}
|
|
.muted {{ color: var(--muted); }}
|
|
.section-kicker {{
|
|
margin: 0 0 0.22rem;
|
|
color: var(--muted);
|
|
font-size: 0.72rem;
|
|
font-weight: 800;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}}
|
|
.readiness-pill {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
padding: 0.2rem 0.58rem;
|
|
background: #fff;
|
|
font-size: 0.78rem;
|
|
font-weight: 800;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
}}
|
|
.readiness-ready {{ border-color: rgba(21, 128, 61, 0.28); background: #ecfdf3; color: #166534; }}
|
|
.readiness-needs_attention,
|
|
.readiness-warning,
|
|
.readiness-waiting {{ border-color: rgba(180, 83, 9, 0.28); background: #fffbeb; color: var(--warning); }}
|
|
.readiness-blocked {{ border-color: rgba(153, 27, 27, 0.28); background: #fff1f2; color: var(--danger); }}
|
|
.report-scorecards {{
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 0.65rem;
|
|
margin: 1rem 0;
|
|
}}
|
|
.scorecard {{
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 0.7rem;
|
|
background: #fff;
|
|
}}
|
|
.scorecard span {{
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 0.72rem;
|
|
font-weight: 800;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
}}
|
|
.scorecard strong {{ display: block; margin-top: 0.25rem; font-size: 1.05rem; }}
|
|
.report-section {{ margin-top: 1rem; padding: 1rem; overflow: hidden; }}
|
|
.table-wrap {{ width: 100%; overflow-x: auto; }}
|
|
table {{ width: 100%; border-collapse: collapse; min-width: 42rem; }}
|
|
th, td {{ border-bottom: 1px solid var(--line); padding: 0.55rem; text-align: left; vertical-align: top; }}
|
|
th {{
|
|
background: var(--soft);
|
|
color: var(--muted);
|
|
font-size: 0.72rem;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
}}
|
|
ul {{ margin: 0; padding-left: 1.2rem; }}
|
|
li + li {{ margin-top: 0.35rem; }}
|
|
@media (max-width: 760px) {{
|
|
.report-shell {{ width: min(100% - 1rem, 1120px); }}
|
|
.report-hero {{ grid-template-columns: 1fr; }}
|
|
.report-scorecards {{ grid-template-columns: repeat(2, minmax(0, 1fr)); }}
|
|
}}
|
|
@media print {{
|
|
body {{ background: #fff; }}
|
|
.report-shell {{ width: 100%; padding: 0; }}
|
|
.report-hero,
|
|
.report-section {{ box-shadow: none; border-color: #94a3b8; page-break-inside: avoid; }}
|
|
.table-wrap {{ overflow: visible; }}
|
|
table {{ min-width: 0; font-size: 0.82rem; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="report-shell">
|
|
<section class="report-hero">
|
|
<div>
|
|
<p class="section-kicker">GeoIntel project report artifact</p>
|
|
<h1>{escape(str(project["name"]))}</h1>
|
|
<p class="muted">{generated_context}</p>
|
|
<p>Region: {escape(str(project["region"]))} · Status: {escape(str(project["status"]))}</p>
|
|
<p>Description: {escape(str(project["description"] or "n/a"))}</p>
|
|
</div>
|
|
<span class="readiness-pill readiness-{overall_state_class}">{escape(overall_state)}</span>
|
|
</section>
|
|
<div class="report-scorecards">{scorecard_html}</div>
|
|
<section class="report-section">
|
|
<p class="section-kicker">Release handoff</p>
|
|
<h2>V1 Readiness Summary</h2>
|
|
<p>Overall state: <span class="readiness-pill readiness-{overall_state_class}">{escape(overall_state)}</span></p>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>Area</th><th>State</th><th>Detail</th></tr></thead>
|
|
<tbody>{readiness_rows}</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<section class="report-section">
|
|
<p class="section-kicker">Data handoff</p>
|
|
<h2>Dataset inventory ({len(datasets)})</h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Type</th><th>Role</th><th>Status</th><th>Features</th><th>Source</th><th>CRS</th></tr></thead>
|
|
<tbody>{dataset_rows or '<tr><td colspan="7">No datasets</td></tr>'}</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<section class="report-section">
|
|
<p class="section-kicker">Quality handoff</p>
|
|
<h2>QA/QC evidence ({len(quality_checks)})</h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>Check</th><th>Status</th><th>Score</th><th>Reference dataset</th></tr></thead>
|
|
<tbody>{quality_rows or '<tr><td colspan="4">No QA/QC results</td></tr>'}</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<section class="report-section">
|
|
<p class="section-kicker">Artifact handoff</p>
|
|
<h2>Artifact history ({len(exports)})</h2>
|
|
<p class="muted">Export History ({len(exports)})</p>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<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>
|
|
</div>
|
|
</section>
|
|
<section class="report-section">
|
|
<p class="section-kicker">Scope guardrails</p>
|
|
<h2>Known Limitations</h2>
|
|
<ul>{limitation_items}</ul>
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
@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
|