Repair malformed Ollama JSON responses
This commit is contained in:
parent
94b3e29809
commit
ddae04c585
16
README.md
16
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Lokale Dokumentklassifikation und PDF-Benennung."""
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.4.0"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,31 +293,24 @@ 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,
|
||||
},
|
||||
"messages": [
|
||||
content = self._chat(
|
||||
selected_model,
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
|
|
@ -218,33 +320,38 @@ class OllamaAnalyzer:
|
|||
},
|
||||
user_message,
|
||||
],
|
||||
},
|
||||
)
|
||||
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}"
|
||||
schema,
|
||||
)
|
||||
repaired_with: str | None = None
|
||||
try:
|
||||
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())
|
||||
_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)}"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue