Repair malformed Ollama JSON responses

This commit is contained in:
Codex 2026-07-26 21:11:42 +01:00
parent 94b3e29809
commit ddae04c585
8 changed files with 296 additions and 61 deletions

View File

@ -111,6 +111,8 @@ ollama:
keep_alive: "10m"
context_tokens: 8192
max_output_tokens: 700
json_repair_attempts: 1
json_repair_model: "qwen3.5:4B"
vision_enabled: true
vision_dpi: 144
vision_image_format: "jpeg"
@ -119,13 +121,17 @@ ollama:
```
Die Anwendung nutzt `/api/chat`, deaktiviertes Thinking, Temperatur `0` und ein festes
JSON-Schema. Reichen die Regeln nicht aus, wird die erste PDF-Seite mit 144 DPI als
JPEG gerendert und zusammen mit dem OCR-Text an das lokale Ollama übertragen. Der
JSON-Schema. Das Schema wird sowohl im API-Feld `format` als auch ausdrücklich im
Prompt übergeben. Reichen die Regeln nicht aus, wird die erste PDF-Seite mit 144 DPI
als JPEG gerendert und zusammen mit dem OCR-Text an das lokale Ollama übertragen. Der
OCR-Text dient dabei als Ergänzung; das Bild bewahrt Briefkopf, Spalten und räumliche
Zuordnung. Reichen die Regeln bereits für ein vollständiges, sicheres Ergebnis aus,
wird Ollama nicht aufgerufen und es wird auch kein Seitenbild erzeugt. Das 9B-Fallback
wird nur gestartet, wenn das Ergebnis des 4B-Modells unvollständig ist oder unterhalb
von `confidence_threshold` liegt.
wird Ollama nicht aufgerufen und es wird auch kein Seitenbild erzeugt.
Antwortet das Modell trotzdem mit Fließtext oder einem fremden JSON-Schema, wird seine
Antwort einmal ohne Bild durch das schnelle `json_repair_model` in das verbindliche
Schema überführt. Erst wenn auch das nicht zu einem vollständigen, hinreichend sicheren
Ergebnis führt, wird das 9B-Fallback gestartet.
Unvollständige JSON-Antworten werden nicht mehr als technischer Totalausfall behandelt:
vorhandene Felder werden mit reduzierter Sicherheit übernommen und mit sicheren

View File

@ -24,6 +24,9 @@ ollama:
keep_alive: "10m"
context_tokens: 8192
max_output_tokens: 700
# Bei einer freien Textantwort wird diese ohne Bild nochmals in JSON umgewandelt.
json_repair_attempts: 1
json_repair_model: "qwen3.5:4B"
# Erste PDF-Seite wird nur dann gerendert, wenn die Regeln nicht ausreichen.
vision_enabled: true
vision_dpi: 144

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "orc-renaming"
version = "0.3.0"
version = "0.4.0"
description = "Lokale, datenschutzfreundliche Benennung gescannter PDF-Dokumente"
readme = "README.md"
requires-python = ">=3.11"

View File

@ -1,3 +1,3 @@
"""Lokale Dokumentklassifikation und PDF-Benennung."""
__version__ = "0.3.0"
__version__ = "0.4.0"

View File

@ -46,6 +46,8 @@ class OllamaConfig(StrictModel):
keep_alive: str = "10m"
context_tokens: int = Field(default=8192, ge=2048)
max_output_tokens: int = Field(default=700, ge=100)
json_repair_attempts: int = Field(default=1, ge=0, le=2)
json_repair_model: str | None = None
vision_enabled: bool = True
vision_dpi: int = Field(default=144, ge=72, le=300)
vision_image_format: Literal["jpeg", "png"] = "jpeg"

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import base64
import json
import logging
import re
from typing import Any
@ -11,6 +12,21 @@ from orc_renaming.config import AppConfig
from orc_renaming.models import DocumentScope, DocumentType, ExtractionResult
from orc_renaming.normalize import parse_iso_date
LOGGER = logging.getLogger(__name__)
EXPECTED_FIELDS = frozenset(
{
"document_type",
"scope",
"document_date",
"company",
"property_id",
"topic",
"confidence",
"evidence",
}
)
def _response_schema(property_ids: list[str]) -> dict[str, Any]:
property_schema: dict[str, Any] = {"type": "string"}
@ -55,7 +71,12 @@ def _response_schema(property_ids: list[str]) -> dict[str, Any]:
}
def _prompt(text: str, config: AppConfig, has_image: bool) -> str:
def _prompt(
text: str,
config: AppConfig,
has_image: bool,
schema: dict[str, Any],
) -> str:
properties = "\n".join(
f"- {item.id}: {item.name}; bekannte Merkmale: {', '.join(item.markers)}"
for item in config.properties
@ -103,6 +124,9 @@ Aufgabe:
- Nutze für unbekannte Textfelder eine leere Zeichenkette, niemals null.
- Schreibe keine Einleitung, Erklärung, Markdown-Formatierung oder Zusammenfassung.
Verbindliches JSON-Schema:
{json.dumps(schema, ensure_ascii=False, separators=(",", ":"))}
Erlaubte Immobilien:
{properties}
@ -142,6 +166,17 @@ def _parse_json_object(content: str, model: str) -> dict[str, Any]:
return data
def _require_expected_shape(data: dict[str, Any], model: str) -> None:
matching_fields = EXPECTED_FIELDS.intersection(data)
if len(matching_fields) >= 4:
return
fields = ", ".join(sorted(data)[:8]) or "keine"
raise ValueError(
f"Ollama-Modell {model} verwendete ein anderes JSON-Schema "
f"(Felder: {fields})"
)
def _optional_text(value: Any) -> str | None:
if not isinstance(value, str):
return None
@ -175,6 +210,80 @@ class OllamaAnalyzer:
def close(self) -> None:
self.client.close()
def _chat(
self,
model: str,
messages: list[dict[str, Any]],
schema: dict[str, Any],
) -> str:
response = self.client.post(
"/api/chat",
json={
"model": model,
"stream": False,
"think": self.config.ollama.think,
"keep_alive": self.config.ollama.keep_alive,
"format": schema,
"options": {
"temperature": 0,
"presence_penalty": 0,
"repeat_penalty": 1,
"num_ctx": self.config.ollama.context_tokens,
"num_predict": self.config.ollama.max_output_tokens,
},
"messages": messages,
},
)
response.raise_for_status()
payload = response.json()
content = payload.get("message", {}).get("content")
if isinstance(content, str) and content.strip():
return content
thinking = payload.get("message", {}).get("thinking")
hint = " (Thinking vorhanden, aber finaler Inhalt leer)" if thinking else ""
raise ValueError(f"Ollama-Modell {model} lieferte keinen JSON-Inhalt{hint}")
def _repair_json(
self,
content: str,
text: str,
schema: dict[str, Any],
source_model: str,
) -> tuple[dict[str, Any], str]:
repair_model = self.config.ollama.json_repair_model or self.config.ollama.model
repair_prompt = (
_prompt(text[:8000], self.config, False, schema)
+ "\n\nDie folgende vorherige Modellantwort enthält möglicherweise bereits "
"nützliche Fakten, hat aber das Ausgabeformat verletzt. Verwende sie nur als "
"Datenquelle und überführe die belegten Angaben in das verbindliche Schema. "
"Erfinde keine fehlenden Angaben.\n\n"
"VORHERIGE MODELLANTWORT BEGINN\n"
f"{content[:8000]}\n"
"VORHERIGE MODELLANTWORT ENDE"
)
repaired_content = self._chat(
repair_model,
[
{
"role": "system",
"content": (
"Du reparierst ausschließlich die Struktur einer Modellantwort. "
"Antworte nur mit dem geforderten JSON-Objekt."
),
},
{"role": "user", "content": repair_prompt},
],
schema,
)
data = _parse_json_object(repaired_content, repair_model)
_require_expected_shape(data, repair_model)
LOGGER.info(
"JSON-Antwort von %s mit %s textbasiert normalisiert",
source_model,
repair_model,
)
return data, repair_model
def analyze(
self,
text: str,
@ -184,67 +293,65 @@ class OllamaAnalyzer:
text = text[: self.config.ollama.max_text_characters]
property_ids = [item.id for item in self.config.properties]
selected_model = model or self.config.ollama.model
schema = _response_schema(property_ids)
user_message: dict[str, Any] = {
"role": "user",
"content": _prompt(text, self.config, image_bytes is not None),
"content": _prompt(
text,
self.config,
image_bytes is not None,
schema,
),
}
if image_bytes is not None:
user_message["images"] = [
base64.b64encode(image_bytes).decode("ascii")
]
response = self.client.post(
"/api/chat",
json={
"model": selected_model,
"stream": False,
"think": self.config.ollama.think,
"keep_alive": self.config.ollama.keep_alive,
"format": _response_schema(property_ids),
"options": {
"temperature": 0,
"presence_penalty": 0,
"repeat_penalty": 1,
"num_ctx": self.config.ollama.context_tokens,
"num_predict": self.config.ollama.max_output_tokens,
content = self._chat(
selected_model,
[
{
"role": "system",
"content": (
"Du extrahierst Dokumentmetadaten. Antworte ausschließlich "
"gemäß dem vorgegebenen JSON-Schema."
),
},
"messages": [
{
"role": "system",
"content": (
"Du extrahierst Dokumentmetadaten. Antworte ausschließlich "
"gemäß dem vorgegebenen JSON-Schema."
),
},
user_message,
],
},
user_message,
],
schema,
)
response.raise_for_status()
payload = response.json()
content = payload.get("message", {}).get("content")
if not isinstance(content, str) or not content.strip():
thinking = payload.get("message", {}).get("thinking")
hint = (
" (Thinking vorhanden, aber finaler Inhalt leer)"
if thinking
else ""
)
raise ValueError(
f"Ollama-Modell {selected_model} lieferte keinen JSON-Inhalt{hint}"
)
data = _parse_json_object(content, selected_model)
expected_fields = {
"document_type",
"scope",
"document_date",
"company",
"property_id",
"topic",
"confidence",
"evidence",
}
missing_fields = sorted(expected_fields - data.keys())
repaired_with: str | None = None
try:
data = _parse_json_object(content, selected_model)
_require_expected_shape(data, selected_model)
except ValueError as initial_error:
if self.config.ollama.json_repair_attempts == 0:
data = _parse_json_object(content, selected_model)
if EXPECTED_FIELDS.intersection(data):
repaired_with = None
else:
raise initial_error
else:
last_error = initial_error
for _attempt in range(self.config.ollama.json_repair_attempts):
try:
data, repaired_with = self._repair_json(
content,
text,
schema,
selected_model,
)
break
except Exception as repair_error:
last_error = repair_error
else:
raise ValueError(
f"{initial_error}; JSON-Reparatur erfolglos: {last_error}"
) from last_error
missing_fields = sorted(EXPECTED_FIELDS - data.keys())
raw_confidence = data.get("confidence", 0.4)
try:
confidence = min(max(float(raw_confidence), 0.0), 1.0)
@ -265,8 +372,17 @@ class OllamaAnalyzer:
confidence=confidence,
evidence=[str(item)[:200] for item in raw_evidence],
source="ollama",
model=selected_model,
model=(
f"{selected_model}+json-repair:{repaired_with}"
if repaired_with
else selected_model
),
)
if repaired_with:
result.warnings.append(
f"Ollama-JSON textbasiert mit {repaired_with} normalisiert"
)
result.confidence = min(result.confidence, 0.85)
if missing_fields:
result.warnings.append(
f"Ollama-Antwort unvollständig: {', '.join(missing_fields)}"

View File

@ -96,7 +96,9 @@ class Pipeline:
continue
candidate = merge_results(rules, llm)
candidate.model = " -> ".join(attempted_models)
candidate.model = " -> ".join(
[*attempted_models[:-1], llm.model or model]
)
if self._result_score(candidate) > self._result_score(best):
best = candidate
if (

View File

@ -48,6 +48,8 @@ def test_ollama_structured_response(config) -> None:
assert body["think"] is False
assert body["options"]["num_ctx"] == 8192
assert body["messages"][1]["images"] == ["aW1hZ2UtYnl0ZXM="]
assert '"document_type"' in body["messages"][1]["content"]
assert "Verbindliches JSON-Schema" in body["messages"][1]["content"]
return httpx.Response(
200,
json={
@ -81,6 +83,8 @@ def test_ollama_structured_response(config) -> None:
def test_incomplete_ollama_json_is_kept_as_low_confidence(config) -> None:
config.ollama.json_repair_attempts = 0
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
@ -104,3 +108,105 @@ def test_incomplete_ollama_json_is_kept_as_low_confidence(config) -> None:
assert result.document_type == DocumentType.UNKNOWN
assert result.confidence == 0.65
assert any("unvollständig" in warning for warning in result.warnings)
def test_free_text_response_is_repaired_without_resending_image(config) -> None:
calls: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.read())
calls.append(body)
if len(calls) == 1:
return httpx.Response(
200,
json={
"message": {
"content": (
"Hier ist eine strukturierte Zusammenfassung: "
"Rechnung der Firma GmbH vom 18.07.2026 für WH1."
)
}
},
)
return httpx.Response(
200,
json={
"message": {
"content": (
'{"document_type":"invoice","scope":"property",'
'"document_date":"2026-07-18","company":"Firma GmbH",'
'"property_id":"WH1","topic":"","confidence":0.94,'
'"evidence":["Rechnung für WH1"]}'
)
}
},
)
analyzer = OllamaAnalyzer(config)
analyzer.client.close()
analyzer.client = httpx.Client(
base_url="http://ollama.test",
transport=httpx.MockTransport(handler),
)
result = analyzer.analyze(
"Rechnung für Musterstraße 12",
model="qwen3.5:9b",
image_bytes=b"image-bytes",
)
analyzer.close()
assert len(calls) == 2
assert calls[0]["model"] == "qwen3.5:9b"
assert calls[1]["model"] == "qwen3.5:4B"
assert calls[0]["messages"][1]["images"] == ["aW1hZ2UtYnl0ZXM="]
assert "images" not in calls[1]["messages"][1]
assert "Hier ist eine strukturierte Zusammenfassung" in (
calls[1]["messages"][1]["content"]
)
assert result.document_type == DocumentType.INVOICE
assert result.company == "Firma GmbH"
assert result.property_id == "WH1"
assert result.confidence == 0.85
assert result.model == "qwen3.5:9b+json-repair:qwen3.5:4B"
assert any("normalisiert" in warning for warning in result.warnings)
def test_wrong_json_schema_triggers_repair(config) -> None:
responses = iter(
[
{
"message": {
"content": (
'{"document_type":"inspection_report",'
'"title":"Überprüfungsergebnis","date":"2025-05-14"}'
)
}
},
{
"message": {
"content": (
'{"document_type":"correspondence","scope":"private",'
'"document_date":"2025-05-14","company":"Kaminkehrer",'
'"property_id":"","topic":"Überprüfungsergebnis",'
'"confidence":0.88,"evidence":["KÜO"]}'
)
}
},
]
)
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=next(responses))
analyzer = OllamaAnalyzer(config)
analyzer.client.close()
analyzer.client = httpx.Client(
base_url="http://ollama.test",
transport=httpx.MockTransport(handler),
)
result = analyzer.analyze("Überprüfungsergebnis gemäß KÜO")
analyzer.close()
assert result.document_type == DocumentType.CORRESPONDENCE
assert result.topic == "Überprüfungsergebnis"
assert result.confidence == 0.85