Optimize assistant context by requested themes
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-16 03:48:36 +02:00
parent a19bff9d14
commit 1e23d30eaf
5 changed files with 206 additions and 4 deletions
+6
View File
@@ -17,6 +17,12 @@
chronology or unsupported-metric rules.
- Exposed the output limit in the Unraid DockerMan template and aligned all
Compose, runtime, example and operator documentation defaults.
- Limited expensive PostGIS summaries to explicitly requested themes while
preserving full context for general overview/source questions. This keeps a
six-theme Mol profile from calculating unrelated agricultural subclasses.
- Provisioned the five official thematic products and the 1,159-feature DOV
soil map into Mol's Area in the central 28-municipality workbench, removing
the mismatch with the earlier standalone Mol project.
## Sprint 213-214 Cross-domain area profile (2026-07-16)
+63 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from typing import Any
from urllib.error import HTTPError, URLError
@@ -57,12 +58,50 @@ class GeoAssistantService:
"accessibility": "bereikbaarheidsscores",
"services": "voorzieningenscores",
}
THEME_QUERY_TERMS = {
"buildings": ("bebouwing", "gebouw", "gebouwen", "gebouwoppervlakte"),
"space_occupation": ("ruimtebeslag", "verharding"),
"open_space": ("open ruimte", "openruimte"),
"population": ("bevolking", "bevolkingsdichtheid", "inwoner", "inwoners"),
"forest": ("bos", "bossen", "bosoppervlakte", "groen"),
"nature_value": ("natuur", "natuurwaarde", "biodiversiteit", "habitat", "natura 2000"),
"agriculture": (
"landbouw",
"landbouwteelt",
"landbouwteelten",
"akker",
"akkers",
"teelt",
"teelten",
"gewas",
"gewassen",
),
"soil": ("bodem", "bodemkaart", "bodemtype", "bodemtypes"),
"water": ("water", "waterloop", "waterlopen", "waterweg", "waterwegen", "rivier", "beek"),
"flood_hazard": ("overstroming", "overstromingen", "inundatie", "waterdiepte"),
"terrain": ("hoogte", "reliëf", "terrein", "dhmv"),
"accessibility": ("bereikbaarheid", "bereikbaar", "knooppuntwaarde", "collectief vervoer"),
"services": ("voorziening", "voorzieningen", "voorzieningenniveau"),
"roads": ("weg", "wegen", "wegennet", "rijbaan", "rijbanen", "straat", "straten"),
"parcels": ("perceel", "percelen", "kadastraal", "kadaster"),
}
@classmethod
def history_requested(cls, question: str) -> bool:
normalized = question.casefold()
return any(keyword in normalized for keyword in cls.HISTORY_KEYWORDS)
@classmethod
def requested_themes(cls, question: str) -> set[str] | None:
normalized = " ".join(re.sub(r"[^\w]+", " ", question.casefold()).split())
padded = f" {normalized} "
themes = {
theme
for theme, terms in cls.THEME_QUERY_TERMS.items()
if any(f" {term} " in padded for term in terms)
}
return themes or None
@classmethod
def ensure_estimate_disclosure(
cls,
@@ -269,17 +308,33 @@ class GeoAssistantService:
.all()
)
vector_datasets = [dataset for dataset in datasets if dataset.dataset_type in {"vector", "geojson"}]
requested_themes = self.requested_themes(payload.question)
relevant_vector_datasets = [
dataset
for dataset in vector_datasets
if requested_themes is None or VectorFeatureService._dataset_theme(dataset) in requested_themes
]
flood_hazard_datasets = [
dataset
for dataset in datasets
if dataset.dataset_type == "raster" and dataset.source_name == FloodHazardAcquisitionService.PROVIDER
and (area is None or dataset.area_id is None or dataset.area_id == area.id)
and (requested_themes is None or "flood_hazard" in requested_themes)
]
thematic_products = ThematicRasterAcquisitionService._products()
thematic_candidates = [
dataset
for dataset in datasets
if dataset.dataset_type == "raster" and dataset.source_name == ThematicRasterAcquisitionService.PROVIDER
and (area is None or dataset.area_id is None or dataset.area_id == area.id)
and (
requested_themes is None
or (
str((dataset.source_metadata or {}).get("product_key") or "") in thematic_products
and thematic_products[str((dataset.source_metadata or {}).get("product_key") or "")].theme
in requested_themes
)
)
]
thematic_by_product: dict[str, Dataset] = {}
for dataset in thematic_candidates:
@@ -294,7 +349,7 @@ class GeoAssistantService:
current_context: list[dict[str, Any]] = []
if bbox is not None:
for dataset in self._current_datasets(vector_datasets):
for dataset in self._current_datasets(relevant_vector_datasets):
kwargs: dict[str, Any] = {"dataset": dataset, "bbox": bbox}
if area is not None:
kwargs["selection_geometry"] = area.geometry
@@ -441,7 +496,7 @@ class GeoAssistantService:
temporal_series: list[AssistantTemporalSeries] = []
temporal_context: list[dict[str, Any]] = []
include_history = self.history_requested(payload.question)
for key, observations in self._series(vector_datasets):
for key, observations in self._series(relevant_vector_datasets):
first = observations[0]
last = observations[-1]
source_metadata = last.source_metadata if isinstance(last.source_metadata, dict) else {}
@@ -483,7 +538,12 @@ class GeoAssistantService:
context = {
"project": {"id": str(project.id), "name": project.name, "region": project.region},
"scope": {"label": scope_label, "bbox": bbox, "exact_area_geometry_used": area is not None},
"scope": {
"label": scope_label,
"bbox": bbox,
"exact_area_geometry_used": area is not None,
"requested_themes": sorted(requested_themes) if requested_themes is not None else None,
},
"current_measurements": current_context,
"available_temporal_series": temporal_context,
"rules": {
@@ -98,6 +98,28 @@ def test_geo_assistant_recognizes_dutch_historical_questions(question: str) -> N
assert GeoAssistantService.history_requested(question) is True
def test_geo_assistant_limits_explicit_cross_domain_question_to_requested_themes() -> None:
themes = GeoAssistantService.requested_themes(
"Geef een profiel met ruimtebeslag, open ruimte, bevolking, bereikbaarheid, voorzieningen en bodem."
)
assert themes == {"space_occupation", "open_space", "population", "accessibility", "services", "soil"}
@pytest.mark.parametrize(
("question", "expected"),
[
("Hoe evolueerden bevolking en bosoppervlakte?", {"population", "forest"}),
("Toon wegen, waterlopen en overstromingen.", {"roads", "water", "flood_hazard"}),
("Welke bodemtypes en landbouwteelten komen voor?", {"soil", "agriculture"}),
("Vat de belangrijkste gebiedsmetingen samen.", None),
("Welke officiële bronnen zijn beschikbaar?", None),
],
)
def test_geo_assistant_theme_selection_preserves_general_overviews(question: str, expected: set[str] | None) -> None:
assert GeoAssistantService.requested_themes(question) == expected
def test_geo_assistant_discloses_estimated_population_values() -> None:
metrics = [
AssistantContextMetric(
@@ -23,6 +23,7 @@ from app.services.geo_assistant_service import GeoAssistantService
from app.services.thematic_raster_acquisition_service import ThematicRasterAcquisitionService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
from app.services.dataset_service import DatasetService
from app.services.vector_feature_service import VectorFeatureService
ROOT = Path(__file__).resolve().parents[2]
@@ -277,6 +278,104 @@ def test_assistant_context_receives_persisted_thematic_metrics(tmp_path) -> None
assert context["rules"]["thematic_policy_rasters_available"] is True
def test_assistant_context_skips_unrequested_expensive_themes(monkeypatch) -> None:
project_id = uuid4()
soil_id, agriculture_id = uuid4(), uuid4()
population_id, space_id = uuid4(), uuid4()
project = Project(id=project_id, name="Kempen", region="Kempen")
datasets = [
Dataset(
id=soil_id,
project_id=project_id,
name="soil.geojson",
dataset_type="vector",
source="official",
source_name="dov",
source_metadata={"theme": "soil"},
status="ready",
),
Dataset(
id=agriculture_id,
project_id=project_id,
name="agriculture.geojson",
dataset_type="vector",
source="official",
source_name="lv",
source_metadata={"theme": "agriculture"},
status="ready",
),
Dataset(
id=population_id,
project_id=project_id,
name="population.tif",
dataset_type="raster",
source="official",
source_name=ThematicRasterAcquisitionService.PROVIDER,
source_metadata={"product_key": "population_density_2019"},
status="ready",
),
Dataset(
id=space_id,
project_id=project_id,
name="space.tif",
dataset_type="raster",
source="official",
source_name=ThematicRasterAcquisitionService.PROVIDER,
source_metadata={"product_key": "space_occupation_2025"},
status="ready",
),
]
db = FakeSession({(Project, project_id): project}, query_result=datasets)
summarized: list = []
analyzed: list = []
def summarize(_db, *, dataset, **_kwargs):
summarized.append(dataset.id)
return {
"metric_label": "Gekarteerde bodemoppervlakte",
"metric_value": 12.5,
"metric_unit": "ha",
"is_estimate": False,
"warning": "Historische bodemkaart",
}
def analyze(_db, _project_id, dataset_id, _payload, **_kwargs):
analyzed.append(dataset_id)
return {
"theme": "population",
"summary": {
"metrics": [
{
"metric_label": "Geraamd aantal inwoners (2019)",
"metric_value": 100.0,
"metric_unit": "inwoners",
"is_estimate": True,
}
]
},
"unsupported_metrics": ["current_population"],
"limitation_message": "Rasterraming",
}
monkeypatch.setattr(VectorFeatureService, "summarize_features_by_bbox", summarize)
monkeypatch.setattr(ThematicRasterAnalysisService, "analyze", analyze)
context, metrics, _series, dataset_ids, _warnings, _scope = GeoAssistantService(Settings(_env_file=None))._build_context(
db,
project_id=project_id,
payload=AssistantQueryRequest(
question="Hoeveel inwoners zijn er en welke bodemtypes komen voor?",
bbox=payload("population_density_2019", side_m=200.0).bbox,
),
)
assert summarized == [soil_id]
assert analyzed == [population_id]
assert {metric.theme for metric in metrics} == {"soil", "population"}
assert set(dataset_ids) == {soil_id, population_id}
assert context["scope"]["requested_themes"] == ["population", "soil"]
def test_index_renderer_returns_browser_png(tmp_path) -> None:
project_id, dataset_id = uuid4(), uuid4()
values = np.linspace(0.1, 4.0, 100, dtype="float32").reshape((10, 10))
+16 -1
View File
@@ -10,6 +10,12 @@ Changed:
fail-closed rejection of `done_reason=length` unchanged.
- Added the output limit to the editable Unraid template and aligned all
deployment defaults and documentation.
- Browser QA in the central 28-municipality workbench exposed that an explicit
six-theme question still summarized every current vector theme. Agricultural
subclass intersections pushed context construction past 110 seconds.
- Added deterministic Dutch theme selection before GIS calculation. Explicit
questions now calculate only named themes; general summaries and source
inventory questions deliberately retain full context.
Validation evidence:
- A read-only live production-chain probe with the 1,200-token setting
@@ -18,7 +24,16 @@ Validation evidence:
- The complete readiness gate passed 713 backend tests, backend compilation,
105 documented API routes with three explicit binary/non-envelope routes,
one Alembic head and the frontend TypeScript and production build.
- Tower deployment and browser verification follow in this pass.
- After deterministic theme filtering was added, the complete readiness gate
passed 720 backend tests plus all compile, contract, Alembic, frontend and
shell gates.
- The regional thematic operator dry-run resolved exactly 28 official
municipality Areas. The live Mol run in `Kempen Regional Workbench` imported
all five products with complete source coverage; the DOV operator imported
1,159 exact Mol soil polygons into the same project.
- Browser validation then showed all six new themes as available and returned
3,638.41 ha space occupation, 31.76% share and the full 15-theme Mol summary
from persisted data.
Next:
- Deploy, rerun the exact six-theme Dutch question and verify the end-user