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:
parent
915c79c1c8
commit
b07dd16143
4 changed files with 484 additions and 36 deletions
11
understanding/Abbildungs-Prompt.txt
Normal file
11
understanding/Abbildungs-Prompt.txt
Normal 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.
|
||||
|
|
@ -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
87
understanding/figures.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Abbildungen in Aufgabentexten: vom Text trennen, als Bild an die Verstehensstufe geben, Übertragung einsetzen.
|
||||
|
||||
docling exportiert Abbildungen als `` (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"}
|
||||
#  oder ; 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))
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue