Optimize assistant context by requested themes
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user