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