Compare commits

..
15 changed files with 40 additions and 541 deletions

View file

@ -11,7 +11,7 @@ Diese Anleitung beschreibt Schritt für Schritt, wie man das Programm installier
5. [Aufrufvarianten](#5-aufrufvarianten) 5. [Aufrufvarianten](#5-aufrufvarianten)
6. [Was das Programm ausgibt](#6-was-das-programm-ausgibt) 6. [Was das Programm ausgibt](#6-was-das-programm-ausgibt)
7. [Das Ergebnis richtig lesen](#7-das-ergebnis-richtig-lesen) 7. [Das Ergebnis richtig lesen](#7-das-ergebnis-richtig-lesen)
8. [Aufgaben mit Datendateien und Abbildungen](#8-aufgaben-mit-datendateien-und-abbildungen) 8. [Aufgaben mit Datendateien](#8-aufgaben-mit-datendateien)
9. [Weiterarbeiten ohne neue LLM-Kosten](#9-weiterarbeiten-ohne-neue-llm-kosten) 9. [Weiterarbeiten ohne neue LLM-Kosten](#9-weiterarbeiten-ohne-neue-llm-kosten)
10. [Kosten und Laufzeit steuern](#10-kosten-und-laufzeit-steuern) 10. [Kosten und Laufzeit steuern](#10-kosten-und-laufzeit-steuern)
11. [Alle Einstellungen im Überblick](#11-alle-einstellungen-im-überblick) 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 und Abbildungen ## 8. Aufgaben mit Datendateien
Für größere Datenmengen (Kurse, Bedarfe, Entfernungen) legt man Dateien bei: Für größere Datenmengen (Kurse, Bedarfe, Entfernungen) legt man Dateien bei:
@ -302,36 +302,6 @@ 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. Das Standardmodell `~deepseek/deepseek-flash-latest` (derzeit 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 ## 9. Weiterarbeiten ohne neue LLM-Kosten
### Andere Zahlen, gleiches Modell: `--resolve` ### Andere Zahlen, gleiches Modell: `--resolve`
@ -401,7 +371,7 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav
**Prompt-Cache:** Alle Aufrufe eines Laufs gehen an denselben OpenRouter-Anbieter, damit dessen Cache wiederholt gelesene Prompt-Teile billiger abrechnen kann. Der Cache-Anteil steht in der Abschlusszeile. **Prompt-Cache:** Alle Aufrufe eines Laufs gehen an denselben OpenRouter-Anbieter, damit dessen Cache wiederholt gelesene Prompt-Teile billiger abrechnen kann. Der Cache-Anteil steht in der Abschlusszeile.
**Standardmodell ist der Alias `~deepseek/deepseek-flash-latest`** – er zeigt immer auf das neueste DeepSeek-Flash-Modell (September 2026: V4.1 Flash). Das hält die Pipeline aktuell, kann Ergebnisse aber unbemerkt verändern, sobald DeepSeek ein neues Flash-Modell veröffentlicht. **Für Benchmarks und Vergleiche feste Modellnamen verwenden** (z. B. `--models deepseek/deepseek-v4.1-flash`). **Feste Modellnamen verwenden** (z. B. `deepseek/deepseek-v4.1-flash`), keine `…-latest`-Aliase – sonst ändern sich Ergebnisse unbemerkt.
--- ---
@ -429,8 +399,7 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav
|---|---|---| |---|---|---|
| `OPENROUTER_API_KEY` | Schlüssel für OpenRouter | – | | `OPENROUTER_API_KEY` | Schlüssel für OpenRouter | – |
| `GEMINI_API_KEY` | Schlüssel für Gemini | – | | `GEMINI_API_KEY` | Schlüssel für Gemini | – |
| `LLM_MODEL` | Modell, falls `--model` fehlt | `~deepseek/deepseek-flash-latest` | | `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_EXTRACTION` | Modell nur für die Extraktion | Session-Modell |
| `LLM_MODEL_REVIEW` | Modell nur für die Prüfung | 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 | | `LLM_MODEL_REVISION` | Modell nur für die Korrektur | Session-Modell |

View file

@ -102,7 +102,7 @@ Exit-Codes: `0` Ergebnis zuverlässig, `2` Ergebnis vorhanden, aber unzuverläss
|---|---|---| |---|---|---|
| `--report DATEI` | Report als `.md` (z. B. VS Code-Vorschau: Strg+Umschalt+V) oder `.html` (Browser, Formeln via MathJax) | – | | `--report DATEI` | Report als `.md` (z. B. VS Code-Vorschau: Strg+Umschalt+V) oder `.html` (Browser, Formeln via MathJax) | – |
| `--code DATEI` | Solver-Programm als eigenständiges, dokumentiertes Python-Skript speichern | – | | `--code DATEI` | Solver-Programm als eigenständiges, dokumentiertes Python-Skript speichern | – |
| `--model` / `LLM_MODEL` | LLM-Modell | `~deepseek/deepseek-flash-latest` (Alias, derzeit V4.1 Flash) | | `--model` / `LLM_MODEL` | LLM-Modell | `deepseek/deepseek-v4.1-flash` |
| `--daten DATEI …` | beigelegte CSV-/Excel-Dateien; ihr Profil (Zeilen, Spalten, Spannweiten, Lücken) geht in die Prompts, die Dateien ins Arbeitsverzeichnis des Programms | – | | `--daten DATEI …` | beigelegte CSV-/Excel-Dateien; ihr Profil (Zeilen, Spalten, Spannweiten, Lücken) geht in die Prompts, die Dateien ins Arbeitsverzeichnis des Programms | – |
| `--von STUFE ERGEBNIS.JSON` | gespeichertes Ergebnis fortsetzen statt alles zu wiederholen: `solver` (nur Solver-Code neu) oder `pruefung` (ab Prüfung/Korrektur) | – | | `--von STUFE ERGEBNIS.JSON` | gespeichertes Ergebnis fortsetzen statt alles zu wiederholen: `solver` (nur Solver-Code neu) oder `pruefung` (ab Prüfung/Korrektur) | – |
| `--max-revisions` / `MAX_REVISIONS` | Korrekturrunden | 2 | | `--max-revisions` / `MAX_REVISIONS` | Korrekturrunden | 2 |
@ -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_TIMEOUT` | Timeout je LLM-Aufruf (s) | 420 |
| `LLM_REASONING_EFFORT` | Denkaufwand des Modells über OpenRouter: `low`, `medium` oder `high` | Modellvorgabe | | `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` | | `SOLVER_SANDBOX` | Isolierung des erzeugten Codes: `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` | `auto` |
| `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` | | `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 ### Solver-Programme
@ -152,7 +152,6 @@ 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 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 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. - **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“. - **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“. - **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. - **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.

View file

@ -5,7 +5,7 @@ import sys
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from checks.model_checks import check_figure_readings, check_spec, check_understanding, has_errors from checks.model_checks import check_spec, check_understanding, has_errors
from checks.review import needs_work, review_assumptions, revision_reasons, select_best from checks.review import needs_work, review_assumptions, revision_reasons, select_best
from core.llm import DEFAULT_MODEL, LLMSession from core.llm import DEFAULT_MODEL, LLMSession
from core.model_schema import ( from core.model_schema import (
@ -22,8 +22,7 @@ from modeling.extraction import revise_model, text_to_model
from reporting.report import write_report from reporting.report import write_report
from solving.script_export import build_solver_script from solving.script_export import build_solver_script
from solving.solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve from solving.solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve
from understanding.figures import FigureError, normalize_figure_ids, split_figures, with_transcriptions from understanding.understand import understand_task
from understanding.understand import read_figures, understand_task
MAX_REVISIONS = int(os.environ.get("MAX_REVISIONS", "2")) MAX_REVISIONS = int(os.environ.get("MAX_REVISIONS", "2"))
SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2")) SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2"))
@ -48,19 +47,19 @@ def log(msg: str) -> None:
print(msg, file=sys.stderr) print(msg, file=sys.stderr)
def _understand(session, task: str, label: str, data_prompt: str = "", figures=()) -> tuple: def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple:
"""Verstehensstufe samt Prüfung; ein Nachfassen, wenn Zahlen oder Teilfragen fehlen.""" """Verstehensstufe samt Prüfung; ein Nachfassen, wenn Zahlen oder Teilfragen fehlen."""
if UNDERSTAND_RETRIES < 0: if UNDERSTAND_RETRIES < 0:
return None, [] return None, []
log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen" + (", Abbildungen" if figures else "") + ") ...") log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen) ...")
understanding = understand_task(session, task, data_prompt=data_prompt, figures=figures) understanding = understand_task(session, task, data_prompt=data_prompt)
issues = check_understanding(understanding, task) issues = check_understanding(understanding, task)
for versuch in range(UNDERSTAND_RETRIES): for versuch in range(UNDERSTAND_RETRIES):
if not issues or not understanding.is_optimization_task: if not issues or not understanding.is_optimization_task:
break # eine Nicht-Aufgabe wird abgelehnt, nicht vervollständigt break # eine Nicht-Aufgabe wird abgelehnt, nicht vervollständigt
log(f"[{label}] Verständnis unvollständig – fasse nach ({versuch + 1}/{UNDERSTAND_RETRIES}): " log(f"[{label}] Verständnis unvollständig – fasse nach ({versuch + 1}/{UNDERSTAND_RETRIES}): "
+ "; ".join(i.code for i in issues)) + "; ".join(i.code for i in issues))
understanding = understand_task(session, task, issues, data_prompt, figures=figures) understanding = understand_task(session, task, issues, data_prompt)
issues = check_understanding(understanding, task) issues = check_understanding(understanding, task)
if understanding.sub_questions: if understanding.sub_questions:
log(f" {len(understanding.sub_questions)} Teilfrage(n): " log(f" {len(understanding.sub_questions)} Teilfrage(n): "
@ -69,30 +68,6 @@ def _understand(session, task: str, label: str, data_prompt: str = "", figures=(
return understanding, issues 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) ...")
first = normalize_figure_ids(first)
zweite = normalize_figure_ids(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])
for nummer, lesung in ((1, first), (2, zweite)):
for f in lesung:
log(f" Lesung {nummer}, {f.id}: " + f.transcription.replace("\n", " | ")[:300])
dritte = normalize_figure_ids(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 = "", def _model_attempt(session, task: str, max_revisions: int, label: str, hint: str = "",
spec: OptimizationModelSpecification | None = None, spec: OptimizationModelSpecification | None = None,
understanding: TaskUnderstanding | None = None, data_prompt: str = "") -> dict: understanding: TaskUnderstanding | None = None, data_prompt: str = "") -> dict:
@ -242,13 +217,9 @@ def _log_usage(session, result: PipelineResult) -> None:
def run_pipeline( def run_pipeline(
task: str, model: str = DEFAULT_MODEL, max_revisions: int = MAX_REVISIONS, 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, solve_model: bool = True, solver_retries: int = SOLVER_RETRIES, max_restarts: int = MAX_RESTARTS,
session: LLMSession | None = None, data_files=(), task_dir=None, session: LLMSession | None = None, data_files=(),
) -> PipelineResult: ) -> PipelineResult:
session = session or LLMSession(model) 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) profile = data_profile.profile_files(data_files)
data_prompt = data_profile.to_prompt(profile) data_prompt = data_profile.to_prompt(profile)
for p in profile: for p in profile:
@ -256,21 +227,12 @@ def run_pipeline(
for step, step_model in session.overrides().items(): for step, step_model in session.overrides().items():
log(f"[{model}] Stufe '{step}' läuft mit abweichendem Modell: {step_model}") log(f"[{model}] Stufe '{step}' läuft mit abweichendem Modell: {step_model}")
understanding, verstehen_issues = _understand(session, task, model, data_prompt, figures) understanding, verstehen_issues = _understand(session, task, model, data_prompt)
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: if not understanding.is_optimization_task:
# Keine Entscheidung verlangt: jedes Modell wäre erfunden, jede Zahl ohne Frage dahinter # Keine Entscheidung verlangt: jedes Modell wäre erfunden, jede Zahl ohne Frage dahinter
grund = f"Keine Optimierungsaufgabe: {understanding.reason}" grund = f"Keine Optimierungsaufgabe: {understanding.reason}"
log(f"ABGELEHNT – {grund} Es wird weder modelliert noch gerechnet.") log(f"ABGELEHNT – {grund} Es wird weder modelliert noch gerechnet.")
result = PipelineResult(task=task, figures=figures, understanding=understanding, data_files=profile, rejected=True, result = PipelineResult(task=task, understanding=understanding, data_files=profile, rejected=True,
reliable=False, reliability_issues=[grund], checks=verstehen_issues) reliable=False, reliability_issues=[grund], checks=verstehen_issues)
_log_usage(session, result) _log_usage(session, result)
return result return result
@ -287,8 +249,7 @@ def run_pipeline(
best = attempts[select_best([(a["spec"], a["issues"], a["review"]) for a in attempts])] 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"] spec, issues, review = best["spec"], best["issues"] + verstehen_issues, best["review"]
problems = _unresolved(best) + [f"Abbildung: {i.message}" for i in verstehen_issues problems = _unresolved(best)
if i.code == "FIGURE_READINGS_DIFFER"]
for issue in issues: for issue in issues:
log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}") log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}")
@ -300,7 +261,7 @@ def run_pipeline(
log("Prüfung ohne Beanstandung.") log("Prüfung ohne Beanstandung.")
result = PipelineResult( result = PipelineResult(
task=task, figures=figures, understanding=understanding, data_files=profile, specification=spec, review=review, revisions=best["revisions"], task=task, understanding=understanding, data_files=profile, specification=spec, review=review, revisions=best["revisions"],
selected_revision=best["selected"], restarts=len(attempts) - 1, checks=issues, selected_revision=best["selected"], restarts=len(attempts) - 1, checks=issues,
reliable=not problems, reliability_issues=problems, reliable=not problems, reliability_issues=problems,
) )
@ -441,14 +402,11 @@ if __name__ == "__main__":
elif args.von: elif args.von:
result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions) result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions)
else: else:
try:
result = run_pipeline( result = run_pipeline(
read_task(args.task_file), model=args.model, read_task(args.task_file), model=args.model,
max_revisions=args.max_revisions, solve_model=not args.no_solve, max_restarts=args.max_restarts, 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, data_files=args.daten or (),
) )
except FigureError as exc:
parser.error(str(exc))
print(result.model_dump_json(indent=2)) print(result.model_dump_json(indent=2))
if args.report: if args.report:
write_report(result, args.report) write_report(result, args.report)

View file

@ -26,10 +26,9 @@ RESULTS_DIR = BENCHMARK_DIR / "results"
# Nach dem ersten fatalen API-Fehler (Kontingent/Guthaben/Key) keine weiteren Läufe starten # Nach dem ersten fatalen API-Fehler (Kontingent/Guthaben/Key) keine weiteren Läufe starten
quota_exhausted = threading.Event() quota_exhausted = threading.Event()
# Dateien, die das LLM-Verhalten steuern: die System-Prompts und die Feldbeschreibungen # Dateien, die das LLM-Verhalten steuern: die vier System-Prompts und die Feldbeschreibungen
# (Field(description=...) geht als JSON-Schema mit und wirkt wie eine Prompt-Anweisung). # (Field(description=...) geht als JSON-Schema mit und wirkt wie eine Prompt-Anweisung).
PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "understanding/Abbildungs-Prompt.txt", PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt",
"modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt",
"solving/Solver-Prompt.txt", "solving/Solver-Prompt.txt",
"core/model_schema.py") "core/model_schema.py")

View file

@ -4,7 +4,6 @@ error -> löst in run_pipeline eine Korrekturrunde aus
warning -> wird nur berichtet (Heuristiken mit möglichen Fehlalarmen) warning -> wird nur berichtet (Heuristiken mit möglichen Fehlalarmen)
""" """
import re import re
from collections import Counter
from typing import Iterable, List, Set from typing import Iterable, List, Set
from core.model_schema import ( from core.model_schema import (
@ -206,56 +205,6 @@ def check_understanding(understanding, task: str) -> List[CheckIssue]:
return issues return issues
def _number_rows(text: str) -> Counter:
"""Je Datenzeile das Tupel ihrer Zahlen: „Pfeil 1 → 2: 3“ und „(1,2): 3“ ergeben beide (1, 2, 3).
Zeilen trennt der Zeilenumbruch oder ein Semikolon; Zeilen ohne Zahl zählen nicht. So ist der Vergleich
unabhängig von der Schreibweise, bemerkt aber eine Bewertung am falschen Pfeil.
"""
zeilen = Counter()
# „(1,2)“ ist ein Pfeil, keine Dezimalzahl 1,2 – ganzzahlige Paare in Klammern vorher trennen
text = re.sub(r"\((\d+)\s*,\s*(\d+)\)", r"(\1 \2)", text or "")
for zeile in re.split(r"[\n;]", text):
tupel = []
for roh in NUMBER_PATTERN.findall(zeile):
try:
tupel.append(normalize_number(roh))
except ValueError:
continue
if tupel:
zeilen[tuple(tupel)] += 1
return zeilen
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 Datenzeilen als
Zahlentupel (`_number_rows`). Erster Ansatz – alle Zahlen des Freitexts – schlug im ersten echten Lauf
fälschlich an: eine Lesung zählte zusätzlich „Knoten: 1, 2, 3, 4“ auf (test_Image.md, 17.09.2026).
Eine vertauschte Pfeilrichtung (2 → 3 statt 3 → 2) fällt auf; eine in BEIDEN Lesungen gleich falsche nicht.
"""
from understanding.figures import canonical_figure_id
issues = []
erste = {canonical_figure_id(f.id): f for f in first}
zweite = {canonical_figure_id(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 = _number_rows(erste[kennung].transcription)
b = _number_rows(zweite[kennung].transcription)
if a != b:
zeilen = lambda z: ", ".join("(" + " ".join(t) + ")" for t in sorted(z.elements())) or "–"
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="FIGURE_READINGS_DIFFER",
message=f"{kennung} uneinheitlich gelesen – Datenzeilen nur in Lesung 1: {zeilen(a - b)}; "
f"nur in Lesung 2: {zeilen(b - a)}."))
return issues
def _strip_scripts(text: str) -> str: def _strip_scripts(text: str) -> str:
"""Entfernt Indizes/Exponenten: x_{i,j} -> x, C^{prod}_{p} -> C, x_1 -> x.""" """Entfernt Indizes/Exponenten: x_{i,j} -> x, C^{prod}_{p} -> C, x_1 -> x."""
text = text.replace(r"\_", "_") text = text.replace(r"\_", "_")

View file

@ -14,14 +14,10 @@ import instructor
from core.model_schema import CallRecord, LLMUsage from core.model_schema import CallRecord, LLMUsage
# Alias auf das jeweils neueste DeepSeek-Flash-Modell (September 2026: V4.1 Flash, nimmt auch Bilder an). DEFAULT_MODEL = os.environ.get("LLM_MODEL", "deepseek/deepseek-v4.1-flash")
# Für vergleichbare Benchmarks ein festes Modell mit --models angeben.
DEFAULT_MODEL = os.environ.get("LLM_MODEL", "~deepseek/deepseek-flash-latest")
# Modell je Pipeline-Stufe: ein starkes Modell für Extraktion/Prüfung, ein schnelles für den Solver-Code # 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). # (oder umgekehrt, je nach Messung). Leer = das Modell der Session (LLM_MODEL bzw. --model).
STEP_MODEL_ENV = { 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", "Extraktion": "LLM_MODEL_EXTRACTION",
"Prüfung": "LLM_MODEL_REVIEW", "Prüfung": "LLM_MODEL_REVIEW",
"Korrektur": "LLM_MODEL_REVISION", "Korrektur": "LLM_MODEL_REVISION",

View file

@ -118,32 +118,6 @@ class IgnoredNumber(BaseModel):
"'Teil der Einheit (in 1000 Stunden)', 'Seitenzahl'") "'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="Nur die Daten, eine Zeile je Datenpunkt, jede Zahl genau einmal (Graph: je Pfeil 'A → B: Wert', alle Pfeile; "
"Tabelle: 'Zeile / Spalte: Wert'). Keine Knotenaufzählung, keine Lagebeschreibung – das gehört nach shows.")
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): class TaskUnderstanding(BaseModel):
"""Was in der Aufgabe steht – vor jeder Modellierung: Fragen, Zahlen, Machbarkeit.""" """Was in der Aufgabe steht – vor jeder Modellierung: Fragen, Zahlen, Machbarkeit."""
is_optimization_task: bool = Field( is_optimization_task: bool = Field(
@ -155,9 +129,6 @@ class TaskUnderstanding(BaseModel):
facts: List[Fact] = Field(description="Alle Zahlenwerte des Textes, die Eingabegrößen des Modells sind") facts: List[Fact] = Field(description="Alle Zahlenwerte des Textes, die Eingabegrößen des Modells sind")
ignored_numbers: List[IgnoredNumber] = Field( ignored_numbers: List[IgnoredNumber] = Field(
default_factory=list, description="Zahlen im Text, die keine Eingabegrößen sind, mit Begründung") 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): class ObjectiveDefinition(BaseModel):
@ -459,8 +430,6 @@ class LLMUsage(BaseModel):
class PipelineResult(BaseModel): class PipelineResult(BaseModel):
task: str = "" task: str = ""
figures: List[FigureInput] = Field(
default_factory=list, description="Abbildungen der Aufgabe; ihre Übertragung steht im Aufgabentext (task)")
data_files: List[DataFileProfile] = Field( data_files: List[DataFileProfile] = Field(
default_factory=list, description="Profile der beigelegten Datendateien (--daten)") default_factory=list, description="Profile der beigelegten Datendateien (--daten)")
understanding: Optional[TaskUnderstanding] = Field( understanding: Optional[TaskUnderstanding] = Field(

View file

@ -301,28 +301,6 @@ def _fmt_number(value) -> str:
return str(value) 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 []
from understanding.figures import canonical_figure_id
uebertragen = {canonical_figure_id(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: def _rejected_document(result: PipelineResult) -> list:
"""Abgelehnte Aufgabe: Grund und Aufgabentext – kein Modell, keine Zahlen.""" """Abgelehnte Aufgabe: Grund und Aufgabentext – kein Modell, keine Zahlen."""
grund = result.understanding.reason if result.understanding else "; ".join(result.reliability_issues) grund = result.understanding.reason if result.understanding else "; ".join(result.reliability_issues)
@ -333,7 +311,7 @@ def _rejected_document(result: PipelineResult) -> list:
doc.append(("list", [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}", f"Modell: {result.usage.model}", doc.append(("list", [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}", f"Modell: {result.usage.model}",
f"LLM: {result.usage.calls} Aufrufe{cost}"])) f"LLM: {result.usage.calls} Aufrufe{cost}"]))
if result.task.strip(): if result.task.strip():
doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result) doc += [("h2", "Aufgabe"), ("pre", result.task.strip())]
return doc return doc
@ -364,7 +342,7 @@ def build_document(result: PipelineResult, model: str = "") -> list:
doc += _result_section(result) doc += _result_section(result)
if result.task.strip(): if result.task.strip():
doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result) doc += [("h2", "Aufgabe"), ("pre", result.task.strip())]
if result.data_files: if result.data_files:
doc += [("h2", "Beigelegte Daten")] doc += [("h2", "Beigelegte Daten")]
@ -490,8 +468,6 @@ def to_markdown(doc: list) -> str:
out.append(f"**{_md_inline(block[1])}**") out.append(f"**{_md_inline(block[1])}**")
elif kind == "svg": elif kind == "svg":
pass # Diagramme nur im HTML-Report pass # Diagramme nur im HTML-Report
elif kind == "img":
out.append(f"![{block[2]}]({block[1]})")
elif kind == "list": elif kind == "list":
out.append("\n".join(f"- {_md_inline(item)}" for item in block[1])) out.append("\n".join(f"- {_md_inline(item)}" for item in block[1]))
elif kind == "math": elif kind == "math":
@ -615,9 +591,6 @@ def to_html(doc: list) -> str:
out.append(f'<p class="lead">{_html_inline(block[1])}</p>') out.append(f'<p class="lead">{_html_inline(block[1])}</p>')
elif kind == "svg": elif kind == "svg":
out.append(f'<figure class="viz-root">{block[1]}</figure>') out.append(f'<figure class="viz-root">{block[1]}</figure>')
elif kind == "img":
out.append(f'<figure><img src="{html.escape(block[1], quote=True)}" alt="{html.escape(block[2])}" '
f'style="max-width:100%"><figcaption>{html.escape(block[2])}</figcaption></figure>')
elif kind == "list": elif kind == "list":
out.append("<ul>" + "".join(f"<li>{_html_inline(item)}</li>" for item in block[1]) + "</ul>") out.append("<ul>" + "".join(f"<li>{_html_inline(item)}</li>" for item in block[1]) + "</ul>")
elif kind == "math": elif kind == "math":

View file

@ -1,165 +0,0 @@
"""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: (2 4 3)" in befunde[0].message and "nur in Lesung 2: (2 4 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'<img src="data:image/png;base64,{PNG}"' in html
assert "Pfeil 1 → 3: 1" in html and "unsicher gelesen" in html
assert f"![Abbildung 1](data:image/png;base64,{PNG})" in to_markdown(build_document(result))
def test_reading_comparison_ignores_wording_but_not_assignment():
# erster echter Lauf: Knotenaufzählung und doppelte Pfeilbeschreibung lösten einen Fehlalarm aus
anders_formuliert = GRAPH.model_copy(update={"transcription": "(1,2): 3; (1,3): 1; (3,2): 1; (2,4): 3; (3,4): 5"})
assert check_figure_readings([GRAPH], [anders_formuliert]) == []
vertauscht = GRAPH.model_copy(update={"transcription": GRAPH.transcription.replace("1 → 2: 3", "1 → 2: 1")
.replace("1 → 3: 1", "1 → 3: 3")}) # gleiche Zahlen, falsche Pfeile
assert check_figure_readings([GRAPH], [vertauscht])
richtung = GRAPH.model_copy(update={"transcription": GRAPH.transcription.replace("3 → 2: 1", "2 → 3: 1")})
assert check_figure_readings([GRAPH], [richtung])
def test_figure_ids_are_compared_by_number():
# beobachtet: Lesung 1 schrieb als Kennung nur „1“, Lesung 2 „Abbildung 1“ – Inhalt identisch
from understanding.figures import canonical_figure_id
assert check_figure_readings([GRAPH.model_copy(update={"id": "1"})], [GRAPH]) == []
assert canonical_figure_id("Abb. 2") == "Abbildung 2"
def test_short_id_in_understanding_still_reaches_the_report():
result, _ = _lauf(GRAPH.model_copy(update={"id": "1"}), [GRAPH])
html = to_html(build_document(result))
assert "nicht übertragen" not in html and "Pfeil 3 → 4: 5" in html

View file

@ -157,9 +157,3 @@ def test_old_results_without_cache_fields_still_load():
"reasoning_tokens": 0, "call_records": [{"step": "Verstehen", "seconds": 1.0, "completion_tokens": 5}]} "reasoning_tokens": 0, "call_records": [{"step": "Verstehen", "seconds": 1.0, "completion_tokens": 5}]}
usage = LLMUsage.model_validate(alt) usage = LLMUsage.model_validate(alt)
assert usage.cached_tokens == 0 and usage.call_records[0].cached_tokens == 0 assert usage.cached_tokens == 0 and usage.call_records[0].cached_tokens == 0
def test_understanding_and_figure_reading_share_their_own_model(monkeypatch):
monkeypatch.setenv("LLM_MODEL_UNDERSTANDING", "bild/modell")
assert model_for_step("Verstehen", "a/b") == "bild/modell"
assert model_for_step("Abbildungen", "a/b") == "bild/modell"

View file

@ -24,16 +24,13 @@ EINFACHES_VERSTAENDNIS = TaskUnderstanding(
class FakeSession: class FakeSession:
"""Liefert vorgegebene Antworten je Antwort-Typ in fester Reihenfolge.""" """Liefert vorgegebene Antworten je Antwort-Typ in fester Reihenfolge."""
def __init__(self, specs, reviews, codes=(), understandings=None, readings=()): def __init__(self, specs, reviews, codes=(), understandings=None):
from core.model_schema import FigureReading
self.queues = { self.queues = {
OptimizationModelSpecification: list(specs), OptimizationModelSpecification: list(specs),
AssumptionReview: list(reviews), AssumptionReview: list(reviews),
SolverCode: list(codes), SolverCode: list(codes),
TaskUnderstanding: list(understandings if understandings is not None else []), TaskUnderstanding: list(understandings if understandings is not None else []),
FigureReading: list(readings),
} }
self.contents = [] # (Stufe, Inhalt der letzten Nachricht) – prüft, wer Bilder zu sehen bekommt
self.usage = LLMUsage(model="fake", provider="fake") self.usage = LLMUsage(model="fake", provider="fake")
self.calls = {kind: 0 for kind in self.queues} self.calls = {kind: 0 for kind in self.queues}
self.steps = [] self.steps = []
@ -45,7 +42,6 @@ class FakeSession:
def create(self, response_model, messages, step="unbenannt"): def create(self, response_model, messages, step="unbenannt"):
self.letzter_prompt = messages[-1]["content"] self.letzter_prompt = messages[-1]["content"]
self.contents.append((step, messages[-1]["content"]))
if step == "Extraktion": if step == "Extraktion":
self.tasks.append(messages[-1]["content"]) self.tasks.append(messages[-1]["content"])
self.calls[response_model] += 1 self.calls[response_model] += 1

View file

@ -1,11 +0,0 @@
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“); hier ist Platz für Lage und Aufbau.
- transcription: NUR die Daten, eine Zeile je Datenpunkt, jede Zahl genau einmal, ALLE Datenpunkte (ein Graph mit fünf Pfeilen ergibt fünf Zeilen) – bei Graphen je Pfeil „Start → Ziel: Bewertung“ (Schema „A → B: Wert“ mit den Knotennamen und Bewertungen aus dem Bild), bei Tabellen je Zelle „Zeile / Spalte: Wert“, bei Diagrammen je Punkt „x: y“. Keine Aufzählung von Knoten, keine Lagebeschreibung, keine Wiederholung – das gehört nach shows. 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,6 +24,4 @@ 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. - 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] Eine Zahl, die weder im einen noch im anderen steht, gilt als übersehen. [→ UNCAPTURED_NUMBERS]
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 Daten, NUR die Daten, eine Zeile je Datenpunkt, jede Zahl genau einmal, ALLE Datenpunkte (ein Graph mit fünf Pfeilen ergibt fünf Zeilen) – bei Graphen je Pfeil „Start → Ziel: Bewertung“ (Schema „A → B: Wert“ mit den Knotennamen und Bewertungen aus dem Bild), bei Tabellen je Zelle „Zeile / Spalte: Wert“, bei Diagrammen je Punkt „x: y“. Keine Aufzählung von Knoten, keine Lagebeschreibung, keine Wiederholung – das gehört nach shows. Achte auf die Richtung der Pfeilspitze., 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] 4. Erfinde nichts. Was der Text nicht sagt, gehört nicht in facts – fehlende Angaben werden später als Annahme ausgewiesen, nicht hier geraten.
5. Erfinde nichts. Was der Text nicht sagt, gehört nicht in facts – fehlende Angaben werden später als Annahme ausgewiesen, nicht hier geraten.

View file

@ -1,97 +0,0 @@
"""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 canonical_figure_id(kennung: str) -> str:
"""„1“, „Abb. 1“, „Abbildung 1“ → „Abbildung 1“ – beobachtet: eine Lesung schrieb nur „1“."""
nummer = re.search(r"\d+", kennung or "")
return f"Abbildung {nummer.group(0)}" if nummer else (kennung or "").strip()
def normalize_figure_ids(transcriptions: List[FigureTranscription]) -> List[FigureTranscription]:
return [t.model_copy(update={"id": canonical_figure_id(t.id)}) for t in transcriptions]
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,52 +3,24 @@ from pathlib import Path
from typing import List from typing import List
from core.llm import LLMSession from core.llm import LLMSession
from core.model_schema import CheckIssue, FigureInput, FigureReading, TaskUnderstanding from core.model_schema import CheckIssue, TaskUnderstanding
from understanding.figures import image_parts
UNDERSTAND_PROMPT_FILE = Path(__file__).parent / "Verstehen-Prompt.txt" 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] = (), def understand_task(session: LLMSession, task: str, issues: List[CheckIssue] = (),
data_prompt: str = "", figures: List[FigureInput] = ()) -> TaskUnderstanding: data_prompt: str = "") -> TaskUnderstanding:
"""Stufe 1: erfassen, was im Text steht – Teilfragen, Zahlen, Machbarkeit, Abbildungen. Noch kein Modell.""" """Stufe 0: erfassen, was im Text steht – Teilfragen, Zahlen, Machbarkeit. Noch kein Modell."""
nachtrag = "" nachtrag = ""
if issues: if issues:
nachtrag = ("\n\nDEIN VORIGER VERSUCH WAR UNVOLLSTÄNDIG:\n" nachtrag = ("\n\nDEIN VORIGER VERSUCH WAR UNVOLLSTÄNDIG:\n"
+ "\n".join(f"- [{i.code}] {i.message}" for i in issues) + "\n".join(f"- [{i.code}] {i.message}" for i in issues)
+ "\nErfasse die fehlenden Punkte, behalte den Rest bei.") + "\nErfasse die fehlenden Punkte, behalte den Rest bei.")
text = f"AUFGABENTEXT:\n{task}\n\n{data_prompt}{nachtrag}".strip() return session.create(
return _create_with_images(session, TaskUnderstanding, [ TaskUnderstanding,
[
{"role": "system", "content": UNDERSTAND_PROMPT_FILE.read_text(encoding="utf-8")}, {"role": "system", "content": UNDERSTAND_PROMPT_FILE.read_text(encoding="utf-8")},
{"role": "user", "content": _content(text, list(figures))}, {"role": "user", "content": f"AUFGABENTEXT:\n{task}\n\n{data_prompt}{nachtrag}".strip()},
], "Verstehen", figures) ],
step="Verstehen",
)
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)