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" keep_alive: "10m"
context_tokens: 8192 context_tokens: 8192
max_output_tokens: 700 max_output_tokens: 700
json_repair_attempts: 1
json_repair_model: "qwen3.5:4B"
vision_enabled: true vision_enabled: true
vision_dpi: 144 vision_dpi: 144
vision_image_format: "jpeg" vision_image_format: "jpeg"
@ -119,13 +121,17 @@ ollama:
``` ```
Die Anwendung nutzt `/api/chat`, deaktiviertes Thinking, Temperatur `0` und ein festes 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 JSON-Schema. Das Schema wird sowohl im API-Feld `format` als auch ausdrücklich im
JPEG gerendert und zusammen mit dem OCR-Text an das lokale Ollama übertragen. Der 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 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, 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 Ollama nicht aufgerufen und es wird auch kein Seitenbild erzeugt.
wird nur gestartet, wenn das Ergebnis des 4B-Modells unvollständig ist oder unterhalb
von `confidence_threshold` liegt. 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: Unvollständige JSON-Antworten werden nicht mehr als technischer Totalausfall behandelt:
vorhandene Felder werden mit reduzierter Sicherheit übernommen und mit sicheren vorhandene Felder werden mit reduzierter Sicherheit übernommen und mit sicheren

View File

@ -24,6 +24,9 @@ ollama:
keep_alive: "10m" keep_alive: "10m"
context_tokens: 8192 context_tokens: 8192
max_output_tokens: 700 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. # Erste PDF-Seite wird nur dann gerendert, wenn die Regeln nicht ausreichen.
vision_enabled: true vision_enabled: true
vision_dpi: 144 vision_dpi: 144

View File

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

View File

@ -1,3 +1,3 @@
"""Lokale Dokumentklassifikation und PDF-Benennung.""" """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" keep_alive: str = "10m"
context_tokens: int = Field(default=8192, ge=2048) context_tokens: int = Field(default=8192, ge=2048)
max_output_tokens: int = Field(default=700, ge=100) 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_enabled: bool = True
vision_dpi: int = Field(default=144, ge=72, le=300) vision_dpi: int = Field(default=144, ge=72, le=300)
vision_image_format: Literal["jpeg", "png"] = "jpeg" vision_image_format: Literal["jpeg", "png"] = "jpeg"

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import base64 import base64
import json import json
import logging
import re import re
from typing import Any 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.models import DocumentScope, DocumentType, ExtractionResult
from orc_renaming.normalize import parse_iso_date 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]: def _response_schema(property_ids: list[str]) -> dict[str, Any]:
property_schema: dict[str, Any] = {"type": "string"} 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( properties = "\n".join(
f"- {item.id}: {item.name}; bekannte Merkmale: {', '.join(item.markers)}" f"- {item.id}: {item.name}; bekannte Merkmale: {', '.join(item.markers)}"
for item in config.properties for item in config.properties
@ -103,6 +124,9 @@ Aufgabe:
- Nutze für unbekannte Textfelder eine leere Zeichenkette, niemals null. - Nutze für unbekannte Textfelder eine leere Zeichenkette, niemals null.
- Schreibe keine Einleitung, Erklärung, Markdown-Formatierung oder Zusammenfassung. - Schreibe keine Einleitung, Erklärung, Markdown-Formatierung oder Zusammenfassung.
Verbindliches JSON-Schema:
{json.dumps(schema, ensure_ascii=False, separators=(",", ":"))}
Erlaubte Immobilien: Erlaubte Immobilien:
{properties} {properties}
@ -142,6 +166,17 @@ def _parse_json_object(content: str, model: str) -> dict[str, Any]:
return data 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: def _optional_text(value: Any) -> str | None:
if not isinstance(value, str): if not isinstance(value, str):
return None return None
@ -175,6 +210,80 @@ class OllamaAnalyzer:
def close(self) -> None: def close(self) -> None:
self.client.close() 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( def analyze(
self, self,
text: str, text: str,
@ -184,67 +293,65 @@ class OllamaAnalyzer:
text = text[: self.config.ollama.max_text_characters] text = text[: self.config.ollama.max_text_characters]
property_ids = [item.id for item in self.config.properties] property_ids = [item.id for item in self.config.properties]
selected_model = model or self.config.ollama.model selected_model = model or self.config.ollama.model
schema = _response_schema(property_ids)
user_message: dict[str, Any] = { user_message: dict[str, Any] = {
"role": "user", "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: if image_bytes is not None:
user_message["images"] = [ user_message["images"] = [
base64.b64encode(image_bytes).decode("ascii") base64.b64encode(image_bytes).decode("ascii")
] ]
response = self.client.post( content = self._chat(
"/api/chat", selected_model,
json={ [
"model": selected_model, {
"stream": False, "role": "system",
"think": self.config.ollama.think, "content": (
"keep_alive": self.config.ollama.keep_alive, "Du extrahierst Dokumentmetadaten. Antworte ausschließlich "
"format": _response_schema(property_ids), "gemäß dem vorgegebenen JSON-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": [ user_message,
{ ],
"role": "system", schema,
"content": (
"Du extrahierst Dokumentmetadaten. Antworte ausschließlich "
"gemäß dem vorgegebenen JSON-Schema."
),
},
user_message,
],
},
) )
response.raise_for_status() repaired_with: str | None = None
payload = response.json() try:
content = payload.get("message", {}).get("content") data = _parse_json_object(content, selected_model)
if not isinstance(content, str) or not content.strip(): _require_expected_shape(data, selected_model)
thinking = payload.get("message", {}).get("thinking") except ValueError as initial_error:
hint = ( if self.config.ollama.json_repair_attempts == 0:
" (Thinking vorhanden, aber finaler Inhalt leer)" data = _parse_json_object(content, selected_model)
if thinking if EXPECTED_FIELDS.intersection(data):
else "" repaired_with = None
) else:
raise ValueError( raise initial_error
f"Ollama-Modell {selected_model} lieferte keinen JSON-Inhalt{hint}" else:
) last_error = initial_error
data = _parse_json_object(content, selected_model) for _attempt in range(self.config.ollama.json_repair_attempts):
expected_fields = { try:
"document_type", data, repaired_with = self._repair_json(
"scope", content,
"document_date", text,
"company", schema,
"property_id", selected_model,
"topic", )
"confidence", break
"evidence", except Exception as repair_error:
} last_error = repair_error
missing_fields = sorted(expected_fields - data.keys()) 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) raw_confidence = data.get("confidence", 0.4)
try: try:
confidence = min(max(float(raw_confidence), 0.0), 1.0) confidence = min(max(float(raw_confidence), 0.0), 1.0)
@ -265,8 +372,17 @@ class OllamaAnalyzer:
confidence=confidence, confidence=confidence,
evidence=[str(item)[:200] for item in raw_evidence], evidence=[str(item)[:200] for item in raw_evidence],
source="ollama", 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: if missing_fields:
result.warnings.append( result.warnings.append(
f"Ollama-Antwort unvollständig: {', '.join(missing_fields)}" f"Ollama-Antwort unvollständig: {', '.join(missing_fields)}"

View File

@ -96,7 +96,9 @@ class Pipeline:
continue continue
candidate = merge_results(rules, llm) 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): if self._result_score(candidate) > self._result_score(best):
best = candidate best = candidate
if ( if (

View File

@ -48,6 +48,8 @@ def test_ollama_structured_response(config) -> None:
assert body["think"] is False assert body["think"] is False
assert body["options"]["num_ctx"] == 8192 assert body["options"]["num_ctx"] == 8192
assert body["messages"][1]["images"] == ["aW1hZ2UtYnl0ZXM="] 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( return httpx.Response(
200, 200,
json={ json={
@ -81,6 +83,8 @@ def test_ollama_structured_response(config) -> None:
def test_incomplete_ollama_json_is_kept_as_low_confidence(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: def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response( return httpx.Response(
200, 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.document_type == DocumentType.UNKNOWN
assert result.confidence == 0.65 assert result.confidence == 0.65
assert any("unvollständig" in warning for warning in result.warnings) 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