diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b7062f5..b4797d3 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -11,7 +11,7 @@ Diese Anleitung beschreibt Schritt für Schritt, wie man das Programm installier 5. [Aufrufvarianten](#5-aufrufvarianten) 6. [Was das Programm ausgibt](#6-was-das-programm-ausgibt) 7. [Das Ergebnis richtig lesen](#7-das-ergebnis-richtig-lesen) -8. [Aufgaben mit Datendateien](#8-aufgaben-mit-datendateien) +8. [Aufgaben mit Datendateien und Abbildungen](#8-aufgaben-mit-datendateien-und-abbildungen) 9. [Weiterarbeiten ohne neue LLM-Kosten](#9-weiterarbeiten-ohne-neue-llm-kosten) 10. [Kosten und Laufzeit steuern](#10-kosten-und-laufzeit-steuern) 11. [Alle Einstellungen im Überblick](#11-alle-einstellungen-im-überblick) @@ -279,7 +279,7 @@ Der Report beginnt mit dem Ergebnis und erklärt, bevor er Tabellen zeigt. Empfo --- -## 8. Aufgaben mit Datendateien +## 8. Aufgaben mit Datendateien und Abbildungen Für größere Datenmengen (Kurse, Bedarfe, Entfernungen) legt man Dateien bei: @@ -302,6 +302,36 @@ cp kurse.csv entfernungen.xlsx output/ --- +### Aufgaben mit Abbildungen (z. B. aus docling) + +Stehen Daten nur in einer Abbildung – ein Graph mit bewerteten Pfeilen, eine Tabelle als Bild –, kann die Aufgabe die Abbildung als Markdown-Bild enthalten. Beide Formen, die docling erzeugt, werden erkannt: + +- **eingebettet** (`ImageRefMode.EMBEDDED`, Standard): `` +- **als Datei** (`ImageRefMode.REFERENCED`): `` – 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` @@ -371,7 +401,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. -**Feste Modellnamen verwenden** (z. B. `deepseek/deepseek-v4.1-flash`), keine `…-latest`-Aliase – sonst ändern sich Ergebnisse unbemerkt. +**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`). --- @@ -399,7 +429,8 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav |---|---|---| | `OPENROUTER_API_KEY` | Schlüssel für OpenRouter | – | | `GEMINI_API_KEY` | Schlüssel für Gemini | – | -| `LLM_MODEL` | Modell, falls `--model` fehlt | `deepseek/deepseek-v4.1-flash` | +| `LLM_MODEL` | 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_EXTRACTION` | Modell nur für die Extraktion | Session-Modell | | `LLM_MODEL_REVIEW` | Modell nur für die Prüfung | Session-Modell | | `LLM_MODEL_REVISION` | Modell nur für die Korrektur | Session-Modell | diff --git a/README.md b/README.md index 640b564..597e57f 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-v4.1-flash` | +| `--model` / `LLM_MODEL` | LLM-Modell | `~deepseek/deepseek-flash-latest` (Alias, derzeit 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_EXTRACTION`, `LLM_MODEL_REVIEW`, `LLM_MODEL_REVISION`, `LLM_MODEL_SOLVER` | Modell einzelner Pipeline-Stufen, z. B. ein schnelles für den Solver-Code; verschiedene Anbieter sind erlaubt | Modell aus `--model` | +| `LLM_MODEL_UNDERSTANDING`, `LLM_MODEL_EXTRACTION`, `LLM_MODEL_REVIEW`, `LLM_MODEL_REVISION`, `LLM_MODEL_SOLVER` | Modell einzelner Pipeline-Stufen, z. B. ein schnelles für den Solver-Code; verschiedene Anbieter sind erlaubt | Modell aus `--model` | ### Solver-Programme @@ -152,6 +152,7 @@ Das LLM kann Fehler machen – deshalb gibt es mehrere Sicherungen: - **Jede Teilfrage beantwortet:** Der Aufgabentext wird vor dem Modellieren in seine Teilfragen zerlegt (a, b, …); das Programm liefert je Teilfrage einen Antwortsatz. Fehlt eine Antwort, gilt die Aufgabe als nicht erfüllt – auch wenn der Zielwert stimmt. - **Jede Zahl der Antwort belegt:** Zahlen im Antworttext müssen aus dem Ergebnis oder der Aufgabe stammen (Rundungen und Einheitenumrechnungen werden akzeptiert). Ein Vergleichswert, der in keiner Tabelle steht, fällt auf. - **Jede Zahl der Aufgabe erfasst:** Zahlen des Aufgabentexts müssen als Fakt mit Fundstelle oder mit Begründung als irrelevant ausgewiesen sein – sonst betreten sie die Pipeline erst im Solver-Code, hinter allen Prüfungen. +- **Abbildungen werden gelesen und gegengeprüft:** Enthält die Aufgabe Bilder (docling-Markdown, eingebettet oder als Datei), überträgt die Verstehensstufe sie in Text; eine zweite, unabhängige Lesung wird Zahl für Zahl verglichen. Alle weiteren Stufen rechnen mit der Übertragung, der Report zeigt Original und Übertragung nebeneinander. - **Selbst erzeugte Daten sind gekennzeichnet:** Erzeugt das Programm Eingabedaten (Zufallszahlen, synthetischer Markt, verkleinertes Universum), muss das in `data_assumptions` stehen, nicht nur im Fließtext – sonst folgt eine Reparatur und notfalls die Markierung „unzuverlässig“. - **Szenarien nachgerechnet:** Vergleicht die Aufgabe mehrere Konfigurationen, wird jede durch erneutes Ausführen des Programms bestätigt – ohne LLM. Hat ein Szenario kein Ergebnis und ist nicht als unlösbar belegt (beobachtet: Zeitlimit abgelaufen, gemeldet als „keine Lösung“), folgt eine Reparatur und notfalls die Markierung „unzuverlässig“. - **Keine erfundenen Einheiten:** Eine Einheit hinter einer Zahl im Antworttext muss im Aufgabentext oder in beigelegten Daten vorkommen (nach Größenart: „Tausend Euro“ deckt „€“); neutrale Geld- und Mengeneinheiten (GE, ME) nur, wenn der Text von Kosten bzw. Mengen spricht. Sonst folgt eine Reparatur. diff --git a/TextToModel.py b/TextToModel.py index 32ecd88..151d7c9 100644 --- a/TextToModel.py +++ b/TextToModel.py @@ -5,7 +5,7 @@ import sys from pathlib import Path from typing import List -from checks.model_checks import check_spec, check_understanding, has_errors +from checks.model_checks import check_figure_readings, check_spec, check_understanding, has_errors from checks.review import needs_work, review_assumptions, revision_reasons, select_best from core.llm import DEFAULT_MODEL, LLMSession from core.model_schema import ( @@ -22,7 +22,8 @@ from modeling.extraction import revise_model, text_to_model from reporting.report import write_report from solving.script_export import build_solver_script from solving.solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve -from understanding.understand import understand_task +from understanding.figures import FigureError, normalize_figure_ids, split_figures, with_transcriptions +from understanding.understand import read_figures, understand_task MAX_REVISIONS = int(os.environ.get("MAX_REVISIONS", "2")) SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2")) @@ -47,19 +48,19 @@ def log(msg: str) -> None: print(msg, file=sys.stderr) -def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple: +def _understand(session, task: str, label: str, data_prompt: str = "", figures=()) -> tuple: """Verstehensstufe samt Prüfung; ein Nachfassen, wenn Zahlen oder Teilfragen fehlen.""" if UNDERSTAND_RETRIES < 0: return None, [] - log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen) ...") - understanding = understand_task(session, task, data_prompt=data_prompt) + log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen" + (", Abbildungen" if figures else "") + ") ...") + understanding = understand_task(session, task, data_prompt=data_prompt, figures=figures) issues = check_understanding(understanding, task) for versuch in range(UNDERSTAND_RETRIES): if not issues or not understanding.is_optimization_task: break # eine Nicht-Aufgabe wird abgelehnt, nicht vervollständigt log(f"[{label}] Verständnis unvollständig – fasse nach ({versuch + 1}/{UNDERSTAND_RETRIES}): " + "; ".join(i.code for i in issues)) - understanding = understand_task(session, task, issues, data_prompt) + understanding = understand_task(session, task, issues, data_prompt, figures=figures) issues = check_understanding(understanding, task) if understanding.sub_questions: log(f" {len(understanding.sub_questions)} Teilfrage(n): " @@ -68,6 +69,30 @@ def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple: return understanding, issues +def _checked_figure_readings(session, task: str, figures, first, label: str) -> tuple: + """Gegenprobe der Abbildungen: zweite, unabhängige Lesung; bei Abweichung entscheidet eine dritte. + + Stimmen zwei von drei Lesungen Zahl für Zahl überein, gilt diese Übertragung. Sonst bleibt die erste, + und der Befund macht das Ergebnis unzuverlässig – die Daten der Aufgabe stünden auf ungeprüftem Ablesen. + """ + log(f"[{label}] Lese {len(figures)} Abbildung(en) ein zweites Mal (Gegenprobe) ...") + 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: @@ -217,9 +242,13 @@ def _log_usage(session, result: PipelineResult) -> None: def run_pipeline( task: str, model: str = DEFAULT_MODEL, max_revisions: int = MAX_REVISIONS, solve_model: bool = True, solver_retries: int = SOLVER_RETRIES, max_restarts: int = MAX_RESTARTS, - session: LLMSession | None = None, data_files=(), + session: LLMSession | None = None, data_files=(), task_dir=None, ) -> PipelineResult: session = session or LLMSession(model) + # Abbildungen (docling: Base64 oder Bilddatei) gehen nur an die Verstehensstufe; ab dann zählt ihre Übertragung + task, figures = split_figures(task, task_dir) + if figures: + log(f"{len(figures)} Abbildung(en) in der Aufgabe – werden in der Verstehensstufe in Text übertragen.") profile = data_profile.profile_files(data_files) data_prompt = data_profile.to_prompt(profile) for p in profile: @@ -227,12 +256,21 @@ def run_pipeline( for step, step_model in session.overrides().items(): log(f"[{model}] Stufe '{step}' läuft mit abweichendem Modell: {step_model}") - understanding, verstehen_issues = _understand(session, task, model, data_prompt) + understanding, verstehen_issues = _understand(session, task, model, data_prompt, figures) + if figures and understanding is not None: + if understanding.is_optimization_task: + understanding.figures, figur_befunde = _checked_figure_readings( + session, task, figures, understanding.figures, model) + verstehen_issues = verstehen_issues + figur_befunde + for figur in understanding.figures: + if figur.uncertain: + log(f" {figur.id} – unsicher gelesen: " + "; ".join(figur.uncertain)) + task = with_transcriptions(task, understanding.figures) if not understanding.is_optimization_task: # Keine Entscheidung verlangt: jedes Modell wäre erfunden, jede Zahl ohne Frage dahinter grund = f"Keine Optimierungsaufgabe: {understanding.reason}" log(f"ABGELEHNT – {grund} Es wird weder modelliert noch gerechnet.") - result = PipelineResult(task=task, understanding=understanding, data_files=profile, rejected=True, + result = PipelineResult(task=task, figures=figures, understanding=understanding, data_files=profile, rejected=True, reliable=False, reliability_issues=[grund], checks=verstehen_issues) _log_usage(session, result) return result @@ -249,7 +287,8 @@ def run_pipeline( best = attempts[select_best([(a["spec"], a["issues"], a["review"]) for a in attempts])] spec, issues, review = best["spec"], best["issues"] + verstehen_issues, best["review"] - problems = _unresolved(best) + problems = _unresolved(best) + [f"Abbildung: {i.message}" for i in verstehen_issues + if i.code == "FIGURE_READINGS_DIFFER"] for issue in issues: log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}") @@ -261,7 +300,7 @@ def run_pipeline( log("Prüfung ohne Beanstandung.") result = PipelineResult( - task=task, understanding=understanding, data_files=profile, specification=spec, review=review, revisions=best["revisions"], + task=task, figures=figures, understanding=understanding, data_files=profile, specification=spec, review=review, revisions=best["revisions"], selected_revision=best["selected"], restarts=len(attempts) - 1, checks=issues, reliable=not problems, reliability_issues=problems, ) @@ -402,11 +441,14 @@ if __name__ == "__main__": elif args.von: result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions) else: - result = run_pipeline( - read_task(args.task_file), model=args.model, - max_revisions=args.max_revisions, solve_model=not args.no_solve, max_restarts=args.max_restarts, - data_files=args.daten or (), - ) + try: + result = run_pipeline( + read_task(args.task_file), model=args.model, + max_revisions=args.max_revisions, solve_model=not args.no_solve, max_restarts=args.max_restarts, + data_files=args.daten or (), task_dir=Path(args.task_file).parent if args.task_file else None, + ) + except FigureError as exc: + parser.error(str(exc)) print(result.model_dump_json(indent=2)) if args.report: write_report(result, args.report) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 5a77f8c..f70b1c1 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -26,9 +26,10 @@ RESULTS_DIR = BENCHMARK_DIR / "results" # Nach dem ersten fatalen API-Fehler (Kontingent/Guthaben/Key) keine weiteren Läufe starten quota_exhausted = threading.Event() -# Dateien, die das LLM-Verhalten steuern: die vier System-Prompts und die Feldbeschreibungen +# Dateien, die das LLM-Verhalten steuern: die System-Prompts und die Feldbeschreibungen # (Field(description=...) geht als JSON-Schema mit und wirkt wie eine Prompt-Anweisung). -PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt", +PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "understanding/Abbildungs-Prompt.txt", + "modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt", "solving/Solver-Prompt.txt", "core/model_schema.py") diff --git a/checks/model_checks.py b/checks/model_checks.py index 5cdf171..083eac5 100644 --- a/checks/model_checks.py +++ b/checks/model_checks.py @@ -4,6 +4,7 @@ error -> löst in run_pipeline eine Korrekturrunde aus warning -> wird nur berichtet (Heuristiken mit möglichen Fehlalarmen) """ import re +from collections import Counter from typing import Iterable, List, Set from core.model_schema import ( @@ -205,6 +206,56 @@ 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 074165d..b80e33c 100644 --- a/core/llm.py +++ b/core/llm.py @@ -14,10 +14,14 @@ import instructor from core.model_schema import CallRecord, LLMUsage -DEFAULT_MODEL = os.environ.get("LLM_MODEL", "deepseek/deepseek-v4.1-flash") +# 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") # Modell je Pipeline-Stufe: ein starkes Modell für Extraktion/Prüfung, ein schnelles für den Solver-Code # (oder umgekehrt, je nach Messung). Leer = das Modell der Session (LLM_MODEL bzw. --model). STEP_MODEL_ENV = { + "Verstehen": "LLM_MODEL_UNDERSTANDING", # muss Bilder lesen können, wenn die Aufgabe Abbildungen hat + "Abbildungen": "LLM_MODEL_UNDERSTANDING", "Extraktion": "LLM_MODEL_EXTRACTION", "Prüfung": "LLM_MODEL_REVIEW", "Korrektur": "LLM_MODEL_REVISION", diff --git a/core/model_schema.py b/core/model_schema.py index 14b4b68..5ecf0d9 100644 --- a/core/model_schema.py +++ b/core/model_schema.py @@ -118,6 +118,32 @@ class IgnoredNumber(BaseModel): "'Teil der Einheit (in 1000 Stunden)', 'Seitenzahl'") +class FigureInput(BaseModel): + """Eine Abbildung der Aufgabe (eingebettet als Base64 oder als Bilddatei) – Bilddaten, kein LLM-Feld.""" + id: str + alt: str = "" + mime: str + data: str = Field(description="Bildinhalt als Base64") + source: str = Field(default="eingebettet", description="'eingebettet' oder der Pfad der Bilddatei") + + +class FigureTranscription(BaseModel): + """Eine Abbildung, vollständig in Text übertragen – danach arbeitet die Pipeline nur noch mit diesem Text.""" + id: str = Field(description="Kennung wie im Text, z. B. 'Abbildung 1'") + shows: str = Field(description="Was die Abbildung zeigt, in einem Satz (z. B. 'gerichteter Graph mit 4 Knoten')") + transcription: str = Field( + description="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( @@ -129,6 +155,9 @@ class TaskUnderstanding(BaseModel): facts: List[Fact] = Field(description="Alle Zahlenwerte des Textes, die Eingabegrößen des Modells sind") ignored_numbers: List[IgnoredNumber] = Field( default_factory=list, description="Zahlen im Text, die keine Eingabegrößen sind, mit Begründung") + figures: List[FigureTranscription] = Field( + default_factory=list, + description="Nur wenn der Aufgabe Abbildungen beiliegen: je Abbildung eine vollständige Übertragung; sonst leer") class ObjectiveDefinition(BaseModel): @@ -430,6 +459,8 @@ class LLMUsage(BaseModel): class PipelineResult(BaseModel): task: str = "" + figures: List[FigureInput] = Field( + default_factory=list, description="Abbildungen der Aufgabe; ihre Übertragung steht im Aufgabentext (task)") data_files: List[DataFileProfile] = Field( default_factory=list, description="Profile der beigelegten Datendateien (--daten)") understanding: Optional[TaskUnderstanding] = Field( diff --git a/reporting/report.py b/reporting/report.py index 507f136..912d577 100644 --- a/reporting/report.py +++ b/reporting/report.py @@ -301,6 +301,28 @@ 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) @@ -311,7 +333,7 @@ def _rejected_document(result: PipelineResult) -> list: doc.append(("list", [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}", f"Modell: {result.usage.model}", f"LLM: {result.usage.calls} Aufrufe{cost}"])) if result.task.strip(): - doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result) return doc @@ -342,7 +364,7 @@ def build_document(result: PipelineResult, model: str = "") -> list: doc += _result_section(result) if result.task.strip(): - doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result) if result.data_files: doc += [("h2", "Beigelegte Daten")] @@ -468,6 +490,8 @@ def to_markdown(doc: list) -> str: out.append(f"**{_md_inline(block[1])}**") elif kind == "svg": pass # Diagramme nur im HTML-Report + elif kind == "img": + out.append(f"![{block[2]}]({block[1]})") elif kind == "list": out.append("\n".join(f"- {_md_inline(item)}" for item in block[1])) elif kind == "math": @@ -591,6 +615,9 @@ def to_html(doc: list) -> str: out.append(f'
{_html_inline(block[1])}
') elif kind == "svg": out.append(f'