diff --git a/CHANGELOG.md b/CHANGELOG.md index ef74d1a8..202543cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/app/services/geo_assistant_service.py b/backend/app/services/geo_assistant_service.py index 8ee45cc4..13da70da 100644 --- a/backend/app/services/geo_assistant_service.py +++ b/backend/app/services/geo_assistant_service.py @@ -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": { diff --git a/backend/tests/test_sprint202_temporal_metrics_and_ollama.py b/backend/tests/test_sprint202_temporal_metrics_and_ollama.py index ff0bd41f..cf699a07 100644 --- a/backend/tests/test_sprint202_temporal_metrics_and_ollama.py +++ b/backend/tests/test_sprint202_temporal_metrics_and_ollama.py @@ -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( diff --git a/backend/tests/test_sprint213_thematic_rasters.py b/backend/tests/test_sprint213_thematic_rasters.py index 019928b3..87aa559b 100644 --- a/backend/tests/test_sprint213_thematic_rasters.py +++ b/backend/tests/test_sprint213_thematic_rasters.py @@ -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)) diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index bcef9dad..8c0ced8e 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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