make an exported file state its own provenance and limits

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>
This commit is contained in:
Jens
2026-08-22 14:56:37 +02:00
co-authored by Claude Opus 5
parent 12aaf1bb4d
commit 0d8f146fc4
3 changed files with 385 additions and 0 deletions
+131
View File
@@ -3,6 +3,7 @@ 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
@@ -35,6 +36,82 @@ 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."""
@@ -447,6 +524,28 @@ class ExportService:
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 = {
@@ -487,6 +586,18 @@ class ExportService:
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 = {
@@ -530,6 +641,16 @@ class ExportService:
)
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):
@@ -565,6 +686,16 @@ class ExportService:
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 = {