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
4 changed files with 484 additions and 36 deletions

View file

@ -0,0 +1,11 @@
Du überträgst Abbildungen aus einer Aufgabe aus Operations Research oder Wirtschaftsmathematik vollständig in Text. Du löst nichts und modellierst nichts.
Deine Übertragung wird ohne LLM mit einer zweiten, unabhängigen Übertragung derselben Abbildung verglichen – Zahl für Zahl. Weicht sie ab, gilt das Ergebnis als unzuverlässig. Genauigkeit geht deshalb vor Kürze. [→ check_figure_readings]
Für jede Abbildung, in der Reihenfolge ihrer Kennung („Abbildung 1“, „Abbildung 2“, …), ein Eintrag in figures:
- id: die Kennung genau wie angegeben.
- shows: was die Abbildung zeigt, in einem Satz (z. B. „gerichteter Graph mit 4 Knoten und 5 bewerteten Pfeilen“).
- transcription: ALLE Inhalte – jede Zahl, jede Beschriftung, bei Graphen jeden Pfeil mit Start, Ziel und Bewertung (eine Zeile je Pfeil, z. B. „Pfeil 1 → 2: 3“), bei Tabellen jede Zelle, bei Diagrammen Achsen, Einheiten und abgelesene Werte. Achte bei Pfeilen genau auf die Richtung (wo sitzt die Spitze?).
- uncertain: jede Stelle, die du nicht sicher lesen kannst (verdeckt, unscharf, mehrdeutige Richtung oder Zuordnung einer Zahl zu einem Pfeil). Leer nur, wenn alles eindeutig ist.
Ergänze nichts, was nicht zu sehen ist, und rechne nichts aus.

View file

@ -24,4 +24,6 @@ GEMEINSAMER RAHMEN (gilt für alle vier Stufen dieser Pipeline: Verstehen, Model
- ignored_numbers ist für Aufgabennummern („3.8“), Zahlen in Einheiten („in 1000 Stunden“), Jahreszahlen, Seitenzahlen, Nummern von Sätzen. Immer mit Grund.
Eine Zahl, die weder im einen noch im anderen steht, gilt als übersehen. [→ UNCAPTURED_NUMBERS]
4. Erfinde nichts. Was der Text nicht sagt, gehört nicht in facts – fehlende Angaben werden später als Annahme ausgewiesen, nicht hier geraten.
4. ABBILDUNGEN: Liegen der Aufgabe Abbildungen bei (im Text als „[Abbildung N]“, die Bilder folgen der Nachricht), trage je Abbildung einen Eintrag in figures ein: id genau wie im Text, shows in einem Satz, transcription mit ALLEN Inhalten (jede Zahl, jede Beschriftung, bei Graphen jeder Pfeil mit Start, Ziel und Bewertung – achte auf die Richtung der Pfeilspitze; bei Tabellen jede Zelle), uncertain für alles, was nicht sicher lesbar ist. Die Zahlen der Abbildung gehören außerdem in facts, mit quote „Abbildung N: …“. Eine zweite, unabhängige Übertragung wird Zahl für Zahl mit deiner verglichen. [→ check_figure_readings]
5. Erfinde nichts. Was der Text nicht sagt, gehört nicht in facts – fehlende Angaben werden später als Annahme ausgewiesen, nicht hier geraten.

87
understanding/figures.py Normal file
View file

@ -0,0 +1,87 @@
"""Abbildungen in Aufgabentexten: vom Text trennen, als Bild an die Verstehensstufe geben, Übertragung einsetzen.
docling exportiert Abbildungen als `![Image](data:image/png;base64,…)` (ImageRefMode.EMBEDDED) oder als Verweis
auf eine Bilddatei (REFERENCED). Beides bliebe sonst Zeichensalat im Text jeder Stufe: kein Modell sähe den Graphen,
jeder Aufruf bezahlte das Base64 erneut, und die Zahlenprüfung liefe über Base64-Zeichen. Deshalb bekommt nur die
Verstehensstufe das Bild; ab dann arbeitet die Pipeline mit der Übertragung in Text.
"""
import base64
import re
from pathlib import Path
from typing import List, Tuple
from core.model_schema import FigureInput, FigureTranscription
MAX_FIGURES = 6
MAX_BYTES = 5 * 1024 * 1024
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"}
# ![alt](data:image/png;base64,…) oder ![alt](pfad/bild.png); Base64 darf Zeilenumbrüche enthalten
IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(\s*(?:data:(image/[\w.+-]+);base64,([A-Za-z0-9+/=\s]+?)|([^)\s]+))\s*\)")
# docling schreibt als Alternativtext meist nur „Image“ – der trägt nichts zur Aufgabe bei
GENERIC_ALT = {"", "image", "bild", "figure", "abbildung", "picture"}
class FigureError(ValueError):
"""Abbildungen, die nicht verarbeitet werden können (zu viele, zu groß, kaputtes Base64)."""
def split_figures(task: str, basis_dir: Path | str | None = None) -> Tuple[str, List[FigureInput]]:
"""Abbildungen aus dem Aufgabentext lösen; im Text bleibt ein Platzhalter „[Abbildung N]“.
Verweise auf Dateien werden relativ zu `basis_dir` (dem Verzeichnis der Aufgabendatei) aufgelöst. Nicht
vorhandene Dateien und Verweise ohne Bild-Endung (etwa Webadressen) bleiben unverändert im Text.
"""
basis = Path(basis_dir) if basis_dir else Path.cwd()
figures: List[FigureInput] = []
def ersetze(treffer: re.Match) -> str:
alt, mime, daten, pfad = treffer.group(1).strip(), treffer.group(2), treffer.group(3), treffer.group(4)
if daten is not None:
daten = re.sub(r"\s", "", daten)
try:
roh = base64.b64decode(daten, validate=True)
except ValueError as exc:
raise FigureError(f"Eingebettetes Bild {len(figures) + 1} ist kein gültiges Base64: {exc}") from exc
quelle = "eingebettet"
else:
datei = (basis / pfad) if not Path(pfad).is_absolute() else Path(pfad)
mime = IMAGE_TYPES.get(datei.suffix.lower())
if mime is None or not datei.is_file():
return treffer.group(0)
roh = datei.read_bytes()
daten = base64.b64encode(roh).decode("ascii")
quelle = str(datei)
if len(roh) > MAX_BYTES:
raise FigureError(f"Abbildung {len(figures) + 1} ist {len(roh) // 1024} KB groß – erlaubt sind "
f"{MAX_BYTES // 1024 // 1024} MB.")
kennung = f"Abbildung {len(figures) + 1}"
figures.append(FigureInput(id=kennung, alt=alt, mime=mime, data=daten, source=quelle))
return f"[{kennung}]" if alt.lower() in GENERIC_ALT else f"[{kennung}: {alt}]"
text = IMAGE_PATTERN.sub(ersetze, task)
if len(figures) > MAX_FIGURES:
raise FigureError(f"Die Aufgabe enthält {len(figures)} Abbildungen – verarbeitet werden höchstens {MAX_FIGURES}.")
return text, figures
def image_parts(figures: List[FigureInput]) -> list:
"""Nachrichtenteile im OpenAI-Format: je Abbildung ihre Kennung als Text, dann das Bild als Data-URI."""
teile = []
for figur in figures:
teile.append({"type": "text", "text": f"{figur.id}:"})
teile.append({"type": "image_url", "image_url": {"url": f"data:{figur.mime};base64,{figur.data}"}})
return teile
def with_transcriptions(text: str, transcriptions: List[FigureTranscription]) -> str:
"""Aufgabentext für alle weiteren Stufen: Platzhalter bleiben, die Übertragungen folgen am Ende."""
if not transcriptions:
return text
bloecke = []
for t in transcriptions:
block = f"[{t.id}] {t.shows}\n{t.transcription.strip()}"
if t.uncertain:
block += "\nUnsicher gelesen: " + "; ".join(t.uncertain)
bloecke.append(block)
return (text.rstrip() + "\n\nABBILDUNGEN (aus dem Bild in Text übertragen; das Original liegt dem Report bei):\n\n"
+ "\n\n".join(bloecke))

View file

@ -3,24 +3,52 @@ from pathlib import Path
from typing import List
from core.llm import LLMSession
from core.model_schema import CheckIssue, TaskUnderstanding
from core.model_schema import CheckIssue, FigureInput, FigureReading, TaskUnderstanding
from understanding.figures import image_parts
UNDERSTAND_PROMPT_FILE = Path(__file__).parent / "Verstehen-Prompt.txt"
FIGURE_PROMPT_FILE = Path(__file__).parent / "Abbildungs-Prompt.txt"
def _content(text: str, figures: List[FigureInput]):
"""Reiner Text – oder, mit Abbildungen, eine Liste aus Text und Bildern (OpenAI-Format)."""
if not figures:
return text
hinweis = (f"\n\nDER AUFGABE LIEGEN {len(figures)} ABBILDUNG(EN) BEI – sie folgen als Bilder; "
"übertrage jede vollständig in figures.")
return [{"type": "text", "text": text + hinweis}, *image_parts(figures)]
def _create_with_images(session: LLMSession, response_model, messages, step: str, figures):
"""Wie session.create – mit verständlicher Meldung, falls das Modell keine Bilder annimmt."""
try:
return session.create(response_model, messages, step=step)
except Exception as exc:
if figures and any(w in str(exc).lower() for w in ("image", "vision", "modalit", "multimodal")):
raise RuntimeError(f"Das Modell dieser Stufe nimmt offenbar keine Bilder an ({str(exc)[:160]}). "
"Ein Bildmodell über LLM_MODEL_UNDERSTANDING wählen.") from exc
raise
def understand_task(session: LLMSession, task: str, issues: List[CheckIssue] = (),
data_prompt: str = "") -> TaskUnderstanding:
"""Stufe 0: erfassen, was im Text steht – Teilfragen, Zahlen, Machbarkeit. Noch kein Modell."""
data_prompt: str = "", figures: List[FigureInput] = ()) -> TaskUnderstanding:
"""Stufe 1: erfassen, was im Text steht – Teilfragen, Zahlen, Machbarkeit, Abbildungen. Noch kein Modell."""
nachtrag = ""
if issues:
nachtrag = ("\n\nDEIN VORIGER VERSUCH WAR UNVOLLSTÄNDIG:\n"
+ "\n".join(f"- [{i.code}] {i.message}" for i in issues)
+ "\nErfasse die fehlenden Punkte, behalte den Rest bei.")
return session.create(
TaskUnderstanding,
[
{"role": "system", "content": UNDERSTAND_PROMPT_FILE.read_text(encoding="utf-8")},
{"role": "user", "content": f"AUFGABENTEXT:\n{task}\n\n{data_prompt}{nachtrag}".strip()},
],
step="Verstehen",
)
text = f"AUFGABENTEXT:\n{task}\n\n{data_prompt}{nachtrag}".strip()
return _create_with_images(session, TaskUnderstanding, [
{"role": "system", "content": UNDERSTAND_PROMPT_FILE.read_text(encoding="utf-8")},
{"role": "user", "content": _content(text, list(figures))},
], "Verstehen", figures)
def read_figures(session: LLMSession, task: str, figures: List[FigureInput]) -> FigureReading:
"""Unabhängige Übertragung nur der Abbildungen – die Gegenprobe zur Verstehensstufe."""
text = f"AUFGABENTEXT (nur zur Einordnung der Abbildungen):\n{task}"
return _create_with_images(session, FigureReading, [
{"role": "system", "content": FIGURE_PROMPT_FILE.read_text(encoding="utf-8")},
{"role": "user", "content": _content(text, figures)},
], "Abbildungen", figures)