From b07dd161431ff46317e74f17487c7692e9f2f07b Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 17 Sep 2026 17:52:52 +0200 Subject: [PATCH] feat: Aufgaben mit Abbildungen (docling-Markdown), mit Gegenprobe beim Ablesen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BEDIENUNGSANLEITUNG.md | 35 ++++++- README.md | 3 +- TextToModel.py | 70 ++++++++++---- benchmark/benchmark.py | 5 +- checks/model_checks.py | 39 ++++++++ core/llm.py | 2 + core/model_schema.py | 31 ++++++ reporting/report.py | 30 +++++- tests/test_figures.py | 141 ++++++++++++++++++++++++++++ tests/test_llm.py | 6 ++ tests/test_pipeline.py | 6 +- understanding/Abbildungs-Prompt.txt | 11 +++ understanding/Verstehen-Prompt.txt | 4 +- understanding/figures.py | 87 +++++++++++++++++ understanding/understand.py | 50 +++++++--- 15 files changed, 484 insertions(+), 36 deletions(-) create mode 100644 tests/test_figures.py create mode 100644 understanding/Abbildungs-Prompt.txt create mode 100644 understanding/figures.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b7062f5..b9df96a 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -11,7 +11,7 @@ Diese Anleitung beschreibt Schritt für Schritt, wie man das Programm installier 5. [Aufrufvarianten](#5-aufrufvarianten) 6. [Was das Programm ausgibt](#6-was-das-programm-ausgibt) 7. [Das Ergebnis richtig lesen](#7-das-ergebnis-richtig-lesen) -8. [Aufgaben mit Datendateien](#8-aufgaben-mit-datendateien) +8. [Aufgaben mit Datendateien und Abbildungen](#8-aufgaben-mit-datendateien-und-abbildungen) 9. [Weiterarbeiten ohne neue LLM-Kosten](#9-weiterarbeiten-ohne-neue-llm-kosten) 10. [Kosten und Laufzeit steuern](#10-kosten-und-laufzeit-steuern) 11. [Alle Einstellungen im Überblick](#11-alle-einstellungen-im-überblick) @@ -279,7 +279,7 @@ Der Report beginnt mit dem Ergebnis und erklärt, bevor er Tabellen zeigt. Empfo --- -## 8. Aufgaben mit Datendateien +## 8. Aufgaben mit Datendateien und Abbildungen Für größere Datenmengen (Kurse, Bedarfe, Entfernungen) legt man Dateien bei: @@ -302,6 +302,36 @@ cp kurse.csv entfernungen.xlsx output/ --- +### Aufgaben mit Abbildungen (z. B. aus docling) + +Stehen Daten nur in einer Abbildung – ein Graph mit bewerteten Pfeilen, eine Tabelle als Bild –, kann die Aufgabe die Abbildung als Markdown-Bild enthalten. Beide Formen, die docling erzeugt, werden erkannt: + +- **eingebettet** (`ImageRefMode.EMBEDDED`, Standard): `![Image](data:image/png;base64,…)` +- **als Datei** (`ImageRefMode.REFERENCED`): `![Image](bilder/graph.png)` – der Pfad gilt relativ zur Aufgabendatei + +```bash +# Dokument mit docling nach Markdown wandeln (Bilder eingebettet), dann lösen +docling aufgabe.pdf --to md --image-export-mode embedded --output output/ +.venv/bin/python TextToModel.py output/aufgabe.md --report output/aufgabe.html > output/aufgabe.json +``` + +Was dabei passiert: + +1. Die Bilder werden aus dem Text gelöst; im Text steht dort `[Abbildung 1]`. +2. **Nur die Verstehensstufe** bekommt die Bilder und überträgt jede Abbildung vollständig in Text (jede Zahl, jeder Pfeil mit Richtung und Bewertung, unsicher Gelesenes wird benannt). +3. **Gegenprobe:** Ein zweiter, unabhängiger Aufruf überträgt die Abbildungen noch einmal. Weichen die Zahlen ab, entscheidet eine dritte Lesung; stimmen keine zwei überein, gilt das Ergebnis als unzuverlässig. +4. Alle weiteren Stufen rechnen mit der Übertragung – das Bild selbst wird nicht erneut gesendet. +5. Der Report zeigt im Abschnitt „Aufgabe“ **Original und Übertragung nebeneinander**. Bitte vergleichen: Ein verlesener Wert ändert das ganze Ergebnis, und eine vertauschte Pfeilrichtung bei gleichen Zahlen fällt der Gegenprobe nicht auf. + +Voraussetzungen und Grenzen: + +- Das Modell der Verstehensstufe muss Bilder lesen können. `deepseek/deepseek-v4.1-flash` kann das; sonst ein Bildmodell über `LLM_MODEL_UNDERSTANDING` wählen (z. B. `qwen/qwen3.8-flash`). +- Höchstens 6 Abbildungen, je 5 MB. +- Mehrkosten: ein bis zwei zusätzliche kleine Aufrufe für die Gegenprobe. +- Verweise auf Webadressen werden nicht geladen (das Programm lädt nichts aus dem Internet). + +--- + ## 9. Weiterarbeiten ohne neue LLM-Kosten ### Andere Zahlen, gleiches Modell: `--resolve` @@ -400,6 +430,7 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav | `OPENROUTER_API_KEY` | Schlüssel für OpenRouter | – | | `GEMINI_API_KEY` | Schlüssel für Gemini | – | | `LLM_MODEL` | Modell, falls `--model` fehlt | `deepseek/deepseek-v4.1-flash` | +| `LLM_MODEL_UNDERSTANDING` | Modell für Verstehen und Abbildungs-Gegenprobe (muss bei Abbildungen Bilder lesen können) | Session-Modell | | `LLM_MODEL_EXTRACTION` | Modell nur für die Extraktion | Session-Modell | | `LLM_MODEL_REVIEW` | Modell nur für die Prüfung | Session-Modell | | `LLM_MODEL_REVISION` | Modell nur für die Korrektur | Session-Modell | diff --git a/README.md b/README.md index 640b564..b31be28 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Exit-Codes: `0` Ergebnis zuverlässig, `2` Ergebnis vorhanden, aber unzuverläss | `LLM_TIMEOUT` | Timeout je LLM-Aufruf (s) | 420 | | `LLM_REASONING_EFFORT` | Denkaufwand des Modells über OpenRouter: `low`, `medium` oder `high` | Modellvorgabe | | `SOLVER_SANDBOX` | Isolierung des erzeugten Codes: `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` | `auto` | -| `LLM_MODEL_EXTRACTION`, `LLM_MODEL_REVIEW`, `LLM_MODEL_REVISION`, `LLM_MODEL_SOLVER` | Modell einzelner Pipeline-Stufen, z. B. ein schnelles für den Solver-Code; verschiedene Anbieter sind erlaubt | Modell aus `--model` | +| `LLM_MODEL_UNDERSTANDING`, `LLM_MODEL_EXTRACTION`, `LLM_MODEL_REVIEW`, `LLM_MODEL_REVISION`, `LLM_MODEL_SOLVER` | Modell einzelner Pipeline-Stufen, z. B. ein schnelles für den Solver-Code; verschiedene Anbieter sind erlaubt | Modell aus `--model` | ### Solver-Programme @@ -152,6 +152,7 @@ Das LLM kann Fehler machen – deshalb gibt es mehrere Sicherungen: - **Jede Teilfrage beantwortet:** Der Aufgabentext wird vor dem Modellieren in seine Teilfragen zerlegt (a, b, …); das Programm liefert je Teilfrage einen Antwortsatz. Fehlt eine Antwort, gilt die Aufgabe als nicht erfüllt – auch wenn der Zielwert stimmt. - **Jede Zahl der Antwort belegt:** Zahlen im Antworttext müssen aus dem Ergebnis oder der Aufgabe stammen (Rundungen und Einheitenumrechnungen werden akzeptiert). Ein Vergleichswert, der in keiner Tabelle steht, fällt auf. - **Jede Zahl der Aufgabe erfasst:** Zahlen des Aufgabentexts müssen als Fakt mit Fundstelle oder mit Begründung als irrelevant ausgewiesen sein – sonst betreten sie die Pipeline erst im Solver-Code, hinter allen Prüfungen. +- **Abbildungen werden gelesen und gegengeprüft:** Enthält die Aufgabe Bilder (docling-Markdown, eingebettet oder als Datei), überträgt die Verstehensstufe sie in Text; eine zweite, unabhängige Lesung wird Zahl für Zahl verglichen. Alle weiteren Stufen rechnen mit der Übertragung, der Report zeigt Original und Übertragung nebeneinander. - **Selbst erzeugte Daten sind gekennzeichnet:** Erzeugt das Programm Eingabedaten (Zufallszahlen, synthetischer Markt, verkleinertes Universum), muss das in `data_assumptions` stehen, nicht nur im Fließtext – sonst folgt eine Reparatur und notfalls die Markierung „unzuverlässig“. - **Szenarien nachgerechnet:** Vergleicht die Aufgabe mehrere Konfigurationen, wird jede durch erneutes Ausführen des Programms bestätigt – ohne LLM. Hat ein Szenario kein Ergebnis und ist nicht als unlösbar belegt (beobachtet: Zeitlimit abgelaufen, gemeldet als „keine Lösung“), folgt eine Reparatur und notfalls die Markierung „unzuverlässig“. - **Keine erfundenen Einheiten:** Eine Einheit hinter einer Zahl im Antworttext muss im Aufgabentext oder in beigelegten Daten vorkommen (nach Größenart: „Tausend Euro“ deckt „€“); neutrale Geld- und Mengeneinheiten (GE, ME) nur, wenn der Text von Kosten bzw. Mengen spricht. Sonst folgt eine Reparatur. diff --git a/TextToModel.py b/TextToModel.py index 32ecd88..08f85eb 100644 --- a/TextToModel.py +++ b/TextToModel.py @@ -5,7 +5,7 @@ import sys from pathlib import Path from typing import List -from checks.model_checks import check_spec, check_understanding, has_errors +from checks.model_checks import check_figure_readings, check_spec, check_understanding, has_errors from checks.review import needs_work, review_assumptions, revision_reasons, select_best from core.llm import DEFAULT_MODEL, LLMSession from core.model_schema import ( @@ -22,7 +22,8 @@ from modeling.extraction import revise_model, text_to_model from reporting.report import write_report from solving.script_export import build_solver_script from solving.solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve -from understanding.understand import understand_task +from understanding.figures import FigureError, split_figures, with_transcriptions +from understanding.understand import read_figures, understand_task MAX_REVISIONS = int(os.environ.get("MAX_REVISIONS", "2")) SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2")) @@ -47,19 +48,19 @@ def log(msg: str) -> None: print(msg, file=sys.stderr) -def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple: +def _understand(session, task: str, label: str, data_prompt: str = "", figures=()) -> tuple: """Verstehensstufe samt Prüfung; ein Nachfassen, wenn Zahlen oder Teilfragen fehlen.""" if UNDERSTAND_RETRIES < 0: return None, [] - log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen) ...") - understanding = understand_task(session, task, data_prompt=data_prompt) + log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen" + (", Abbildungen" if figures else "") + ") ...") + understanding = understand_task(session, task, data_prompt=data_prompt, figures=figures) issues = check_understanding(understanding, task) for versuch in range(UNDERSTAND_RETRIES): if not issues or not understanding.is_optimization_task: break # eine Nicht-Aufgabe wird abgelehnt, nicht vervollständigt log(f"[{label}] Verständnis unvollständig – fasse nach ({versuch + 1}/{UNDERSTAND_RETRIES}): " + "; ".join(i.code for i in issues)) - understanding = understand_task(session, task, issues, data_prompt) + understanding = understand_task(session, task, issues, data_prompt, figures=figures) issues = check_understanding(understanding, task) if understanding.sub_questions: log(f" {len(understanding.sub_questions)} Teilfrage(n): " @@ -68,6 +69,26 @@ def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple: return understanding, issues +def _checked_figure_readings(session, task: str, figures, first, label: str) -> tuple: + """Gegenprobe der Abbildungen: zweite, unabhängige Lesung; bei Abweichung entscheidet eine dritte. + + Stimmen zwei von drei Lesungen Zahl für Zahl überein, gilt diese Übertragung. Sonst bleibt die erste, + und der Befund macht das Ergebnis unzuverlässig – die Daten der Aufgabe stünden auf ungeprüftem Ablesen. + """ + log(f"[{label}] Lese {len(figures)} Abbildung(en) ein zweites Mal (Gegenprobe) ...") + zweite = read_figures(session, task, figures).figures + befunde = check_figure_readings(first, zweite) + if not befunde: + return first, [] + log(f"[{label}] Abbildungen uneinheitlich gelesen – dritte Lesung: " + "; ".join(i.message for i in befunde)[:300]) + dritte = read_figures(session, task, figures).figures + if not check_figure_readings(first, dritte): + return first, [] + if not check_figure_readings(zweite, dritte): + return dritte, [] + return first, befunde + + def _model_attempt(session, task: str, max_revisions: int, label: str, hint: str = "", spec: OptimizationModelSpecification | None = None, understanding: TaskUnderstanding | None = None, data_prompt: str = "") -> dict: @@ -217,9 +238,13 @@ def _log_usage(session, result: PipelineResult) -> None: def run_pipeline( task: str, model: str = DEFAULT_MODEL, max_revisions: int = MAX_REVISIONS, solve_model: bool = True, solver_retries: int = SOLVER_RETRIES, max_restarts: int = MAX_RESTARTS, - session: LLMSession | None = None, data_files=(), + session: LLMSession | None = None, data_files=(), task_dir=None, ) -> PipelineResult: session = session or LLMSession(model) + # Abbildungen (docling: Base64 oder Bilddatei) gehen nur an die Verstehensstufe; ab dann zählt ihre Übertragung + task, figures = split_figures(task, task_dir) + if figures: + log(f"{len(figures)} Abbildung(en) in der Aufgabe – werden in der Verstehensstufe in Text übertragen.") profile = data_profile.profile_files(data_files) data_prompt = data_profile.to_prompt(profile) for p in profile: @@ -227,12 +252,21 @@ def run_pipeline( for step, step_model in session.overrides().items(): log(f"[{model}] Stufe '{step}' läuft mit abweichendem Modell: {step_model}") - understanding, verstehen_issues = _understand(session, task, model, data_prompt) + understanding, verstehen_issues = _understand(session, task, model, data_prompt, figures) + if figures and understanding is not None: + if understanding.is_optimization_task: + understanding.figures, figur_befunde = _checked_figure_readings( + session, task, figures, understanding.figures, model) + verstehen_issues = verstehen_issues + figur_befunde + for figur in understanding.figures: + if figur.uncertain: + log(f" {figur.id} – unsicher gelesen: " + "; ".join(figur.uncertain)) + task = with_transcriptions(task, understanding.figures) if not understanding.is_optimization_task: # Keine Entscheidung verlangt: jedes Modell wäre erfunden, jede Zahl ohne Frage dahinter grund = f"Keine Optimierungsaufgabe: {understanding.reason}" log(f"ABGELEHNT – {grund} Es wird weder modelliert noch gerechnet.") - result = PipelineResult(task=task, understanding=understanding, data_files=profile, rejected=True, + result = PipelineResult(task=task, figures=figures, understanding=understanding, data_files=profile, rejected=True, reliable=False, reliability_issues=[grund], checks=verstehen_issues) _log_usage(session, result) return result @@ -249,7 +283,8 @@ def run_pipeline( best = attempts[select_best([(a["spec"], a["issues"], a["review"]) for a in attempts])] spec, issues, review = best["spec"], best["issues"] + verstehen_issues, best["review"] - problems = _unresolved(best) + problems = _unresolved(best) + [f"Abbildung: {i.message}" for i in verstehen_issues + if i.code == "FIGURE_READINGS_DIFFER"] for issue in issues: log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}") @@ -261,7 +296,7 @@ def run_pipeline( log("Prüfung ohne Beanstandung.") result = PipelineResult( - task=task, understanding=understanding, data_files=profile, specification=spec, review=review, revisions=best["revisions"], + task=task, figures=figures, understanding=understanding, data_files=profile, specification=spec, review=review, revisions=best["revisions"], selected_revision=best["selected"], restarts=len(attempts) - 1, checks=issues, reliable=not problems, reliability_issues=problems, ) @@ -402,11 +437,14 @@ if __name__ == "__main__": elif args.von: result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions) else: - result = run_pipeline( - read_task(args.task_file), model=args.model, - max_revisions=args.max_revisions, solve_model=not args.no_solve, max_restarts=args.max_restarts, - data_files=args.daten or (), - ) + try: + result = run_pipeline( + read_task(args.task_file), model=args.model, + max_revisions=args.max_revisions, solve_model=not args.no_solve, max_restarts=args.max_restarts, + data_files=args.daten or (), task_dir=Path(args.task_file).parent if args.task_file else None, + ) + except FigureError as exc: + parser.error(str(exc)) print(result.model_dump_json(indent=2)) if args.report: write_report(result, args.report) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 5a77f8c..f70b1c1 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -26,9 +26,10 @@ RESULTS_DIR = BENCHMARK_DIR / "results" # Nach dem ersten fatalen API-Fehler (Kontingent/Guthaben/Key) keine weiteren Läufe starten quota_exhausted = threading.Event() -# Dateien, die das LLM-Verhalten steuern: die vier System-Prompts und die Feldbeschreibungen +# Dateien, die das LLM-Verhalten steuern: die System-Prompts und die Feldbeschreibungen # (Field(description=...) geht als JSON-Schema mit und wirkt wie eine Prompt-Anweisung). -PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt", +PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "understanding/Abbildungs-Prompt.txt", + "modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt", "solving/Solver-Prompt.txt", "core/model_schema.py") diff --git a/checks/model_checks.py b/checks/model_checks.py index 5cdf171..4090a65 100644 --- a/checks/model_checks.py +++ b/checks/model_checks.py @@ -4,6 +4,7 @@ error -> löst in run_pipeline eine Korrekturrunde aus warning -> wird nur berichtet (Heuristiken mit möglichen Fehlalarmen) """ import re +from collections import Counter from typing import Iterable, List, Set from core.model_schema import ( @@ -205,6 +206,44 @@ def check_understanding(understanding, task: str) -> List[CheckIssue]: return issues +def _numbers_multiset(text: str) -> Counter: + """Zahlen eines Textes mit Häufigkeit – eine Kante mit Bewertung 3 zweimal ist nicht dasselbe wie einmal.""" + zaehler = Counter() + for roh in NUMBER_PATTERN.findall(text or ""): + try: + zaehler[normalize_number(roh)] += 1 + except ValueError: + continue + return zaehler + + +def check_figure_readings(first, second) -> List[CheckIssue]: + """Zwei unabhängige Übertragungen derselben Abbildungen vergleichen – Zahl für Zahl, ohne LLM. + + Ein Bildmodell kann eine Pfeilbewertung dem falschen Pfeil zuordnen oder eine Ziffer verlesen; eine einzige + Lesung wäre dann ungeprüft die Datengrundlage jeder weiteren Stufe. Verglichen werden die Zahlen je Abbildung + samt Häufigkeit. Richtungsfehler bei gleichen Zahlen fängt das nicht – dafür steht das Original im Report. + """ + issues = [] + erste = {f.id: f for f in first} + zweite = {f.id: f for f in second} + for kennung in sorted(set(erste) | set(zweite)): + if kennung not in erste or kennung not in zweite: + issues.append(CheckIssue(severity=CheckSeverity.ERROR, code="FIGURE_READINGS_DIFFER", + message=f"{kennung} wurde nur in einer von zwei Lesungen übertragen.")) + continue + a = _numbers_multiset(erste[kennung].transcription) + b = _numbers_multiset(zweite[kennung].transcription) + if a != b: + nur_a = sorted((a - b).elements(), key=float) + nur_b = sorted((b - a).elements(), key=float) + issues.append(CheckIssue( + severity=CheckSeverity.ERROR, code="FIGURE_READINGS_DIFFER", + message=f"{kennung} uneinheitlich gelesen – nur in Lesung 1: {', '.join(nur_a) or '–'}; " + f"nur in Lesung 2: {', '.join(nur_b) or '–'}.")) + return issues + + def _strip_scripts(text: str) -> str: """Entfernt Indizes/Exponenten: x_{i,j} -> x, C^{prod}_{p} -> C, x_1 -> x.""" text = text.replace(r"\_", "_") diff --git a/core/llm.py b/core/llm.py index 074165d..0e65fc4 100644 --- a/core/llm.py +++ b/core/llm.py @@ -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", diff --git a/core/model_schema.py b/core/model_schema.py index 14b4b68..928f75c 100644 --- a/core/model_schema.py +++ b/core/model_schema.py @@ -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( diff --git a/reporting/report.py b/reporting/report.py index 507f136..371a635 100644 --- a/reporting/report.py +++ b/reporting/report.py @@ -301,6 +301,27 @@ def _fmt_number(value) -> str: return str(value) +def _figure_blocks(result: PipelineResult) -> list: + """Abbildungen der Aufgabe: Original neben der Übertragung, mit der die Pipeline gerechnet hat.""" + if not result.figures: + return [] + uebertragen = {f.id: f for f in (result.understanding.figures if result.understanding else [])} + doc = [("h3", "Abbildungen"), + ("p", "Die Pipeline rechnet nicht mit dem Bild, sondern mit seiner Übertragung in Text. " + "Bitte Original und Übertragung vergleichen – ein verlesener Wert ändert das ganze Ergebnis.")] + for figur in result.figures: + doc.append(("h4", figur.id + (f" – {figur.alt}" if figur.alt and figur.alt.lower() != "image" else ""))) + doc.append(("img", f"data:{figur.mime};base64,{figur.data}", figur.id)) + t = uebertragen.get(figur.id) + if t is None: + doc.append(("warn", f"{figur.id} wurde nicht übertragen – ihre Inhalte fehlen im Modell.")) + continue + doc += [("p", f"Übertragung: {t.shows}"), ("pre", t.transcription.strip())] + if t.uncertain: + doc.append(("warn", f"{figur.id} – unsicher gelesen: " + "; ".join(t.uncertain))) + return doc + + def _rejected_document(result: PipelineResult) -> list: """Abgelehnte Aufgabe: Grund und Aufgabentext – kein Modell, keine Zahlen.""" grund = result.understanding.reason if result.understanding else "; ".join(result.reliability_issues) @@ -311,7 +332,7 @@ def _rejected_document(result: PipelineResult) -> list: doc.append(("list", [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}", f"Modell: {result.usage.model}", f"LLM: {result.usage.calls} Aufrufe{cost}"])) if result.task.strip(): - doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result) return doc @@ -342,7 +363,7 @@ def build_document(result: PipelineResult, model: str = "") -> list: doc += _result_section(result) if result.task.strip(): - doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result) if result.data_files: doc += [("h2", "Beigelegte Daten")] @@ -468,6 +489,8 @@ def to_markdown(doc: list) -> str: out.append(f"**{_md_inline(block[1])}**") elif kind == "svg": pass # Diagramme nur im HTML-Report + elif kind == "img": + out.append(f"![{block[2]}]({block[1]})") elif kind == "list": out.append("\n".join(f"- {_md_inline(item)}" for item in block[1])) elif kind == "math": @@ -591,6 +614,9 @@ def to_html(doc: list) -> str: out.append(f'

{_html_inline(block[1])}

') elif kind == "svg": out.append(f'
{block[1]}
') + elif kind == "img": + out.append(f'
{html.escape(block[2])}
{html.escape(block[2])}
') elif kind == "list": out.append("") elif kind == "math": diff --git a/tests/test_figures.py b/tests/test_figures.py new file mode 100644 index 0000000..f8269f9 --- /dev/null +++ b/tests/test_figures.py @@ -0,0 +1,141 @@ +"""Aufgaben mit Abbildungen (docling-Markdown): abtrennen, als Bild an die Verstehensstufe, doppelt ablesen.""" +import base64 + +import pytest + +from checks.model_checks import check_figure_readings, numbers_in +from core.model_schema import (FigureReading, FigureTranscription, SolverCode, SubQuestion, TaskUnderstanding) +from reporting.report import build_document, to_html, to_markdown +from TextToModel import run_pipeline +from understanding.figures import MAX_FIGURES, FigureError, split_figures, with_transcriptions +from understanding.understand import understand_task + +# 1×1-PNG – Inhalt egal, es geht um den Weg durch die Pipeline +PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" +AUFGABE = f"Gesucht sind die minimalen Fahrtkosten von Knoten 1 nach Knoten 4.\n\n![Image](data:image/png;base64,{PNG})\n" + +# Graph aus test_Image.md +GRAPH = FigureTranscription(id="Abbildung 1", shows="gerichteter Graph mit 4 Knoten", + transcription="Pfeil 1 → 2: 3\nPfeil 1 → 3: 1\nPfeil 3 → 2: 1\nPfeil 2 → 4: 3\nPfeil 3 → 4: 5") +VERLESEN = GRAPH.model_copy(update={"transcription": GRAPH.transcription.replace("2 → 4: 3", "2 → 4: 5")}) + + +# --- Abtrennen ohne LLM --- + +def test_embedded_image_is_replaced_by_a_placeholder(): + text, figuren = split_figures(AUFGABE) + assert "[Abbildung 1]" in text and "base64" not in text + assert len(figuren) == 1 and figuren[0].mime == "image/png" and figuren[0].data == PNG + assert numbers_in(text) == {"1", "4"} # keine Zahlen aus dem Base64-Zeichensalat + + +def test_referenced_image_file_is_loaded_relative_to_the_task(tmp_path): + (tmp_path / "bilder").mkdir() + (tmp_path / "bilder" / "graph.png").write_bytes(base64.b64decode(PNG)) + text, figuren = split_figures("Siehe ![Netz](bilder/graph.png) und ![](fehlt.png) und ![Web](https://x.de/a.png)", tmp_path) + assert "[Abbildung 1: Netz]" in text + assert "![](fehlt.png)" in text and "https://x.de/a.png" in text # nicht vorhanden bzw. keine Datei: bleibt + assert len(figuren) == 1 and figuren[0].data == PNG and figuren[0].source.endswith("graph.png") + + +def test_limits_and_broken_base64_are_errors_not_silent_cuts(): + zu_viele = "\n".join(f"![Image](data:image/png;base64,{PNG})" for _ in range(MAX_FIGURES + 1)) + with pytest.raises(FigureError, match="höchstens"): + split_figures(zu_viele) + with pytest.raises(FigureError, match="Base64"): + split_figures("![Image](data:image/png;base64,abc)") + + +def test_task_without_images_is_unchanged(): + assert split_figures("Minimiere 3x + 2y.") == ("Minimiere 3x + 2y.", []) + + +def test_transcription_is_appended_with_uncertain_parts(): + unsicher = GRAPH.model_copy(update={"uncertain": ["Richtung des Pfeils zwischen 2 und 3"]}) + text = with_transcriptions("Aufgabe [Abbildung 1]", [unsicher]) + assert "Pfeil 3 → 4: 5" in text and "Unsicher gelesen: Richtung des Pfeils" in text + + +# --- Nachricht an das Bildmodell --- + +class Aufzeichner: + def __init__(self, antwort): self.antwort, self.nachrichten = antwort, [] + def create(self, response_model, messages, step="unbenannt"): + self.nachrichten.append((step, messages)); return self.antwort + + +def test_understanding_sends_images_as_image_parts(): + text, figuren = split_figures(AUFGABE) + session = Aufzeichner("ok") + understand_task(session, text, figures=figuren) + inhalt = session.nachrichten[0][1][-1]["content"] + assert isinstance(inhalt, list) and inhalt[0]["type"] == "text" and "1 ABBILDUNG" in inhalt[0]["text"] + assert inhalt[-1] == {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{PNG}"}} + + +def test_understanding_without_images_stays_plain_text(): + session = Aufzeichner("ok") + understand_task(session, "Minimiere 3x.") + assert isinstance(session.nachrichten[0][1][-1]["content"], str) + + +# --- Doppelt ablesen --- + +def test_identical_readings_have_no_finding(): + assert check_figure_readings([GRAPH], [GRAPH.model_copy()]) == [] + + +def test_misread_value_is_found_with_both_numbers(): + befunde = check_figure_readings([GRAPH], [VERLESEN]) + assert len(befunde) == 1 and befunde[0].code == "FIGURE_READINGS_DIFFER" + assert "nur in Lesung 1: 3" in befunde[0].message and "nur in Lesung 2: 5" in befunde[0].message + + +def test_figure_missing_in_one_reading_is_found(): + assert "nur in einer" in check_figure_readings([GRAPH], [])[0].message + + +# --- Durch die ganze Pipeline (ohne LLM) --- + +def _verstaendnis(figur): + return TaskUnderstanding(is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], + sub_questions=[SubQuestion(id="1", quote="-", kind="rechnen", needs_solver=True)], + figures=[figur]) + + +def _lauf(erste, lesungen): + from test_pipeline import GOOD, OK, FakeSession + from test_script_export import STRUCTURED_CODE + session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[_verstaendnis(erste)], + readings=[FigureReading(figures=[f]) for f in lesungen]) + return run_pipeline(AUFGABE, session=session, max_revisions=0, max_restarts=0), session + + +def test_only_the_understanding_sees_the_image_later_stages_get_the_transcription(): + result, session = _lauf(GRAPH, [GRAPH]) + bilder = [schritt for schritt, inhalt in session.contents if isinstance(inhalt, list)] + assert set(bilder) == {"Verstehen", "Abbildungen"} + spaeter = [inhalt for schritt, inhalt in session.contents if schritt not in ("Verstehen", "Abbildungen")] + assert spaeter and all("data:image" not in str(i) for i in spaeter) + assert any("Pfeil 3 → 4: 5" in str(i) for i in spaeter) + assert result.reliable and len(result.figures) == 1 and "Pfeil 2 → 4: 3" in result.task + + +def test_two_of_three_agreeing_readings_win(): + result, _ = _lauf(VERLESEN, [GRAPH, GRAPH]) # erste Lesung verlesen, zweite und dritte stimmen überein + assert result.reliable and "Pfeil 2 → 4: 3" in result.task + + +def test_persistently_differing_readings_make_the_result_unreliable(): + dritte = GRAPH.model_copy(update={"transcription": GRAPH.transcription.replace("1 → 3: 1", "1 → 3: 7")}) + result, _ = _lauf(GRAPH, [VERLESEN, dritte]) + assert not result.reliable + assert any(p.startswith("Abbildung:") and "uneinheitlich" in p for p in result.reliability_issues) + + +def test_report_shows_original_and_transcription(): + result, _ = _lauf(GRAPH.model_copy(update={"uncertain": ["Pfeil 3 → 2 schwer lesbar"]}), [GRAPH]) + html = to_html(build_document(result)) + assert f' 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)) diff --git a/understanding/understand.py b/understanding/understand.py index 1cb1d1c..61c68eb 100644 --- a/understanding/understand.py +++ b/understanding/understand.py @@ -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)