distinguish a redrawn footprint from a demolition, and derive estimate

disclosure from data

Change detection had only added/removed/unchanged, so a building extended by
an annexe dropped below the IoU threshold and was reported twice: once as
removed and once as added. That hides exactly the category a change-detection
product exists to show and inflates both counts. A "modified" class now covers
the band between the modified floor and the unchanged threshold.

Matching also ran as a full cross product with no spatial index, unlike the QA
matcher beside it: two municipal building layers meant hundreds of millions of
geometry intersections. It uses an STRtree and considers larger footprints
first, so a big footprint is not left over after a small neighbour claimed its
counterpart.

The assistant guaranteed honesty about estimated values by rewriting the
model's sentences with regular expressions, which only fires when it
recognises the phrasing the model happened to produce. estimate_disclosures
derives the same statement from the metric metadata, so it holds regardless of
how the answer was worded. The prose substitution stays as a second layer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 14:33:37 +02:00
co-authored by Claude Opus 5
parent dd87a62e8f
commit 23d6e0372b
6 changed files with 373 additions and 59 deletions
@@ -16,6 +16,7 @@ from app.core.errors import AppError
from app.models import Area, Dataset, Project
from app.schemas.assistant import (
AssistantContextMetric,
AssistantEstimateDisclosure,
AssistantModelRead,
AssistantQueryRequest,
AssistantQueryResponse,
@@ -112,6 +113,44 @@ class GeoAssistantService:
}
return themes or None
@classmethod
def estimate_disclosures(
cls,
metrics: list[AssistantContextMetric],
) -> list[AssistantEstimateDisclosure]:
"""List every estimated value behind the answer, straight from metadata.
``ensure_estimate_disclosure`` can only add a caveat when it recognises
the phrasing the model produced, which makes the guarantee dependent on
generated text. This derives the same statement from the source
metadata, so it holds regardless of how the answer was written.
"""
seen: set[tuple[str, UUID]] = set()
disclosures: list[AssistantEstimateDisclosure] = []
for metric in sorted(metrics, key=lambda item: (item.theme, item.label)):
if not metric.is_estimate:
continue
key = (metric.theme, metric.dataset_id)
if key in seen:
continue
seen.add(key)
topic = cls.ESTIMATE_TOPIC_LABELS.get(metric.theme, metric.label)
disclosures.append(
AssistantEstimateDisclosure(
theme=metric.theme,
label=metric.label,
unit=metric.unit,
source=metric.source,
dataset_id=metric.dataset_id,
reason=(
f"De bronmetadata van {metric.source} markeert {topic} als schatting, "
"geen exacte telling."
),
)
)
return disclosures
@classmethod
def ensure_estimate_disclosure(
cls,
@@ -702,6 +741,7 @@ class GeoAssistantService:
scope_label=scope_label,
context_metrics=metrics,
temporal_series=series,
estimate_disclosures=self.estimate_disclosures(metrics),
source_dataset_ids=dataset_ids,
warnings=warnings,
generated_at=datetime.now(timezone.utc),