fix: prevent truncated Ollama answers
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 07:39:03 +02:00
parent 5be8f628ef
commit 56f823f02a
13 changed files with 89 additions and 4 deletions
+2
View File
@@ -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)
+4 -1
View File
@@ -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
+1
View File
@@ -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",
+15 -1
View File
@@ -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:
@@ -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": [
+1
View File
@@ -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
@@ -38,4 +38,5 @@
<Config Name="Ollama Base URL" Target="OLLAMA_BASE_URL" Default="http://host.docker.internal:11434" Mode="" Description="Ollama API reachable from the container. The deployment maps host.docker.internal to the Unraid host gateway." Type="Variable" Display="always" Required="true" Mask="false">http://host.docker.internal:11434</Config>
<Config Name="Default Ollama Model" Target="OLLAMA_DEFAULT_MODEL" Default="qwen3.5:9b" Mode="" Description="Preferred locally installed Ollama model. Users can select another installed model in GeoIntel." Type="Variable" Display="always" Required="true" Mask="false">qwen3.5:9b</Config>
<Config Name="Ollama Timeout Seconds" Target="OLLAMA_TIMEOUT_SECONDS" Default="120" Mode="" Description="Maximum wait for one local assistant response." Type="Variable" Display="advanced" Required="true" Mask="false">120</Config>
<Config Name="Ollama Context Tokens" Target="OLLAMA_CONTEXT_TOKENS" Default="16384" Mode="" Description="Context window reserved for grounded GIS measurements and the generated answer." Type="Variable" Display="advanced" Required="true" Mask="false">16384</Config>
</Container>
+1
View File
@@ -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
+2
View File
@@ -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" \
+1
View File
@@ -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:
+1
View File
@@ -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:
+4 -1
View File
@@ -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.
+10 -1
View File
@@ -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