From d0210b9a3c68ac2306770a329708e62d0692803d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 03:54:25 +0200 Subject: [PATCH] Polish grounded assistant output --- CHANGELOG.md | 3 +++ backend/app/services/geo_assistant_service.py | 26 +++++++++++++++++++ ...t_sprint202_temporal_metrics_and_ollama.py | 21 +++++++++++++++ docs/CODEX_EXECUTION_LOG.md | 6 +++++ 4 files changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 202543cd..07ab849b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ - 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. +- Added deterministic semantic rounding for model context and normalized model + Markdown to the existing plain-text renderer so hectare, population and + score answers remain readable without exposing raw floating-point tails. ## 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 13da70da..85f843c8 100644 --- a/backend/app/services/geo_assistant_service.py +++ b/backend/app/services/geo_assistant_service.py @@ -132,6 +132,26 @@ class GeoAssistantService: f"{answer}" ) + @staticmethod + def rounded_context_value(value: float, unit: str) -> int | float: + normalized_unit = unit.casefold().strip() + if normalized_unit in {"inwoners", "personen", "objecten", "features"}: + return int(round(value)) + if "%" in normalized_unit or "ha" in normalized_unit or "km" in normalized_unit or normalized_unit == "m": + return round(value, 2) + if "score" in normalized_unit: + return round(value, 4) + return round(value, 2) + + @staticmethod + def normalize_plain_text(answer: str) -> str: + lines: list[str] = [] + for line in answer.splitlines(): + normalized = re.sub(r"^\s*\*\s+", "- ", line.strip()) + normalized = normalized.replace("**", "").replace("__", "").replace("`", "") + lines.append(normalized) + return "\n".join(lines).strip() + def __init__(self, settings: Settings | None = None): self.settings = settings or get_settings() @@ -386,6 +406,7 @@ class GeoAssistantService: ) context_metrics.append(item) serialized_metrics.append(item.model_dump(mode="json")) + serialized_metrics[-1]["value"] = self.rounded_context_value(item.value, item.unit) serialized_metrics[-1]["measurement_quality"] = ( "schatting" if item.is_estimate else "exact_binnen_bronrepresentatie" ) @@ -428,6 +449,7 @@ class GeoAssistantService: ) context_metrics.append(item) serialized_metrics.append(item.model_dump(mode="json")) + serialized_metrics[-1]["value"] = self.rounded_context_value(item.value, item.unit) serialized_metrics[-1]["measurement_quality"] = "resolutiegebonden_bronmeting" source_dataset_ids.append(dataset.id) current_context.append( @@ -473,6 +495,7 @@ class GeoAssistantService: ) context_metrics.append(item) serialized_metrics.append(item.model_dump(mode="json")) + serialized_metrics[-1]["value"] = self.rounded_context_value(item.value, item.unit) serialized_metrics[-1]["measurement_quality"] = "exacte_berekening_binnen_gemodelleerd_scenario" source_dataset_ids.append(dataset.id) current_context.append( @@ -584,6 +607,8 @@ class GeoAssistantService: "scope.label is het exact geanalyseerde gebied; vervang dit nooit door project.name of project.region. " "Noem bij cijfers de bron en eenheid. Maak duidelijk onderscheid tussen exacte metingen en schattingen. " "Als is_estimate true is, noem de waarde verplicht een schatting en nooit exact. " + "Een officiƫle bron maakt een afgeleide gebiedswaarde niet exact; noem een schatting nooit officieel geteld. " + "De numerieke contextwaarden zijn al bronveilig afgerond; neem die afgeronde waarden letterlijk over. " "Objectaantallen zijn ondersteunend; geef betekenisvolle oppervlakte-, lengte- of bevolkingsmetriek voorrang. " "Wanneer de gebruiker meerdere thema's opsomt, behandel elk gevraagd thema en voeg geen ongevraagd thema toe. " "Houd het antwoord beknopt: groepeer de kernmetrieken per gevraagd thema en herhaal geen beperkingen. " @@ -627,6 +652,7 @@ class GeoAssistantService: answer = str(message.get("content") or "").strip() if not answer: raise AppError(code="OLLAMA_EMPTY_RESPONSE", message="Ollama gaf geen antwoord terug.", status_code=502) + answer = self.normalize_plain_text(answer) answer = self.ensure_estimate_disclosure(answer, metrics) return AssistantQueryResponse( answer=answer, diff --git a/backend/tests/test_sprint202_temporal_metrics_and_ollama.py b/backend/tests/test_sprint202_temporal_metrics_and_ollama.py index cf699a07..17231571 100644 --- a/backend/tests/test_sprint202_temporal_metrics_and_ollama.py +++ b/backend/tests/test_sprint202_temporal_metrics_and_ollama.py @@ -165,6 +165,25 @@ def test_geo_assistant_does_not_add_irrelevant_estimate_disclosure() -> None: assert answer == "De bosoppervlakte bedraagt 3.626,56 hectare." +@pytest.mark.parametrize( + ("value", "unit", "expected"), + [ + (36_782.6497, "inwoners", 36_783), + (3_638.4167, "ha", 3_638.42), + (31.76431, "%", 31.76), + (0.680612, "score", 0.6806), + ], +) +def test_geo_assistant_rounds_prompt_values_by_semantic_unit(value: float, unit: str, expected: int | float) -> None: + assert GeoAssistantService.rounded_context_value(value, unit) == expected + + +def test_geo_assistant_normalizes_model_markdown_for_plain_text_renderer() -> None: + answer = GeoAssistantService.normalize_plain_text("**Bevolking**\n* 36.783 inwoners\n`Bron: Statbel`") + + assert answer == "Bevolking\n- 36.783 inwoners\nBron: Statbel" + + def test_geo_assistant_sends_grounded_context_without_thinking_trace(monkeypatch) -> None: service = GeoAssistantService(ollama_settings()) project_id = uuid4() @@ -228,6 +247,8 @@ def test_geo_assistant_sends_grounded_context_without_thinking_trace(monkeypatch assert "Gebruik uitsluitend feiten en cijfers uit CONTEXT_JSON" in captured["payload"]["messages"][0]["content"] assert "scope.label is het exact geanalyseerde gebied" in captured["payload"]["messages"][0]["content"] assert "noem de waarde verplicht een schatting" in captured["payload"]["messages"][0]["content"] + assert "noem een schatting nooit officieel geteld" in captured["payload"]["messages"][0]["content"] + assert "contextwaarden zijn al bronveilig afgerond" in captured["payload"]["messages"][0]["content"] assert "verzin geen oorzaak, voorspelling, verzadiging" in captured["payload"]["messages"][0]["content"] assert "bereken zelf geen gemiddelde, tempo, oorzaak of afgeleide trend" in captured["payload"]["messages"][0]["content"] assert "zonder Markdown-symbolen" in captured["payload"]["messages"][0]["content"] diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 8c0ced8e..b303a8ef 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -16,6 +16,10 @@ Changed: - 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. +- The optimized live response completed in 12.3 seconds but exposed literal + Markdown and raw floating-point tails in the plain-text chat renderer. Added + deterministic Markdown normalization and unit-aware context rounding while + retaining the unrounded metrics in the canonical API response. Validation evidence: - A read-only live production-chain probe with the 1,200-token setting @@ -27,6 +31,8 @@ Validation evidence: - After deterministic theme filtering was added, the complete readiness gate passed 720 backend tests plus all compile, contract, Alembic, frontend and shell gates. +- After plain-text normalization and semantic prompt rounding, the final + readiness rerun passed 725 backend tests and the same complete gate set. - 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