From ddae04c585ae873707d2289074db824ad5418774 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 21:11:42 +0100 Subject: [PATCH] Repair malformed Ollama JSON responses --- README.md | 16 ++- config.example.yaml | 3 + pyproject.toml | 2 +- src/orc_renaming/__init__.py | 2 +- src/orc_renaming/config.py | 2 + src/orc_renaming/ollama.py | 222 ++++++++++++++++++++++++++--------- src/orc_renaming/pipeline.py | 4 +- tests/test_ollama.py | 106 +++++++++++++++++ 8 files changed, 296 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 40fbc1e..a5855b5 100644 --- a/README.md +++ b/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 diff --git a/config.example.yaml b/config.example.yaml index 9e8fc6f..15cf50b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 686ea9a..e6ecc62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/orc_renaming/__init__.py b/src/orc_renaming/__init__.py index 191bb78..f41e660 100644 --- a/src/orc_renaming/__init__.py +++ b/src/orc_renaming/__init__.py @@ -1,3 +1,3 @@ """Lokale Dokumentklassifikation und PDF-Benennung.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/orc_renaming/config.py b/src/orc_renaming/config.py index 5e99322..85a8e44 100644 --- a/src/orc_renaming/config.py +++ b/src/orc_renaming/config.py @@ -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" diff --git a/src/orc_renaming/ollama.py b/src/orc_renaming/ollama.py index aee61b1..7075716 100644 --- a/src/orc_renaming/ollama.py +++ b/src/orc_renaming/ollama.py @@ -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)}" diff --git a/src/orc_renaming/pipeline.py b/src/orc_renaming/pipeline.py index bea47a4..7d15a0b 100644 --- a/src/orc_renaming/pipeline.py +++ b/src/orc_renaming/pipeline.py @@ -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 ( diff --git a/tests/test_ollama.py b/tests/test_ollama.py index 6301910..f563bea 100644 --- a/tests/test_ollama.py +++ b/tests/test_ollama.py @@ -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