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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user