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"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""Lokale Dokumentklassifikation und PDF-Benennung."""
|
"""Lokale Dokumentklassifikation und PDF-Benennung."""
|
||||||
|
|
||||||
__version__ = "0.3.0"
|
__version__ = "0.4.0"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)}"
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue