diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b4797d3..b7062f5 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 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) 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 und Abbildungen +## 8. Aufgaben mit Datendateien 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 ### 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. -**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 | – | | `GEMINI_API_KEY` | Schlüssel für Gemini | – | -| `LLM_MODEL` | Modell, falls `--model` fehlt | `~deepseek/deepseek-flash-latest` | -| `LLM_MODEL_UNDERSTANDING` | Modell für Verstehen und Abbildungs-Gegenprobe (muss bei Abbildungen Bilder lesen können) | Session-Modell | +| `LLM_MODEL` | Modell, falls `--model` fehlt | `deepseek/deepseek-v4.1-flash` | | `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 597e57f..640b564 100644 --- a/README.md +++ b/README.md @@ -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) | – | | `--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 | – | | `--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 | @@ -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_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 @@ -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 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 151d7c9..32ecd88 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_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 core.llm import DEFAULT_MODEL, LLMSession 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 solving.script_export import build_solver_script 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 read_figures, understand_task +from understanding.understand import understand_task MAX_REVISIONS = int(os.environ.get("MAX_REVISIONS", "2")) SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2")) @@ -48,19 +47,19 @@ def log(msg: str) -> None: 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.""" if UNDERSTAND_RETRIES < 0: return None, [] - log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen" + (", Abbildungen" if figures else "") + ") ...") - understanding = understand_task(session, task, data_prompt=data_prompt, figures=figures) + log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen) ...") + understanding = understand_task(session, task, data_prompt=data_prompt) 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, figures=figures) + understanding = understand_task(session, task, issues, data_prompt) issues = check_understanding(understanding, task) if understanding.sub_questions: 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 -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 = "", spec: OptimizationModelSpecification | None = None, understanding: TaskUnderstanding | None = None, data_prompt: str = "") -> dict: @@ -242,13 +217,9 @@ 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=(), task_dir=None, + session: LLMSession | None = None, data_files=(), ) -> 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: @@ -256,21 +227,12 @@ 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, 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) + understanding, verstehen_issues = _understand(session, task, model, data_prompt) 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, 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) _log_usage(session, result) return result @@ -287,8 +249,7 @@ 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) + [f"Abbildung: {i.message}" for i in verstehen_issues - if i.code == "FIGURE_READINGS_DIFFER"] + problems = _unresolved(best) for issue in issues: log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}") @@ -300,7 +261,7 @@ def run_pipeline( log("Prüfung ohne Beanstandung.") 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, reliable=not problems, reliability_issues=problems, ) @@ -441,14 +402,11 @@ if __name__ == "__main__": elif args.von: result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions) else: - 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)) + 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 (), + ) 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 f70b1c1..5a77f8c 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -26,10 +26,9 @@ 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 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). -PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "understanding/Abbildungs-Prompt.txt", - "modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt", +PROMPT_FILES = ("understanding/Verstehen-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 083eac5..5cdf171 100644 --- a/checks/model_checks.py +++ b/checks/model_checks.py @@ -4,7 +4,6 @@ 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 ( @@ -206,56 +205,6 @@ def check_understanding(understanding, task: str) -> List[CheckIssue]: 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: """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 b80e33c..074165d 100644 --- a/core/llm.py +++ b/core/llm.py @@ -14,14 +14,10 @@ import instructor from core.model_schema import CallRecord, LLMUsage -# Alias auf das jeweils neueste DeepSeek-Flash-Modell (September 2026: V4.1 Flash, nimmt auch Bilder an). -# Für vergleichbare Benchmarks ein festes Modell mit --models angeben. -DEFAULT_MODEL = os.environ.get("LLM_MODEL", "~deepseek/deepseek-flash-latest") +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 5ecf0d9..14b4b68 100644 --- a/core/model_schema.py +++ b/core/model_schema.py @@ -118,32 +118,6 @@ 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="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): """Was in der Aufgabe steht – vor jeder Modellierung: Fragen, Zahlen, Machbarkeit.""" 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") 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): @@ -459,8 +430,6 @@ 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 912d577..507f136 100644 --- a/reporting/report.py +++ b/reporting/report.py @@ -301,28 +301,6 @@ 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 [] - 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: """Abgelehnte Aufgabe: Grund und Aufgabentext – kein Modell, keine Zahlen.""" 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}", f"LLM: {result.usage.calls} Aufrufe{cost}"])) if result.task.strip(): - doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result) + doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] return doc @@ -364,7 +342,7 @@ def build_document(result: PipelineResult, model: str = "") -> list: doc += _result_section(result) 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: doc += [("h2", "Beigelegte Daten")] @@ -490,8 +468,6 @@ 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": @@ -615,9 +591,6 @@ 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 deleted file mode 100644 index 99b12c5..0000000 --- a/tests/test_figures.py +++ /dev/null @@ -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' 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)) diff --git a/understanding/understand.py b/understanding/understand.py index 61c68eb..1cb1d1c 100644 --- a/understanding/understand.py +++ b/understanding/understand.py @@ -3,52 +3,24 @@ from pathlib import Path from typing import List from core.llm import LLMSession -from core.model_schema import CheckIssue, FigureInput, FigureReading, TaskUnderstanding -from understanding.figures import image_parts +from core.model_schema import CheckIssue, TaskUnderstanding 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 = "", figures: List[FigureInput] = ()) -> TaskUnderstanding: - """Stufe 1: erfassen, was im Text steht – Teilfragen, Zahlen, Machbarkeit, Abbildungen. Noch kein Modell.""" + data_prompt: str = "") -> TaskUnderstanding: + """Stufe 0: erfassen, was im Text steht – Teilfragen, Zahlen, Machbarkeit. 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.") - 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) + 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", + )