Files
geointel/backend/app/schemas/assistant.py
T
JensandClaude Opus 5 23d6e0372b 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>
2026-08-22 14:33:37 +02:00

94 lines
2.3 KiB
Python

from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
from app.schemas.operations import VectorSelectionBBox
class AssistantChatMessage(BaseModel):
role: Literal["user", "assistant"]
content: str = Field(min_length=1, max_length=4_000)
class AssistantQueryRequest(BaseModel):
question: str = Field(min_length=2, max_length=2_000)
model: str | None = Field(default=None, max_length=255)
bbox: VectorSelectionBBox | None = None
area_id: UUID | None = None
history: list[AssistantChatMessage] = Field(default_factory=list, max_length=8)
class AssistantModelRead(BaseModel):
name: str
size_bytes: int | None = None
parameter_size: str | None = None
quantization_level: str | None = None
capabilities: list[str] = Field(default_factory=list)
class AssistantModelList(BaseModel):
items: list[AssistantModelRead]
total: int
default_model: str | None = None
class AssistantStatus(BaseModel):
enabled: bool
reachable: bool
status: str
base_url: str
default_model: str | None = None
model_count: int = 0
limitation_message: str
class AssistantContextMetric(BaseModel):
theme: str
label: str
value: float
unit: str
source: str
dataset_id: UUID
observed_at: datetime | None = None
is_estimate: bool = False
class AssistantTemporalSeries(BaseModel):
temporal_series_key: str
label: str
source: str
first_year: int
last_year: int
observation_count: int
class AssistantEstimateDisclosure(BaseModel):
"""A value in the answer that the source itself calls an estimate.
Derived from metric metadata rather than from the generated sentences, so
the disclosure is present whatever wording the model chose.
"""
theme: str
label: str
unit: str
source: str
dataset_id: UUID
reason: str
class AssistantQueryResponse(BaseModel):
answer: str
model: str
scope_label: str
context_metrics: list[AssistantContextMetric]
temporal_series: list[AssistantTemporalSeries]
estimate_disclosures: list[AssistantEstimateDisclosure] = Field(default_factory=list)
source_dataset_ids: list[UUID]
warnings: list[str]
generated_at: datetime