Files
geointel/backend/app/services/export_service.py
T
Codex 46fe3edfc5
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled
Persist map area selection exports
2026-06-25 02:12:09 +02:00

756 lines
31 KiB
Python

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.export import ExportContentResponse, ExportCreateResponse, ExportListResponse, ExportRead
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService
from app.services.storage_service import StorageService
from app.services.vector_feature_service import VectorFeatureService
class ExportService:
@staticmethod
def export_vector_selection_geojson(
db: Session,
dataset_id: uuid.UUID,
bbox: dict[str, Any],
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 = VectorFeatureService.select_features_by_bbox(db, dataset_id=dataset_id, bbox=bbox, limit=limit)
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"],
"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(
"<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