From 56f823f02a8a36c79df7f2b4a8a5da7db37b5810 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 07:39:03 +0200 Subject: [PATCH] fix: prevent truncated Ollama answers --- CHANGELOG.md | 2 + backend/README.md | 5 +- backend/app/core/config.py | 1 + backend/app/services/geo_assistant_service.py | 16 ++++++- ...t_sprint202_temporal_metrics_and_ollama.py | 46 +++++++++++++++++++ deploy/unraid/README.md | 1 + deploy/unraid/geointel-unraid-template.xml | 1 + deploy/unraid/geointel.env.example | 1 + deploy/unraid/run-dockerman-container.sh | 2 + docker-compose.unraid.yml | 1 + docker-compose.yml | 1 + docs/API_CONTRACTS.md | 5 +- docs/CODEX_EXECUTION_LOG.md | 11 ++++- 13 files changed, 89 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b56770..4dc8681e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ refuses to infer unavailable values such as water volume. - Added editable Unraid environment/template settings and a Docker host-gateway mapping for the Ollama service running on the server. +- Added an explicit 16,384-token Ollama context window and reject truncated + `done_reason=length` responses instead of showing an incomplete answer. ## Sprint 201 Semantic area-selection metrics (2026-07-15) diff --git a/backend/README.md b/backend/README.md index 04f98096..ba1e17e9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1100,6 +1100,7 @@ OLLAMA_BASE_URL=http://host.docker.internal:11434 OLLAMA_DEFAULT_MODEL=qwen3.5:9b OLLAMA_TIMEOUT_SECONDS=120 OLLAMA_MAX_OUTPUT_TOKENS=700 +OLLAMA_CONTEXT_TOKENS=16384 ``` The Unraid deployment adds `host.docker.internal:host-gateway` automatically. @@ -1107,7 +1108,9 @@ Verify the connection with `GET /api/v1/assistant/status`, inspect installed models with `GET /api/v1/assistant/models` and ask a grounded question through `POST /api/v1/projects/{project_id}/assistant/query`. A requested model must be present in Ollama `/api/tags`. Missing water depth/bathymetry remains explicit; -the assistant cannot turn 2D water geometry into volume. +the assistant cannot turn 2D water geometry into volume. GeoIntel rejects an +answer when Ollama reports `done_reason=length`, so a visibly truncated sentence +is never presented as a complete result. ## Helpful repository scripts diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 00c9af5d..bbf0c9ab 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -51,6 +51,7 @@ class Settings(BaseSettings): ollama_default_model: str = Field(default="qwen3.5:9b", validation_alias="OLLAMA_DEFAULT_MODEL") ollama_timeout_seconds: int = Field(default=120, ge=5, le=600, validation_alias="OLLAMA_TIMEOUT_SECONDS") ollama_max_output_tokens: int = Field(default=700, ge=100, le=4_000, validation_alias="OLLAMA_MAX_OUTPUT_TOKENS") + ollama_context_tokens: int = Field(default=16_384, ge=4_096, le=131_072, validation_alias="OLLAMA_CONTEXT_TOKENS") cors_origins: list[str] | str = Field( default=["http://localhost:5173", "http://127.0.0.1:5173"], validation_alias="CORS_ORIGINS", diff --git a/backend/app/services/geo_assistant_service.py b/backend/app/services/geo_assistant_service.py index f58c45f9..e8643b7d 100644 --- a/backend/app/services/geo_assistant_service.py +++ b/backend/app/services/geo_assistant_service.py @@ -367,9 +367,23 @@ class GeoAssistantService: "stream": False, "think": False, "keep_alive": "10m", - "options": {"temperature": 0.1, "num_predict": self.settings.ollama_max_output_tokens}, + "options": { + "temperature": 0.1, + "num_ctx": self.settings.ollama_context_tokens, + "num_predict": self.settings.ollama_max_output_tokens, + }, }, ) + if response.get("done_reason") == "length": + raise AppError( + code="OLLAMA_RESPONSE_TRUNCATED", + message="Ollama kon geen volledig antwoord binnen de ingestelde contextlimiet genereren.", + details={ + "context_tokens": self.settings.ollama_context_tokens, + "max_output_tokens": self.settings.ollama_max_output_tokens, + }, + status_code=502, + ) message = response.get("message") if isinstance(response.get("message"), dict) else {} answer = str(message.get("content") or "").strip() if not answer: diff --git a/backend/tests/test_sprint202_temporal_metrics_and_ollama.py b/backend/tests/test_sprint202_temporal_metrics_and_ollama.py index 7e2c4664..f8352210 100644 --- a/backend/tests/test_sprint202_temporal_metrics_and_ollama.py +++ b/backend/tests/test_sprint202_temporal_metrics_and_ollama.py @@ -155,11 +155,57 @@ def test_geo_assistant_sends_grounded_context_without_thinking_trace(monkeypatch assert captured["path"] == "/api/chat" assert captured["payload"]["stream"] is False assert captured["payload"]["think"] is False + assert captured["payload"]["options"]["num_ctx"] == 16_384 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 "water_volume_available" in captured["payload"]["messages"][0]["content"] +def test_geo_assistant_rejects_truncated_ollama_answer(monkeypatch) -> None: + service = GeoAssistantService(ollama_settings()) + monkeypatch.setattr(service, "list_models", lambda: [AssistantModelRead(name="qwen3.5:9b")]) + monkeypatch.setattr( + service, + "_build_context", + lambda *args, **kwargs: ( + {"scope": {"label": "Gemeente Mol"}}, + [], + [], + [], + [], + "Gemeente Mol", + ), + ) + monkeypatch.setattr( + service, + "_request_json", + lambda path, payload=None: { + "done": True, + "done_reason": "length", + "message": {"role": "assistant", "content": "Een onvolledige zin"}, + }, + ) + + with pytest.raises(AppError) as exc_info: + service.query( + object(), + project_id=uuid4(), + payload=AssistantQueryRequest(question="Hoe evolueerde Mol?"), + ) + + assert exc_info.value.code == "OLLAMA_RESPONSE_TRUNCATED" + + +def test_unraid_ollama_context_window_is_configurable() -> None: + compose = (ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8") + template = (ROOT / "deploy/unraid/geointel-unraid-template.xml").read_text(encoding="utf-8") + env_example = (ROOT / "deploy/unraid/geointel.env.example").read_text(encoding="utf-8") + + assert "OLLAMA_CONTEXT_TOKENS: ${OLLAMA_CONTEXT_TOKENS:-16384}" in compose + assert 'Target="OLLAMA_CONTEXT_TOKENS"' in template + assert "OLLAMA_CONTEXT_TOKENS=16384" in env_example + + def test_temporal_comparison_preserves_all_compatible_semantic_metrics() -> None: earlier = { "metrics": [ diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md index d5381789..c442ddfe 100644 --- a/deploy/unraid/README.md +++ b/deploy/unraid/README.md @@ -201,6 +201,7 @@ OLLAMA_BASE_URL=http://host.docker.internal:11434 OLLAMA_DEFAULT_MODEL=qwen3.5:9b OLLAMA_TIMEOUT_SECONDS=120 OLLAMA_MAX_OUTPUT_TOKENS=700 +OLLAMA_CONTEXT_TOKENS=16384 ``` The model dropdown comes from Ollama `/api/tags`, so changing the installed diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml index a2460c52..6ef56a45 100644 --- a/deploy/unraid/geointel-unraid-template.xml +++ b/deploy/unraid/geointel-unraid-template.xml @@ -38,4 +38,5 @@ http://host.docker.internal:11434 qwen3.5:9b 120 + 16384 diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example index dca6ec9d..1a18e6b8 100644 --- a/deploy/unraid/geointel.env.example +++ b/deploy/unraid/geointel.env.example @@ -56,3 +56,4 @@ OLLAMA_BASE_URL=http://host.docker.internal:11434 OLLAMA_DEFAULT_MODEL=qwen3.5:9b OLLAMA_TIMEOUT_SECONDS=120 OLLAMA_MAX_OUTPUT_TOKENS=700 +OLLAMA_CONTEXT_TOKENS=16384 diff --git a/deploy/unraid/run-dockerman-container.sh b/deploy/unraid/run-dockerman-container.sh index cd22e5c5..a733b8aa 100644 --- a/deploy/unraid/run-dockerman-container.sh +++ b/deploy/unraid/run-dockerman-container.sh @@ -45,6 +45,7 @@ OLLAMA_BASE_URL="${OLLAMA_BASE_URL:-http://host.docker.internal:11434}" OLLAMA_DEFAULT_MODEL="${OLLAMA_DEFAULT_MODEL:-qwen3.5:9b}" OLLAMA_TIMEOUT_SECONDS="${OLLAMA_TIMEOUT_SECONDS:-120}" OLLAMA_MAX_OUTPUT_TOKENS="${OLLAMA_MAX_OUTPUT_TOKENS:-700}" +OLLAMA_CONTEXT_TOKENS="${OLLAMA_CONTEXT_TOKENS:-16384}" install_dockerman_metadata() { if [ -d /boot/config/plugins/dockerMan ]; then @@ -120,6 +121,7 @@ docker run -d \ -e OLLAMA_DEFAULT_MODEL="$OLLAMA_DEFAULT_MODEL" \ -e OLLAMA_TIMEOUT_SECONDS="$OLLAMA_TIMEOUT_SECONDS" \ -e OLLAMA_MAX_OUTPUT_TOKENS="$OLLAMA_MAX_OUTPUT_TOKENS" \ + -e OLLAMA_CONTEXT_TOKENS="$OLLAMA_CONTEXT_TOKENS" \ -v "${GEOINTEL_POSTGIS_DATA_PATH}:/var/lib/postgresql/data" \ -v "${GEOINTEL_STORAGE_PATH}:/app/storage" \ -v "${GEOINTEL_MODELS_PATH}:/app/models" \ diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index be98a9a1..770a3632 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -43,6 +43,7 @@ services: OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-qwen3.5:9b} OLLAMA_TIMEOUT_SECONDS: ${OLLAMA_TIMEOUT_SECONDS:-120} OLLAMA_MAX_OUTPUT_TOKENS: ${OLLAMA_MAX_OUTPUT_TOKENS:-700} + OLLAMA_CONTEXT_TOKENS: ${OLLAMA_CONTEXT_TOKENS:-16384} ports: - "${GEOINTEL_FRONTEND_PORT:-1202}:80" volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 10d3464e..1c3eab88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,7 @@ services: OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-qwen3.5:9b} OLLAMA_TIMEOUT_SECONDS: ${OLLAMA_TIMEOUT_SECONDS:-120} OLLAMA_MAX_OUTPUT_TOKENS: ${OLLAMA_MAX_OUTPUT_TOKENS:-700} + OLLAMA_CONTEXT_TOKENS: ${OLLAMA_CONTEXT_TOKENS:-16384} ports: - "${GEOINTEL_BACKEND_PORT:-8000}:8000" volumes: diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 5d9dc8e8..c83885bb 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -1637,4 +1637,7 @@ metrics from PostGIS and includes dated observations only for persisted temporal series. Geometry is not sent to Ollama. The response contains the answer, used model, scope label, context metrics, discovered temporal series, source dataset ids and warnings. Missing measurements remain unavailable; -specifically, no water volume is inferred from 2D water geometry. +specifically, no water volume is inferred from 2D water geometry. The backend +sets an explicit Ollama context window and returns +`OLLAMA_RESPONSE_TRUNCATED` instead of accepting a response with +`done_reason=length` as a complete answer. diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 6333250a..21587274 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -8389,7 +8389,7 @@ Validation evidence: - Focused temporal, source, Ollama, navigation and Unraid regression tests passed, including direct coverage for multi-metric history and DockerMan host mapping. -- Full readiness passed 615 backend tests, backend compilation, 91 documented +- Full readiness passed 617 backend tests, backend compilation, 91 documented API routes, one Alembic head, frontend TypeScript typecheck/build and all shell syntax gates. - Tower deployment passed the all-in-one container health check, live PostGIS @@ -8403,6 +8403,15 @@ Validation evidence: message keys now use a session-local monotonic id generator; no persisted or security-sensitive identity depends on it. Typecheck, production build and full readiness reran successfully. +- The regional operator then reused the five governed forest snapshots and + imported 15 new authoritative snapshots: water, built functions and + transport for 2013, 2016, 2019, 2022 and 2025. A live exact-Area Mol water + comparison returned 606.16 ha in 2013 and 641.75 ha in 2025 across all five + observations; the API retained the 10 m raster and object-lineage warnings. +- Browser QA also reproduced an Ollama `done_reason=length`: the previous + implicit 4096-token context left only 86 tokens after a 4010-token grounded + prompt. GeoIntel now requests a configurable 16,384-token context and rejects + any future length-truncated response instead of presenting a partial answer. Known limitations: - Water volume remains unavailable until a governed depth/bathymetry source is