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:
Dieter Schlüter 2026-09-17 17:52:52 +02:00
commit b07dd16143

View file

@ -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)