feat: Aufgaben mit Abbildungen (docling-Markdown), mit Gegenprobe beim Ablesen
Eingebettete Base64-Bilder und Bilddateien werden aus dem Aufgabentext gelöst und nur der Verstehensstufe als Bild übergeben; sie überträgt jede Abbildung in Text. Eine zweite, unabhängige Lesung wird Zahl für Zahl verglichen (bei Abweichung entscheidet eine dritte, sonst reliable=False). Alle weiteren Stufen rechnen mit der Übertragung, der Report zeigt Original und Übertragung nebeneinander. LLM_MODEL_UNDERSTANDING wählt das Bildmodell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
915c79c1c8
commit
b07dd16143
15 changed files with 484 additions and 36 deletions
|
|
@ -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. `deepseek/deepseek-v4.1-flash` kann das; sonst ein Bildmodell über `LLM_MODEL_UNDERSTANDING` wählen (z. B. `qwen/qwen3.8-flash`).
|
||||
- Höchstens 6 Abbildungen, je 5 MB.
|
||||
- Mehrkosten: ein bis zwei zusätzliche kleine Aufrufe für die Gegenprobe.
|
||||
- Verweise auf Webadressen werden nicht geladen (das Programm lädt nichts aus dem Internet).
|
||||
|
||||
---
|
||||
|
||||
## 9. Weiterarbeiten ohne neue LLM-Kosten
|
||||
|
||||
### Andere Zahlen, gleiches Modell: `--resolve`
|
||||
|
|
@ -400,6 +430,7 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav
|
|||
| `OPENROUTER_API_KEY` | Schlüssel für OpenRouter | – |
|
||||
| `GEMINI_API_KEY` | Schlüssel für Gemini | – |
|
||||
| `LLM_MODEL` | Modell, falls `--model` fehlt | `deepseek/deepseek-v4.1-flash` |
|
||||
| `LLM_MODEL_UNDERSTANDING` | Modell für Verstehen und Abbildungs-Gegenprobe (muss bei Abbildungen Bilder lesen können) | Session-Modell |
|
||||
| `LLM_MODEL_EXTRACTION` | Modell nur für die Extraktion | Session-Modell |
|
||||
| `LLM_MODEL_REVIEW` | Modell nur für die Prüfung | Session-Modell |
|
||||
| `LLM_MODEL_REVISION` | Modell nur für die Korrektur | Session-Modell |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import sys
|
|||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from checks.model_checks import check_spec, check_understanding, has_errors
|
||||
from checks.model_checks import check_figure_readings, check_spec, check_understanding, has_errors
|
||||
from checks.review import needs_work, review_assumptions, revision_reasons, select_best
|
||||
from core.llm import DEFAULT_MODEL, LLMSession
|
||||
from core.model_schema import (
|
||||
|
|
@ -22,7 +22,8 @@ from modeling.extraction import revise_model, text_to_model
|
|||
from reporting.report import write_report
|
||||
from solving.script_export import build_solver_script
|
||||
from solving.solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve
|
||||
from understanding.understand import understand_task
|
||||
from understanding.figures import FigureError, split_figures, with_transcriptions
|
||||
from understanding.understand import read_figures, understand_task
|
||||
|
||||
MAX_REVISIONS = int(os.environ.get("MAX_REVISIONS", "2"))
|
||||
SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2"))
|
||||
|
|
@ -47,19 +48,19 @@ def log(msg: str) -> None:
|
|||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple:
|
||||
def _understand(session, task: str, label: str, data_prompt: str = "", figures=()) -> tuple:
|
||||
"""Verstehensstufe samt Prüfung; ein Nachfassen, wenn Zahlen oder Teilfragen fehlen."""
|
||||
if UNDERSTAND_RETRIES < 0:
|
||||
return None, []
|
||||
log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen) ...")
|
||||
understanding = understand_task(session, task, data_prompt=data_prompt)
|
||||
log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen" + (", Abbildungen" if figures else "") + ") ...")
|
||||
understanding = understand_task(session, task, data_prompt=data_prompt, figures=figures)
|
||||
issues = check_understanding(understanding, task)
|
||||
for versuch in range(UNDERSTAND_RETRIES):
|
||||
if not issues or not understanding.is_optimization_task:
|
||||
break # eine Nicht-Aufgabe wird abgelehnt, nicht vervollständigt
|
||||
log(f"[{label}] Verständnis unvollständig – fasse nach ({versuch + 1}/{UNDERSTAND_RETRIES}): "
|
||||
+ "; ".join(i.code for i in issues))
|
||||
understanding = understand_task(session, task, issues, data_prompt)
|
||||
understanding = understand_task(session, task, issues, data_prompt, figures=figures)
|
||||
issues = check_understanding(understanding, task)
|
||||
if understanding.sub_questions:
|
||||
log(f" {len(understanding.sub_questions)} Teilfrage(n): "
|
||||
|
|
@ -68,6 +69,26 @@ def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple:
|
|||
return understanding, issues
|
||||
|
||||
|
||||
def _checked_figure_readings(session, task: str, figures, first, label: str) -> tuple:
|
||||
"""Gegenprobe der Abbildungen: zweite, unabhängige Lesung; bei Abweichung entscheidet eine dritte.
|
||||
|
||||
Stimmen zwei von drei Lesungen Zahl für Zahl überein, gilt diese Übertragung. Sonst bleibt die erste,
|
||||
und der Befund macht das Ergebnis unzuverlässig – die Daten der Aufgabe stünden auf ungeprüftem Ablesen.
|
||||
"""
|
||||
log(f"[{label}] Lese {len(figures)} Abbildung(en) ein zweites Mal (Gegenprobe) ...")
|
||||
zweite = read_figures(session, task, figures).figures
|
||||
befunde = check_figure_readings(first, zweite)
|
||||
if not befunde:
|
||||
return first, []
|
||||
log(f"[{label}] Abbildungen uneinheitlich gelesen – dritte Lesung: " + "; ".join(i.message for i in befunde)[:300])
|
||||
dritte = read_figures(session, task, figures).figures
|
||||
if not check_figure_readings(first, dritte):
|
||||
return first, []
|
||||
if not check_figure_readings(zweite, dritte):
|
||||
return dritte, []
|
||||
return first, befunde
|
||||
|
||||
|
||||
def _model_attempt(session, task: str, max_revisions: int, label: str, hint: str = "",
|
||||
spec: OptimizationModelSpecification | None = None,
|
||||
understanding: TaskUnderstanding | None = None, data_prompt: str = "") -> dict:
|
||||
|
|
@ -217,9 +238,13 @@ def _log_usage(session, result: PipelineResult) -> None:
|
|||
def run_pipeline(
|
||||
task: str, model: str = DEFAULT_MODEL, max_revisions: int = MAX_REVISIONS,
|
||||
solve_model: bool = True, solver_retries: int = SOLVER_RETRIES, max_restarts: int = MAX_RESTARTS,
|
||||
session: LLMSession | None = None, data_files=(),
|
||||
session: LLMSession | None = None, data_files=(), task_dir=None,
|
||||
) -> PipelineResult:
|
||||
session = session or LLMSession(model)
|
||||
# Abbildungen (docling: Base64 oder Bilddatei) gehen nur an die Verstehensstufe; ab dann zählt ihre Übertragung
|
||||
task, figures = split_figures(task, task_dir)
|
||||
if figures:
|
||||
log(f"{len(figures)} Abbildung(en) in der Aufgabe – werden in der Verstehensstufe in Text übertragen.")
|
||||
profile = data_profile.profile_files(data_files)
|
||||
data_prompt = data_profile.to_prompt(profile)
|
||||
for p in profile:
|
||||
|
|
@ -227,12 +252,21 @@ def run_pipeline(
|
|||
for step, step_model in session.overrides().items():
|
||||
log(f"[{model}] Stufe '{step}' läuft mit abweichendem Modell: {step_model}")
|
||||
|
||||
understanding, verstehen_issues = _understand(session, task, model, data_prompt)
|
||||
understanding, verstehen_issues = _understand(session, task, model, data_prompt, figures)
|
||||
if figures and understanding is not None:
|
||||
if understanding.is_optimization_task:
|
||||
understanding.figures, figur_befunde = _checked_figure_readings(
|
||||
session, task, figures, understanding.figures, model)
|
||||
verstehen_issues = verstehen_issues + figur_befunde
|
||||
for figur in understanding.figures:
|
||||
if figur.uncertain:
|
||||
log(f" {figur.id} – unsicher gelesen: " + "; ".join(figur.uncertain))
|
||||
task = with_transcriptions(task, understanding.figures)
|
||||
if not understanding.is_optimization_task:
|
||||
# Keine Entscheidung verlangt: jedes Modell wäre erfunden, jede Zahl ohne Frage dahinter
|
||||
grund = f"Keine Optimierungsaufgabe: {understanding.reason}"
|
||||
log(f"ABGELEHNT – {grund} Es wird weder modelliert noch gerechnet.")
|
||||
result = PipelineResult(task=task, understanding=understanding, data_files=profile, rejected=True,
|
||||
result = PipelineResult(task=task, figures=figures, understanding=understanding, data_files=profile, rejected=True,
|
||||
reliable=False, reliability_issues=[grund], checks=verstehen_issues)
|
||||
_log_usage(session, result)
|
||||
return result
|
||||
|
|
@ -249,7 +283,8 @@ def run_pipeline(
|
|||
|
||||
best = attempts[select_best([(a["spec"], a["issues"], a["review"]) for a in attempts])]
|
||||
spec, issues, review = best["spec"], best["issues"] + verstehen_issues, best["review"]
|
||||
problems = _unresolved(best)
|
||||
problems = _unresolved(best) + [f"Abbildung: {i.message}" for i in verstehen_issues
|
||||
if i.code == "FIGURE_READINGS_DIFFER"]
|
||||
|
||||
for issue in issues:
|
||||
log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}")
|
||||
|
|
@ -261,7 +296,7 @@ def run_pipeline(
|
|||
log("Prüfung ohne Beanstandung.")
|
||||
|
||||
result = PipelineResult(
|
||||
task=task, understanding=understanding, data_files=profile, specification=spec, review=review, revisions=best["revisions"],
|
||||
task=task, figures=figures, understanding=understanding, data_files=profile, specification=spec, review=review, revisions=best["revisions"],
|
||||
selected_revision=best["selected"], restarts=len(attempts) - 1, checks=issues,
|
||||
reliable=not problems, reliability_issues=problems,
|
||||
)
|
||||
|
|
@ -402,11 +437,14 @@ if __name__ == "__main__":
|
|||
elif args.von:
|
||||
result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions)
|
||||
else:
|
||||
result = run_pipeline(
|
||||
read_task(args.task_file), model=args.model,
|
||||
max_revisions=args.max_revisions, solve_model=not args.no_solve, max_restarts=args.max_restarts,
|
||||
data_files=args.daten or (),
|
||||
)
|
||||
try:
|
||||
result = run_pipeline(
|
||||
read_task(args.task_file), model=args.model,
|
||||
max_revisions=args.max_revisions, solve_model=not args.no_solve, max_restarts=args.max_restarts,
|
||||
data_files=args.daten or (), task_dir=Path(args.task_file).parent if args.task_file else None,
|
||||
)
|
||||
except FigureError as exc:
|
||||
parser.error(str(exc))
|
||||
print(result.model_dump_json(indent=2))
|
||||
if args.report:
|
||||
write_report(result, args.report)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ error -> löst in run_pipeline eine Korrekturrunde aus
|
|||
warning -> wird nur berichtet (Heuristiken mit möglichen Fehlalarmen)
|
||||
"""
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Iterable, List, Set
|
||||
|
||||
from core.model_schema import (
|
||||
|
|
@ -205,6 +206,44 @@ def check_understanding(understanding, task: str) -> List[CheckIssue]:
|
|||
return issues
|
||||
|
||||
|
||||
def _numbers_multiset(text: str) -> Counter:
|
||||
"""Zahlen eines Textes mit Häufigkeit – eine Kante mit Bewertung 3 zweimal ist nicht dasselbe wie einmal."""
|
||||
zaehler = Counter()
|
||||
for roh in NUMBER_PATTERN.findall(text or ""):
|
||||
try:
|
||||
zaehler[normalize_number(roh)] += 1
|
||||
except ValueError:
|
||||
continue
|
||||
return zaehler
|
||||
|
||||
|
||||
def check_figure_readings(first, second) -> List[CheckIssue]:
|
||||
"""Zwei unabhängige Übertragungen derselben Abbildungen vergleichen – Zahl für Zahl, ohne LLM.
|
||||
|
||||
Ein Bildmodell kann eine Pfeilbewertung dem falschen Pfeil zuordnen oder eine Ziffer verlesen; eine einzige
|
||||
Lesung wäre dann ungeprüft die Datengrundlage jeder weiteren Stufe. Verglichen werden die Zahlen je Abbildung
|
||||
samt Häufigkeit. Richtungsfehler bei gleichen Zahlen fängt das nicht – dafür steht das Original im Report.
|
||||
"""
|
||||
issues = []
|
||||
erste = {f.id: f for f in first}
|
||||
zweite = {f.id: f for f in second}
|
||||
for kennung in sorted(set(erste) | set(zweite)):
|
||||
if kennung not in erste or kennung not in zweite:
|
||||
issues.append(CheckIssue(severity=CheckSeverity.ERROR, code="FIGURE_READINGS_DIFFER",
|
||||
message=f"{kennung} wurde nur in einer von zwei Lesungen übertragen."))
|
||||
continue
|
||||
a = _numbers_multiset(erste[kennung].transcription)
|
||||
b = _numbers_multiset(zweite[kennung].transcription)
|
||||
if a != b:
|
||||
nur_a = sorted((a - b).elements(), key=float)
|
||||
nur_b = sorted((b - a).elements(), key=float)
|
||||
issues.append(CheckIssue(
|
||||
severity=CheckSeverity.ERROR, code="FIGURE_READINGS_DIFFER",
|
||||
message=f"{kennung} uneinheitlich gelesen – nur in Lesung 1: {', '.join(nur_a) or '–'}; "
|
||||
f"nur in Lesung 2: {', '.join(nur_b) or '–'}."))
|
||||
return issues
|
||||
|
||||
|
||||
def _strip_scripts(text: str) -> str:
|
||||
"""Entfernt Indizes/Exponenten: x_{i,j} -> x, C^{prod}_{p} -> C, x_1 -> x."""
|
||||
text = text.replace(r"\_", "_")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ DEFAULT_MODEL = os.environ.get("LLM_MODEL", "deepseek/deepseek-v4.1-flash")
|
|||
# Modell je Pipeline-Stufe: ein starkes Modell für Extraktion/Prüfung, ein schnelles für den Solver-Code
|
||||
# (oder umgekehrt, je nach Messung). Leer = das Modell der Session (LLM_MODEL bzw. --model).
|
||||
STEP_MODEL_ENV = {
|
||||
"Verstehen": "LLM_MODEL_UNDERSTANDING", # muss Bilder lesen können, wenn die Aufgabe Abbildungen hat
|
||||
"Abbildungen": "LLM_MODEL_UNDERSTANDING",
|
||||
"Extraktion": "LLM_MODEL_EXTRACTION",
|
||||
"Prüfung": "LLM_MODEL_REVIEW",
|
||||
"Korrektur": "LLM_MODEL_REVISION",
|
||||
|
|
|
|||
|
|
@ -118,6 +118,32 @@ class IgnoredNumber(BaseModel):
|
|||
"'Teil der Einheit (in 1000 Stunden)', 'Seitenzahl'")
|
||||
|
||||
|
||||
class FigureInput(BaseModel):
|
||||
"""Eine Abbildung der Aufgabe (eingebettet als Base64 oder als Bilddatei) – Bilddaten, kein LLM-Feld."""
|
||||
id: str
|
||||
alt: str = ""
|
||||
mime: str
|
||||
data: str = Field(description="Bildinhalt als Base64")
|
||||
source: str = Field(default="eingebettet", description="'eingebettet' oder der Pfad der Bilddatei")
|
||||
|
||||
|
||||
class FigureTranscription(BaseModel):
|
||||
"""Eine Abbildung, vollständig in Text übertragen – danach arbeitet die Pipeline nur noch mit diesem Text."""
|
||||
id: str = Field(description="Kennung wie im Text, z. B. 'Abbildung 1'")
|
||||
shows: str = Field(description="Was die Abbildung zeigt, in einem Satz (z. B. 'gerichteter Graph mit 4 Knoten')")
|
||||
transcription: str = Field(
|
||||
description="Vollständige Übertragung ALLER Inhalte: jede Zahl, jede Beschriftung, jede Pfeilrichtung, "
|
||||
"am besten als Tabelle oder Liste (z. B. 'Pfeil 1→2: 3'). Nichts ergänzen, was nicht zu sehen ist.")
|
||||
uncertain: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Stellen, die nicht sicher lesbar sind (verdeckt, unscharf, mehrdeutig); leer = alles sicher gelesen")
|
||||
|
||||
|
||||
class FigureReading(BaseModel):
|
||||
"""Unabhängige zweite Übertragung der Abbildungen – Gegenprobe zur Verstehensstufe."""
|
||||
figures: List[FigureTranscription]
|
||||
|
||||
|
||||
class TaskUnderstanding(BaseModel):
|
||||
"""Was in der Aufgabe steht – vor jeder Modellierung: Fragen, Zahlen, Machbarkeit."""
|
||||
is_optimization_task: bool = Field(
|
||||
|
|
@ -129,6 +155,9 @@ class TaskUnderstanding(BaseModel):
|
|||
facts: List[Fact] = Field(description="Alle Zahlenwerte des Textes, die Eingabegrößen des Modells sind")
|
||||
ignored_numbers: List[IgnoredNumber] = Field(
|
||||
default_factory=list, description="Zahlen im Text, die keine Eingabegrößen sind, mit Begründung")
|
||||
figures: List[FigureTranscription] = Field(
|
||||
default_factory=list,
|
||||
description="Nur wenn der Aufgabe Abbildungen beiliegen: je Abbildung eine vollständige Übertragung; sonst leer")
|
||||
|
||||
|
||||
class ObjectiveDefinition(BaseModel):
|
||||
|
|
@ -430,6 +459,8 @@ class LLMUsage(BaseModel):
|
|||
|
||||
class PipelineResult(BaseModel):
|
||||
task: str = ""
|
||||
figures: List[FigureInput] = Field(
|
||||
default_factory=list, description="Abbildungen der Aufgabe; ihre Übertragung steht im Aufgabentext (task)")
|
||||
data_files: List[DataFileProfile] = Field(
|
||||
default_factory=list, description="Profile der beigelegten Datendateien (--daten)")
|
||||
understanding: Optional[TaskUnderstanding] = Field(
|
||||
|
|
|
|||
|
|
@ -301,6 +301,27 @@ def _fmt_number(value) -> str:
|
|||
return str(value)
|
||||
|
||||
|
||||
def _figure_blocks(result: PipelineResult) -> list:
|
||||
"""Abbildungen der Aufgabe: Original neben der Übertragung, mit der die Pipeline gerechnet hat."""
|
||||
if not result.figures:
|
||||
return []
|
||||
uebertragen = {f.id: f for f in (result.understanding.figures if result.understanding else [])}
|
||||
doc = [("h3", "Abbildungen"),
|
||||
("p", "Die Pipeline rechnet nicht mit dem Bild, sondern mit seiner Übertragung in Text. "
|
||||
"Bitte Original und Übertragung vergleichen – ein verlesener Wert ändert das ganze Ergebnis.")]
|
||||
for figur in result.figures:
|
||||
doc.append(("h4", figur.id + (f" – {figur.alt}" if figur.alt and figur.alt.lower() != "image" else "")))
|
||||
doc.append(("img", f"data:{figur.mime};base64,{figur.data}", figur.id))
|
||||
t = uebertragen.get(figur.id)
|
||||
if t is None:
|
||||
doc.append(("warn", f"{figur.id} wurde nicht übertragen – ihre Inhalte fehlen im Modell."))
|
||||
continue
|
||||
doc += [("p", f"Übertragung: {t.shows}"), ("pre", t.transcription.strip())]
|
||||
if t.uncertain:
|
||||
doc.append(("warn", f"{figur.id} – unsicher gelesen: " + "; ".join(t.uncertain)))
|
||||
return doc
|
||||
|
||||
|
||||
def _rejected_document(result: PipelineResult) -> list:
|
||||
"""Abgelehnte Aufgabe: Grund und Aufgabentext – kein Modell, keine Zahlen."""
|
||||
grund = result.understanding.reason if result.understanding else "; ".join(result.reliability_issues)
|
||||
|
|
@ -311,7 +332,7 @@ def _rejected_document(result: PipelineResult) -> list:
|
|||
doc.append(("list", [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}", f"Modell: {result.usage.model}",
|
||||
f"LLM: {result.usage.calls} Aufrufe{cost}"]))
|
||||
if result.task.strip():
|
||||
doc += [("h2", "Aufgabe"), ("pre", result.task.strip())]
|
||||
doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result)
|
||||
return doc
|
||||
|
||||
|
||||
|
|
@ -342,7 +363,7 @@ def build_document(result: PipelineResult, model: str = "") -> list:
|
|||
doc += _result_section(result)
|
||||
|
||||
if result.task.strip():
|
||||
doc += [("h2", "Aufgabe"), ("pre", result.task.strip())]
|
||||
doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result)
|
||||
|
||||
if result.data_files:
|
||||
doc += [("h2", "Beigelegte Daten")]
|
||||
|
|
@ -468,6 +489,8 @@ def to_markdown(doc: list) -> str:
|
|||
out.append(f"**{_md_inline(block[1])}**")
|
||||
elif kind == "svg":
|
||||
pass # Diagramme nur im HTML-Report
|
||||
elif kind == "img":
|
||||
out.append(f"![{block[2]}]({block[1]})")
|
||||
elif kind == "list":
|
||||
out.append("\n".join(f"- {_md_inline(item)}" for item in block[1]))
|
||||
elif kind == "math":
|
||||
|
|
@ -591,6 +614,9 @@ def to_html(doc: list) -> str:
|
|||
out.append(f'<p class="lead">{_html_inline(block[1])}</p>')
|
||||
elif kind == "svg":
|
||||
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":
|
||||
out.append("<ul>" + "".join(f"<li>{_html_inline(item)}</li>" for item in block[1]) + "</ul>")
|
||||
elif kind == "math":
|
||||
|
|
|
|||
141
tests/test_figures.py
Normal file
141
tests/test_figures.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Aufgaben mit Abbildungen (docling-Markdown): abtrennen, als Bild an die Verstehensstufe, doppelt ablesen."""
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from checks.model_checks import check_figure_readings, numbers_in
|
||||
from core.model_schema import (FigureReading, FigureTranscription, SolverCode, SubQuestion, TaskUnderstanding)
|
||||
from reporting.report import build_document, to_html, to_markdown
|
||||
from TextToModel import run_pipeline
|
||||
from understanding.figures import MAX_FIGURES, FigureError, split_figures, with_transcriptions
|
||||
from understanding.understand import understand_task
|
||||
|
||||
# 1×1-PNG – Inhalt egal, es geht um den Weg durch die Pipeline
|
||||
PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
AUFGABE = f"Gesucht sind die minimalen Fahrtkosten von Knoten 1 nach Knoten 4.\n\n\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  und  und ", tmp_path)
|
||||
assert "[Abbildung 1: Netz]" in text
|
||||
assert "" 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"" 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("")
|
||||
|
||||
|
||||
def test_task_without_images_is_unchanged():
|
||||
assert split_figures("Minimiere 3x + 2y.") == ("Minimiere 3x + 2y.", [])
|
||||
|
||||
|
||||
def test_transcription_is_appended_with_uncertain_parts():
|
||||
unsicher = GRAPH.model_copy(update={"uncertain": ["Richtung des Pfeils zwischen 2 und 3"]})
|
||||
text = with_transcriptions("Aufgabe [Abbildung 1]", [unsicher])
|
||||
assert "Pfeil 3 → 4: 5" in text and "Unsicher gelesen: Richtung des Pfeils" in text
|
||||
|
||||
|
||||
# --- Nachricht an das Bildmodell ---
|
||||
|
||||
class Aufzeichner:
|
||||
def __init__(self, antwort): self.antwort, self.nachrichten = antwort, []
|
||||
def create(self, response_model, messages, step="unbenannt"):
|
||||
self.nachrichten.append((step, messages)); return self.antwort
|
||||
|
||||
|
||||
def test_understanding_sends_images_as_image_parts():
|
||||
text, figuren = split_figures(AUFGABE)
|
||||
session = Aufzeichner("ok")
|
||||
understand_task(session, text, figures=figuren)
|
||||
inhalt = session.nachrichten[0][1][-1]["content"]
|
||||
assert isinstance(inhalt, list) and inhalt[0]["type"] == "text" and "1 ABBILDUNG" in inhalt[0]["text"]
|
||||
assert inhalt[-1] == {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{PNG}"}}
|
||||
|
||||
|
||||
def test_understanding_without_images_stays_plain_text():
|
||||
session = Aufzeichner("ok")
|
||||
understand_task(session, "Minimiere 3x.")
|
||||
assert isinstance(session.nachrichten[0][1][-1]["content"], str)
|
||||
|
||||
|
||||
# --- Doppelt ablesen ---
|
||||
|
||||
def test_identical_readings_have_no_finding():
|
||||
assert check_figure_readings([GRAPH], [GRAPH.model_copy()]) == []
|
||||
|
||||
|
||||
def test_misread_value_is_found_with_both_numbers():
|
||||
befunde = check_figure_readings([GRAPH], [VERLESEN])
|
||||
assert len(befunde) == 1 and befunde[0].code == "FIGURE_READINGS_DIFFER"
|
||||
assert "nur in Lesung 1: 3" in befunde[0].message and "nur in Lesung 2: 5" in befunde[0].message
|
||||
|
||||
|
||||
def test_figure_missing_in_one_reading_is_found():
|
||||
assert "nur in einer" in check_figure_readings([GRAPH], [])[0].message
|
||||
|
||||
|
||||
# --- Durch die ganze Pipeline (ohne LLM) ---
|
||||
|
||||
def _verstaendnis(figur):
|
||||
return TaskUnderstanding(is_optimization_task=True, reason="-", facts=[], ignored_numbers=[],
|
||||
sub_questions=[SubQuestion(id="1", quote="-", kind="rechnen", needs_solver=True)],
|
||||
figures=[figur])
|
||||
|
||||
|
||||
def _lauf(erste, lesungen):
|
||||
from test_pipeline import GOOD, OK, FakeSession
|
||||
from test_script_export import STRUCTURED_CODE
|
||||
session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[_verstaendnis(erste)],
|
||||
readings=[FigureReading(figures=[f]) for f in lesungen])
|
||||
return run_pipeline(AUFGABE, session=session, max_revisions=0, max_restarts=0), session
|
||||
|
||||
|
||||
def test_only_the_understanding_sees_the_image_later_stages_get_the_transcription():
|
||||
result, session = _lauf(GRAPH, [GRAPH])
|
||||
bilder = [schritt for schritt, inhalt in session.contents if isinstance(inhalt, list)]
|
||||
assert set(bilder) == {"Verstehen", "Abbildungen"}
|
||||
spaeter = [inhalt for schritt, inhalt in session.contents if schritt not in ("Verstehen", "Abbildungen")]
|
||||
assert spaeter and all("data:image" not in str(i) for i in spaeter)
|
||||
assert any("Pfeil 3 → 4: 5" in str(i) for i in spaeter)
|
||||
assert result.reliable and len(result.figures) == 1 and "Pfeil 2 → 4: 3" in result.task
|
||||
|
||||
|
||||
def test_two_of_three_agreeing_readings_win():
|
||||
result, _ = _lauf(VERLESEN, [GRAPH, GRAPH]) # erste Lesung verlesen, zweite und dritte stimmen überein
|
||||
assert result.reliable and "Pfeil 2 → 4: 3" in result.task
|
||||
|
||||
|
||||
def test_persistently_differing_readings_make_the_result_unreliable():
|
||||
dritte = GRAPH.model_copy(update={"transcription": GRAPH.transcription.replace("1 → 3: 1", "1 → 3: 7")})
|
||||
result, _ = _lauf(GRAPH, [VERLESEN, dritte])
|
||||
assert not result.reliable
|
||||
assert any(p.startswith("Abbildung:") and "uneinheitlich" in p for p in result.reliability_issues)
|
||||
|
||||
|
||||
def test_report_shows_original_and_transcription():
|
||||
result, _ = _lauf(GRAPH.model_copy(update={"uncertain": ["Pfeil 3 → 2 schwer lesbar"]}), [GRAPH])
|
||||
html = to_html(build_document(result))
|
||||
assert f'<img src="data:image/png;base64,{PNG}"' in html
|
||||
assert "Pfeil 1 → 3: 1" in html and "unsicher gelesen" in html
|
||||
assert f"" in to_markdown(build_document(result))
|
||||
|
|
@ -157,3 +157,9 @@ def test_old_results_without_cache_fields_still_load():
|
|||
"reasoning_tokens": 0, "call_records": [{"step": "Verstehen", "seconds": 1.0, "completion_tokens": 5}]}
|
||||
usage = LLMUsage.model_validate(alt)
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -24,13 +24,16 @@ EINFACHES_VERSTAENDNIS = TaskUnderstanding(
|
|||
class FakeSession:
|
||||
"""Liefert vorgegebene Antworten je Antwort-Typ in fester Reihenfolge."""
|
||||
|
||||
def __init__(self, specs, reviews, codes=(), understandings=None):
|
||||
def __init__(self, specs, reviews, codes=(), understandings=None, readings=()):
|
||||
from core.model_schema import FigureReading
|
||||
self.queues = {
|
||||
OptimizationModelSpecification: list(specs),
|
||||
AssumptionReview: list(reviews),
|
||||
SolverCode: list(codes),
|
||||
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.calls = {kind: 0 for kind in self.queues}
|
||||
self.steps = []
|
||||
|
|
@ -42,6 +45,7 @@ class FakeSession:
|
|||
|
||||
def create(self, response_model, messages, step="unbenannt"):
|
||||
self.letzter_prompt = messages[-1]["content"]
|
||||
self.contents.append((step, messages[-1]["content"]))
|
||||
if step == "Extraktion":
|
||||
self.tasks.append(messages[-1]["content"])
|
||||
self.calls[response_model] += 1
|
||||
|
|
|
|||
11
understanding/Abbildungs-Prompt.txt
Normal file
11
understanding/Abbildungs-Prompt.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
Du überträgst Abbildungen aus einer Aufgabe aus Operations Research oder Wirtschaftsmathematik vollständig in Text. Du löst nichts und modellierst nichts.
|
||||
|
||||
Deine Übertragung wird ohne LLM mit einer zweiten, unabhängigen Übertragung derselben Abbildung verglichen – Zahl für Zahl. Weicht sie ab, gilt das Ergebnis als unzuverlässig. Genauigkeit geht deshalb vor Kürze. [→ check_figure_readings]
|
||||
|
||||
Für jede Abbildung, in der Reihenfolge ihrer Kennung („Abbildung 1“, „Abbildung 2“, …), ein Eintrag in figures:
|
||||
- id: die Kennung genau wie angegeben.
|
||||
- shows: was die Abbildung zeigt, in einem Satz (z. B. „gerichteter Graph mit 4 Knoten und 5 bewerteten Pfeilen“).
|
||||
- transcription: ALLE Inhalte – jede Zahl, jede Beschriftung, bei Graphen jeden Pfeil mit Start, Ziel und Bewertung (eine Zeile je Pfeil, z. B. „Pfeil 1 → 2: 3“), bei Tabellen jede Zelle, bei Diagrammen Achsen, Einheiten und abgelesene Werte. Achte bei Pfeilen genau auf die Richtung (wo sitzt die Spitze?).
|
||||
- uncertain: jede Stelle, die du nicht sicher lesen kannst (verdeckt, unscharf, mehrdeutige Richtung oder Zuordnung einer Zahl zu einem Pfeil). Leer nur, wenn alles eindeutig ist.
|
||||
|
||||
Ergänze nichts, was nicht zu sehen ist, und rechne nichts aus.
|
||||
|
|
@ -24,4 +24,6 @@ GEMEINSAMER RAHMEN (gilt für alle vier Stufen dieser Pipeline: Verstehen, Model
|
|||
- ignored_numbers ist für Aufgabennummern („3.8“), Zahlen in Einheiten („in 1000 Stunden“), Jahreszahlen, Seitenzahlen, Nummern von Sätzen. Immer mit Grund.
|
||||
Eine Zahl, die weder im einen noch im anderen steht, gilt als übersehen. [→ UNCAPTURED_NUMBERS]
|
||||
|
||||
4. Erfinde nichts. Was der Text nicht sagt, gehört nicht in facts – fehlende Angaben werden später als Annahme ausgewiesen, nicht hier geraten.
|
||||
4. ABBILDUNGEN: Liegen der Aufgabe Abbildungen bei (im Text als „[Abbildung N]“, die Bilder folgen der Nachricht), trage je Abbildung einen Eintrag in figures ein: id genau wie im Text, shows in einem Satz, transcription mit ALLEN Inhalten (jede Zahl, jede Beschriftung, bei Graphen jeder Pfeil mit Start, Ziel und Bewertung – achte auf die Richtung der Pfeilspitze; bei Tabellen jede Zelle), uncertain für alles, was nicht sicher lesbar ist. Die Zahlen der Abbildung gehören außerdem in facts, mit quote „Abbildung N: …“. Eine zweite, unabhängige Übertragung wird Zahl für Zahl mit deiner verglichen. [→ check_figure_readings]
|
||||
|
||||
5. Erfinde nichts. Was der Text nicht sagt, gehört nicht in facts – fehlende Angaben werden später als Annahme ausgewiesen, nicht hier geraten.
|
||||
|
|
|
|||
87
understanding/figures.py
Normal file
87
understanding/figures.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Abbildungen in Aufgabentexten: vom Text trennen, als Bild an die Verstehensstufe geben, Übertragung einsetzen.
|
||||
|
||||
docling exportiert Abbildungen als `` (ImageRefMode.EMBEDDED) oder als Verweis
|
||||
auf eine Bilddatei (REFERENCED). Beides bliebe sonst Zeichensalat im Text jeder Stufe: kein Modell sähe den Graphen,
|
||||
jeder Aufruf bezahlte das Base64 erneut, und die Zahlenprüfung liefe über Base64-Zeichen. Deshalb bekommt nur die
|
||||
Verstehensstufe das Bild; ab dann arbeitet die Pipeline mit der Übertragung in Text.
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
from core.model_schema import FigureInput, FigureTranscription
|
||||
|
||||
MAX_FIGURES = 6
|
||||
MAX_BYTES = 5 * 1024 * 1024
|
||||
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"}
|
||||
#  oder ; Base64 darf Zeilenumbrüche enthalten
|
||||
IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(\s*(?:data:(image/[\w.+-]+);base64,([A-Za-z0-9+/=\s]+?)|([^)\s]+))\s*\)")
|
||||
# docling schreibt als Alternativtext meist nur „Image“ – der trägt nichts zur Aufgabe bei
|
||||
GENERIC_ALT = {"", "image", "bild", "figure", "abbildung", "picture"}
|
||||
|
||||
|
||||
class FigureError(ValueError):
|
||||
"""Abbildungen, die nicht verarbeitet werden können (zu viele, zu groß, kaputtes Base64)."""
|
||||
|
||||
|
||||
def split_figures(task: str, basis_dir: Path | str | None = None) -> Tuple[str, List[FigureInput]]:
|
||||
"""Abbildungen aus dem Aufgabentext lösen; im Text bleibt ein Platzhalter „[Abbildung N]“.
|
||||
|
||||
Verweise auf Dateien werden relativ zu `basis_dir` (dem Verzeichnis der Aufgabendatei) aufgelöst. Nicht
|
||||
vorhandene Dateien und Verweise ohne Bild-Endung (etwa Webadressen) bleiben unverändert im Text.
|
||||
"""
|
||||
basis = Path(basis_dir) if basis_dir else Path.cwd()
|
||||
figures: List[FigureInput] = []
|
||||
|
||||
def ersetze(treffer: re.Match) -> str:
|
||||
alt, mime, daten, pfad = treffer.group(1).strip(), treffer.group(2), treffer.group(3), treffer.group(4)
|
||||
if daten is not None:
|
||||
daten = re.sub(r"\s", "", daten)
|
||||
try:
|
||||
roh = base64.b64decode(daten, validate=True)
|
||||
except ValueError as exc:
|
||||
raise FigureError(f"Eingebettetes Bild {len(figures) + 1} ist kein gültiges Base64: {exc}") from exc
|
||||
quelle = "eingebettet"
|
||||
else:
|
||||
datei = (basis / pfad) if not Path(pfad).is_absolute() else Path(pfad)
|
||||
mime = IMAGE_TYPES.get(datei.suffix.lower())
|
||||
if mime is None or not datei.is_file():
|
||||
return treffer.group(0)
|
||||
roh = datei.read_bytes()
|
||||
daten = base64.b64encode(roh).decode("ascii")
|
||||
quelle = str(datei)
|
||||
if len(roh) > MAX_BYTES:
|
||||
raise FigureError(f"Abbildung {len(figures) + 1} ist {len(roh) // 1024} KB groß – erlaubt sind "
|
||||
f"{MAX_BYTES // 1024 // 1024} MB.")
|
||||
kennung = f"Abbildung {len(figures) + 1}"
|
||||
figures.append(FigureInput(id=kennung, alt=alt, mime=mime, data=daten, source=quelle))
|
||||
return f"[{kennung}]" if alt.lower() in GENERIC_ALT else f"[{kennung}: {alt}]"
|
||||
|
||||
text = IMAGE_PATTERN.sub(ersetze, task)
|
||||
if len(figures) > MAX_FIGURES:
|
||||
raise FigureError(f"Die Aufgabe enthält {len(figures)} Abbildungen – verarbeitet werden höchstens {MAX_FIGURES}.")
|
||||
return text, figures
|
||||
|
||||
|
||||
def image_parts(figures: List[FigureInput]) -> list:
|
||||
"""Nachrichtenteile im OpenAI-Format: je Abbildung ihre Kennung als Text, dann das Bild als Data-URI."""
|
||||
teile = []
|
||||
for figur in figures:
|
||||
teile.append({"type": "text", "text": f"{figur.id}:"})
|
||||
teile.append({"type": "image_url", "image_url": {"url": f"data:{figur.mime};base64,{figur.data}"}})
|
||||
return teile
|
||||
|
||||
|
||||
def with_transcriptions(text: str, transcriptions: List[FigureTranscription]) -> str:
|
||||
"""Aufgabentext für alle weiteren Stufen: Platzhalter bleiben, die Übertragungen folgen am Ende."""
|
||||
if not transcriptions:
|
||||
return text
|
||||
bloecke = []
|
||||
for t in transcriptions:
|
||||
block = f"[{t.id}] {t.shows}\n{t.transcription.strip()}"
|
||||
if t.uncertain:
|
||||
block += "\nUnsicher gelesen: " + "; ".join(t.uncertain)
|
||||
bloecke.append(block)
|
||||
return (text.rstrip() + "\n\nABBILDUNGEN (aus dem Bild in Text übertragen; das Original liegt dem Report bei):\n\n"
|
||||
+ "\n\n".join(bloecke))
|
||||
|
|
@ -3,24 +3,52 @@ from pathlib import Path
|
|||
from typing import List
|
||||
|
||||
from core.llm import LLMSession
|
||||
from core.model_schema import CheckIssue, TaskUnderstanding
|
||||
from core.model_schema import CheckIssue, FigureInput, FigureReading, TaskUnderstanding
|
||||
from understanding.figures import image_parts
|
||||
|
||||
UNDERSTAND_PROMPT_FILE = Path(__file__).parent / "Verstehen-Prompt.txt"
|
||||
FIGURE_PROMPT_FILE = Path(__file__).parent / "Abbildungs-Prompt.txt"
|
||||
|
||||
|
||||
def _content(text: str, figures: List[FigureInput]):
|
||||
"""Reiner Text – oder, mit Abbildungen, eine Liste aus Text und Bildern (OpenAI-Format)."""
|
||||
if not figures:
|
||||
return text
|
||||
hinweis = (f"\n\nDER AUFGABE LIEGEN {len(figures)} ABBILDUNG(EN) BEI – sie folgen als Bilder; "
|
||||
"übertrage jede vollständig in figures.")
|
||||
return [{"type": "text", "text": text + hinweis}, *image_parts(figures)]
|
||||
|
||||
|
||||
def _create_with_images(session: LLMSession, response_model, messages, step: str, figures):
|
||||
"""Wie session.create – mit verständlicher Meldung, falls das Modell keine Bilder annimmt."""
|
||||
try:
|
||||
return session.create(response_model, messages, step=step)
|
||||
except Exception as exc:
|
||||
if figures and any(w in str(exc).lower() for w in ("image", "vision", "modalit", "multimodal")):
|
||||
raise RuntimeError(f"Das Modell dieser Stufe nimmt offenbar keine Bilder an ({str(exc)[:160]}). "
|
||||
"Ein Bildmodell über LLM_MODEL_UNDERSTANDING wählen.") from exc
|
||||
raise
|
||||
|
||||
|
||||
def understand_task(session: LLMSession, task: str, issues: List[CheckIssue] = (),
|
||||
data_prompt: str = "") -> TaskUnderstanding:
|
||||
"""Stufe 0: erfassen, was im Text steht – Teilfragen, Zahlen, Machbarkeit. Noch kein Modell."""
|
||||
data_prompt: str = "", figures: List[FigureInput] = ()) -> TaskUnderstanding:
|
||||
"""Stufe 1: erfassen, was im Text steht – Teilfragen, Zahlen, Machbarkeit, Abbildungen. Noch kein Modell."""
|
||||
nachtrag = ""
|
||||
if issues:
|
||||
nachtrag = ("\n\nDEIN VORIGER VERSUCH WAR UNVOLLSTÄNDIG:\n"
|
||||
+ "\n".join(f"- [{i.code}] {i.message}" for i in issues)
|
||||
+ "\nErfasse die fehlenden Punkte, behalte den Rest bei.")
|
||||
return session.create(
|
||||
TaskUnderstanding,
|
||||
[
|
||||
{"role": "system", "content": UNDERSTAND_PROMPT_FILE.read_text(encoding="utf-8")},
|
||||
{"role": "user", "content": f"AUFGABENTEXT:\n{task}\n\n{data_prompt}{nachtrag}".strip()},
|
||||
],
|
||||
step="Verstehen",
|
||||
)
|
||||
text = f"AUFGABENTEXT:\n{task}\n\n{data_prompt}{nachtrag}".strip()
|
||||
return _create_with_images(session, TaskUnderstanding, [
|
||||
{"role": "system", "content": UNDERSTAND_PROMPT_FILE.read_text(encoding="utf-8")},
|
||||
{"role": "user", "content": _content(text, list(figures))},
|
||||
], "Verstehen", figures)
|
||||
|
||||
|
||||
def read_figures(session: LLMSession, task: str, figures: List[FigureInput]) -> FigureReading:
|
||||
"""Unabhängige Übertragung nur der Abbildungen – die Gegenprobe zur Verstehensstufe."""
|
||||
text = f"AUFGABENTEXT (nur zur Einordnung der Abbildungen):\n{task}"
|
||||
return _create_with_images(session, FigureReading, [
|
||||
{"role": "system", "content": FIGURE_PROMPT_FILE.read_text(encoding="utf-8")},
|
||||
{"role": "user", "content": _content(text, figures)},
|
||||
], "Abbildungen", figures)
|
||||
|
|
|
|||
Loading…
Reference in a new issue