feat: Aufgaben mit Abbildungen (docling-Markdown), mit Gegenprobe beim Ablesen

Eingebettete Base64-Bilder und Bilddateien werden aus dem Aufgabentext gelöst und nur
der Verstehensstufe als Bild übergeben; sie überträgt jede Abbildung in Text. Eine
zweite, unabhängige Lesung wird Zahl für Zahl verglichen (bei Abweichung entscheidet
eine dritte, sonst reliable=False). Alle weiteren Stufen rechnen mit der Übertragung,
der Report zeigt Original und Übertragung nebeneinander. LLM_MODEL_UNDERSTANDING
wählt das Bildmodell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-17 17:52:52 +02:00
commit b07dd16143
2 changed files with 484 additions and 36 deletions

View file

@ -18,6 +18,8 @@ DEFAULT_MODEL = os.environ.get("LLM_MODEL", "deepseek/deepseek-v4.1-flash")
# Modell je Pipeline-Stufe: ein starkes Modell für Extraktion/Prüfung, ein schnelles für den Solver-Code
# (oder umgekehrt, je nach Messung). Leer = das Modell der Session (LLM_MODEL bzw. --model).
STEP_MODEL_ENV = {
"Verstehen": "LLM_MODEL_UNDERSTANDING", # muss Bilder lesen können, wenn die Aufgabe Abbildungen hat
"Abbildungen": "LLM_MODEL_UNDERSTANDING",
"Extraktion": "LLM_MODEL_EXTRACTION",
"Prüfung": "LLM_MODEL_REVIEW",
"Korrektur": "LLM_MODEL_REVISION",

View file

@ -118,6 +118,32 @@ class IgnoredNumber(BaseModel):
"'Teil der Einheit (in 1000 Stunden)', 'Seitenzahl'")
class FigureInput(BaseModel):
"""Eine Abbildung der Aufgabe (eingebettet als Base64 oder als Bilddatei) – Bilddaten, kein LLM-Feld."""
id: str
alt: str = ""
mime: str
data: str = Field(description="Bildinhalt als Base64")
source: str = Field(default="eingebettet", description="'eingebettet' oder der Pfad der Bilddatei")
class FigureTranscription(BaseModel):
"""Eine Abbildung, vollständig in Text übertragen – danach arbeitet die Pipeline nur noch mit diesem Text."""
id: str = Field(description="Kennung wie im Text, z. B. 'Abbildung 1'")
shows: str = Field(description="Was die Abbildung zeigt, in einem Satz (z. B. 'gerichteter Graph mit 4 Knoten')")
transcription: str = Field(
description="Vollständige Übertragung ALLER Inhalte: jede Zahl, jede Beschriftung, jede Pfeilrichtung, "
"am besten als Tabelle oder Liste (z. B. 'Pfeil 1→2: 3'). Nichts ergänzen, was nicht zu sehen ist.")
uncertain: List[str] = Field(
default_factory=list,
description="Stellen, die nicht sicher lesbar sind (verdeckt, unscharf, mehrdeutig); leer = alles sicher gelesen")
class FigureReading(BaseModel):
"""Unabhängige zweite Übertragung der Abbildungen – Gegenprobe zur Verstehensstufe."""
figures: List[FigureTranscription]
class TaskUnderstanding(BaseModel):
"""Was in der Aufgabe steht – vor jeder Modellierung: Fragen, Zahlen, Machbarkeit."""
is_optimization_task: bool = Field(
@ -129,6 +155,9 @@ class TaskUnderstanding(BaseModel):
facts: List[Fact] = Field(description="Alle Zahlenwerte des Textes, die Eingabegrößen des Modells sind")
ignored_numbers: List[IgnoredNumber] = Field(
default_factory=list, description="Zahlen im Text, die keine Eingabegrößen sind, mit Begründung")
figures: List[FigureTranscription] = Field(
default_factory=list,
description="Nur wenn der Aufgabe Abbildungen beiliegen: je Abbildung eine vollständige Übertragung; sonst leer")
class ObjectiveDefinition(BaseModel):
@ -430,6 +459,8 @@ class LLMUsage(BaseModel):
class PipelineResult(BaseModel):
task: str = ""
figures: List[FigureInput] = Field(
default_factory=list, description="Abbildungen der Aufgabe; ihre Übertragung steht im Aufgabentext (task)")
data_files: List[DataFileProfile] = Field(
default_factory=list, description="Profile der beigelegten Datendateien (--daten)")
understanding: Optional[TaskUnderstanding] = Field(