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. chronology or unsupported-metric rules.
- Exposed the output limit in the Unraid DockerMan template and aligned all - Exposed the output limit in the Unraid DockerMan template and aligned all
Compose, runtime, example and operator documentation defaults. 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) ## Sprint 213-214 Cross-domain area profile (2026-07-16)
+63 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
@@ -57,12 +58,50 @@ class GeoAssistantService:
"accessibility": "bereikbaarheidsscores", "accessibility": "bereikbaarheidsscores",
"services": "voorzieningenscores", "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 @classmethod
def history_requested(cls, question: str) -> bool: def history_requested(cls, question: str) -> bool:
normalized = question.casefold() normalized = question.casefold()
return any(keyword in normalized for keyword in cls.HISTORY_KEYWORDS) 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 @classmethod
def ensure_estimate_disclosure( def ensure_estimate_disclosure(
cls, cls,
@@ -269,17 +308,33 @@ class GeoAssistantService:
.all() .all()
) )
vector_datasets = [dataset for dataset in datasets if dataset.dataset_type in {"vector", "geojson"}] 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 = [ flood_hazard_datasets = [
dataset dataset
for dataset in datasets for dataset in datasets
if dataset.dataset_type == "raster" and dataset.source_name == FloodHazardAcquisitionService.PROVIDER 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 (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 = [ thematic_candidates = [
dataset dataset
for dataset in datasets for dataset in datasets
if dataset.dataset_type == "raster" and dataset.source_name == ThematicRasterAcquisitionService.PROVIDER 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 (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] = {} thematic_by_product: dict[str, Dataset] = {}
for dataset in thematic_candidates: for dataset in thematic_candidates:
@@ -294,7 +349,7 @@ class GeoAssistantService:
current_context: list[dict[str, Any]] = [] current_context: list[dict[str, Any]] = []
if bbox is not None: 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} kwargs: dict[str, Any] = {"dataset": dataset, "bbox": bbox}
if area is not None: if area is not None:
kwargs["selection_geometry"] = area.geometry kwargs["selection_geometry"] = area.geometry
@@ -441,7 +496,7 @@ class GeoAssistantService:
temporal_series: list[AssistantTemporalSeries] = [] temporal_series: list[AssistantTemporalSeries] = []
temporal_context: list[dict[str, Any]] = [] temporal_context: list[dict[str, Any]] = []
include_history = self.history_requested(payload.question) 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] first = observations[0]
last = observations[-1] last = observations[-1]
source_metadata = last.source_metadata if isinstance(last.source_metadata, dict) else {} source_metadata = last.source_metadata if isinstance(last.source_metadata, dict) else {}
@@ -483,7 +538,12 @@ class GeoAssistantService:
context = { context = {
"project": {"id": str(project.id), "name": project.name, "region": project.region}, "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, "current_measurements": current_context,
"available_temporal_series": temporal_context, "available_temporal_series": temporal_context,
"rules": { "rules": {
@@ -98,6 +98,28 @@ def test_geo_assistant_recognizes_dutch_historical_questions(question: str) -> N
assert GeoAssistantService.history_requested(question) is True 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: def test_geo_assistant_discloses_estimated_population_values() -> None:
metrics = [ metrics = [
AssistantContextMetric( 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_acquisition_service import ThematicRasterAcquisitionService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
from app.services.dataset_service import DatasetService from app.services.dataset_service import DatasetService
from app.services.vector_feature_service import VectorFeatureService
ROOT = Path(__file__).resolve().parents[2] 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 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: def test_index_renderer_returns_browser_png(tmp_path) -> None:
project_id, dataset_id = uuid4(), uuid4() project_id, dataset_id = uuid4(), uuid4()
values = np.linspace(0.1, 4.0, 100, dtype="float32").reshape((10, 10)) 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. fail-closed rejection of `done_reason=length` unchanged.
- Added the output limit to the editable Unraid template and aligned all - Added the output limit to the editable Unraid template and aligned all
deployment defaults and documentation. 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: Validation evidence:
- A read-only live production-chain probe with the 1,200-token setting - 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, - The complete readiness gate passed 713 backend tests, backend compilation,
105 documented API routes with three explicit binary/non-envelope routes, 105 documented API routes with three explicit binary/non-envelope routes,
one Alembic head and the frontend TypeScript and production build. 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: Next:
- Deploy, rerun the exact six-theme Dutch question and verify the end-user - Deploy, rerun the exact six-theme Dutch question and verify the end-user