text_to_solver_program/TextToModel.py
dschlueter 6b2b2c6466 fix: abgeschnittene Antworten meiden den Anbieter; LLM-Ausfall macht unzuverlässig statt abzustürzen
Grundlinie 20260918-191400: 9 von 22 Läufen hatten Aufrufe, die an LLM_MAX_TOKENS abgeschnitten
wurden (Venice 4/28, Makora 2/43, übrige 0/54); hard_portfolio_kardinalitaet brach nach vier
Abschneidungen ab, Kosten gingen verloren.

- Abschneiden: sofort neu versuchen, Anbieter per provider.ignore meiden (_avoid_provider,
  gemeinsam mit „zu langsam“)
- nach allen Wiederholungen LLMUnavailable (Stufe + Ursache); fatale Fehler bleiben unverpackt
- Solver-Stufe behält Modell und Prüfbericht (Solver-Fehler), Gegenprobe behält erstes Ergebnis,
  davor Ergebnis ohne Spezifikation mit Kosten („LLM-Fehler“, Report „Lauf abgebrochen“)
- Benchmark: Status „LLM-Fehler“ statt ABBRUCH, zählt nicht als ehrliches Scheitern
- TextToModel.py: Shebang

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:24:23 +02:00

717 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
from typing import List
from checks.model_checks import check_figure_readings, check_spec, check_understanding
from checks.review import needs_work, review_assumptions, revision_reasons, select_best
from core import llm
from core.llm import DEFAULT_MODEL, LLMSession, LLMUnavailable
from core.model_schema import (
SolverRun,
SolverStatus,
CheckSeverity,
OptimizationModelSpecification,
PipelineResult,
TaskUnderstanding,
)
from data import data_profile
from modeling.extraction import revise_model, text_to_model
from reporting.report import write_report
from solving import solver_stage
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
MAX_REVISIONS = int(os.environ.get("MAX_REVISIONS", "2"))
SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2"))
MAX_RESTARTS = int(os.environ.get("MAX_RESTARTS", "1"))
# "unlösbar" kann ein Modellierungsfehler sein (beobachtet bei qwen3-235b: Aufgabe war lösbar).
# 1 = einmal unabhängig neu modellieren und erneut lösen, bevor "unlösbar" berichtet wird.
INFEASIBLE_RECHECK = int(os.environ.get("INFEASIBLE_RECHECK", "1"))
# Nachfassen, wenn die Verstehensstufe Zahlen oder Teilfragen übersehen hat (0 = kein Nachfassen, -1 = Stufe aus)
UNDERSTAND_RETRIES = int(os.environ.get("UNDERSTAND_RETRIES", "1"))
task_description = """
Ein Logistikzentrum muss 12 Mitarbeiter auf 3 Acht-Stunden-Schichten verteilen.
In Schicht 1 werden mindestens 3 Mitarbeiter benötigt, in Schicht 2 mindestens 5
und in Schicht 3 genau 2. Kein Mitarbeiter darf zwei aufeinanderfolgende
Schichten arbeiten. Zudem gilt: Wenn Mitarbeiter A arbeitet, muss Mitarbeiter B
in derselben Schicht sein. Die Gesamtkosten für Schichtzulagen sollen minimiert werden.
"""
def log(msg: str) -> None:
# stderr, damit stdout reines JSON bleibt
print(msg, file=sys.stderr)
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" + (", 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, figures=figures)
issues = check_understanding(understanding, task)
if understanding.sub_questions:
log(f" {len(understanding.sub_questions)} Teilfrage(n): "
+ ", ".join(q.id for q in understanding.sub_questions)
+ f"; {len(understanding.facts)} Zahlenwerte erfasst")
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:
"""Ein Modellierungsversuch: Extraktion, Checks, Prüfung, Korrekturrunden, beste Runde.
Mit `spec` wird die Extraktion übersprungen – Einstieg ab der Prüfung (siehe continue_pipeline).
"""
if spec is None:
log(f"[{label}] Extrahiere Modell ...")
spec = text_to_model(session, task + hint, understanding, data_prompt)
else:
log(f"[{label}] Übernehme gespeicherte Spezifikation ({len(spec.constraints)} Restriktionen) ...")
issues = check_spec(spec, task, understanding)
log(f"[{label}] Prüfe Annahmen ...")
review = review_assumptions(session, task, spec, data_prompt)
candidates = [(spec, issues, review)]
revisions = 0
while needs_work(review, issues) and revisions < max_revisions:
revisions += 1
log(f"[{label}] Probleme gefunden – Korrekturrunde {revisions}/{max_revisions} ...")
for grund in revision_reasons(review, issues):
log(f" Grund: {grund}")
spec = revise_model(session, task, spec, review, issues, data_prompt)
issues = check_spec(spec, task, understanding)
review = review_assumptions(session, task, spec, data_prompt)
candidates.append((spec, issues, review))
selected = select_best(candidates)
if selected != len(candidates) - 1:
log(f"[{label}] Spätere Korrekturrunden haben das Modell verschlechtert – verwende Stand aus Runde {selected}.")
spec, issues, review = candidates[selected]
return {"spec": spec, "issues": issues, "review": review, "revisions": revisions, "selected": selected}
def _unresolved(attempt: dict) -> List[str]:
"""Probleme, die nach allen Korrekturrunden einen Neustart rechtfertigen."""
problems = []
if not attempt["review"].objective_check.matches:
oc = attempt["review"].objective_check
problems.append(f"Zielfunktion passt nicht zur Frage (gefragt: {oc.asked_quantity}; modelliert: {oc.modeled_quantity})")
problems += [f"[{i.code}] {i.message}" for i in attempt["issues"] if i.severity == CheckSeverity.ERROR]
return problems
INFEASIBLE_HINT = (
"\n\nHINWEIS: Eine erste, unabhängige Modellierung dieser Aufgabe ergab '{status}'. "
"Modelliere die Aufgabe daher völlig neu und prüfe dabei besonders, ob eine Bedingung zu streng ausgelegt "
"wurde (z. B. eine Gleichung, wo der Text einen Spielraum zulässt, oder eine Grenze, die der Text nicht "
"verlangt). Ist die Aufgabe wirklich unlösbar, modelliere sie trotzdem getreu."
)
def _recheck_infeasible(session, task: str, model: str, result: PipelineResult,
max_revisions: int, solver_retries: int, data_files=(), data_prompt: str = "") -> PipelineResult:
"""'unlösbar' nicht ungeprüft glauben: unabhängig neu modellieren und erneut lösen."""
status = result.solver_run.status.value
if INFEASIBLE_RECHECK < 1:
result.reliable = False
result.reliability_issues.append(f"Solver meldet '{status}' – ohne Gegenprobe (INFEASIBLE_RECHECK=0)")
return result
log(f"[{model}] Solver meldet '{status}' – Gegenprobe mit unabhängiger Modellierung ...")
try:
return _run_recheck(session, task, model, result, status, max_revisions, solver_retries,
data_files, data_prompt)
except LLMUnavailable as exc:
log(f" Gegenprobe abgebrochen: {exc}")
result.reliable = False
result.reliability_issues.append(f"Solver meldet '{status}'; die Gegenprobe scheiterte am LLM ({exc.step})")
return result
def _run_recheck(session, task: str, model: str, result: PipelineResult, status: str, max_revisions: int,
solver_retries: int, data_files, data_prompt: str) -> PipelineResult:
zweiter = _model_attempt(session, task, max_revisions, f"{model} Gegenprobe",
hint=INFEASIBLE_HINT.format(status=status),
understanding=result.understanding, data_prompt=data_prompt)
if not zweiter["review"].objective_check.matches:
log(" Gegenprobe verworfen: Zielfunktion der neuen Modellierung passt nicht zur Frage.")
result.reliable = False
result.reliability_issues.append(f"Solver meldet '{status}'; die Gegenprobe war unbrauchbar")
return result
spec = zweiter["spec"]
log(f"[{model}] Gegenprobe: Solver-Code erzeugen und starten ({spec.target_stack.value}) ...")
code, run = solve(session, task, spec, retries=solver_retries, log=log, understanding=result.understanding,
data_files=data_files, data_prompt=data_prompt)
if run.status in (SolverStatus.OPTIMAL, SolverStatus.FEASIBLE) and not run.feasibility:
log(f" Gegenprobe widerlegt '{status}': Zielwert {run.objective_value} – diese Lösung wird verwendet.")
result.specification, result.review = spec, zweiter["review"]
result.checks = check_spec(spec, task, result.understanding)
result.solver_code, result.solver_run = code, run
result.restarts += 1
_assess_run(result) # die neue Lösung durchläuft dieselbe Abnahme wie die erste
return result
if run.status.value == status:
log(f" Gegenprobe bestätigt '{status}' mit einer unabhängigen Modellierung.")
result.infeasible_confirmed = True
return result
log(f" Gegenprobe nicht verwertbar (Status '{run.status.value}'), erste Modellierung bleibt.")
result.reliable = False
result.reliability_issues.append(f"Solver meldet '{status}'; die Gegenprobe endete mit '{run.status.value}'")
return result
def _solver_stage(session, task: str, result: PipelineResult, model: str,
max_revisions: int, solver_retries: int, data_files=(), data_prompt: str = "") -> PipelineResult:
"""Solver-Code erzeugen, ausführen, prüfen – gemeinsam für run_pipeline und continue_pipeline."""
spec = result.specification
if not result.review.objective_check.matches:
# Der Solver würde ein anderes als das gefragte Problem lösen – ein "optimal" wäre irreführend
log(f"[{model}] Solver wird nicht gestartet, weil die Zielfunktion nicht zur Frage passt.")
return result
log(f"[{model}] Erzeuge und starte Solver-Code ({spec.target_stack.value}) ...")
try:
result.solver_code, result.solver_run = solve(session, task, spec, retries=solver_retries, log=log,
understanding=result.understanding,
data_files=data_files, data_prompt=data_prompt)
except LLMUnavailable as exc:
# Modell und Prüfbericht bleiben erhalten – `--von solver` kann genau hier weitermachen
result.solver_run = SolverRun(status=SolverStatus.ERROR, error=str(exc))
result.reliable = False
result.reliability_issues.append(f"Solver-Fehler: {exc}")
log(f"ERGEBNIS UNZUVERLÄSSIG – {exc}")
return result
_assess_run(result)
if result.solver_run.status in (SolverStatus.INFEASIBLE, SolverStatus.UNBOUNDED):
result = _recheck_infeasible(session, task, model, result, max_revisions, solver_retries,
data_files=data_files, data_prompt=data_prompt)
return result
def _assess_run(result: PipelineResult) -> None:
"""Abnahme eines Solver-Laufs: Befunde, die das Ergebnis unzuverlässig machen, in `result` übernehmen."""
run = result.solver_run
log(f"Solver: {run.status.value}, Zielwert: {run.objective_value}" + (f"\n {run.error}" if run.error else ""))
if run.status == SolverStatus.ERROR:
# Auch nach allen Reparaturen kein Ergebnis – bisher blieb reliable=True und die CLI endete mit 0
result.reliable = False
result.reliability_issues.append(f"Solver-Fehler: {solver_error_summary(run)}")
log("ERGEBNIS UNZUVERLÄSSIG – das Solver-Programm ist auch nach allen Reparaturversuchen gescheitert.")
if run.feasibility:
result.reliable = False
result.reliability_issues += [f"Zulässigkeitsprüfung: {p}" for p in run.feasibility]
log("ERGEBNIS UNZUVERLÄSSIG – die Lösung hält das Modell nicht ein:")
for problem in run.feasibility:
log(f" {problem}")
elif run.constraints:
log(f" Zulässigkeit geprüft: {len(run.constraints)} Restriktionen, {len(run.variables)} Variablen – alles eingehalten.")
if run.undeclared_data:
result.reliable = False
result.reliability_issues += [f"Datenherkunft: {p}" for p in run.undeclared_data]
log("ERGEBNIS UNZUVERLÄSSIG – selbst erzeugte Eingabedaten sind nicht ausgewiesen.")
if run.unsolved_scenarios:
result.reliable = False
result.reliability_issues += [f"Szenario ohne Ergebnis: {p}" for p in run.unsolved_scenarios]
log("ERGEBNIS UNZUVERLÄSSIG – mindestens ein Szenario hat kein Ergebnis und ist nicht als unlösbar belegt.")
if result.solver_code.data_assumptions:
log(f" Mit angenommenen Daten gelöst ({len(result.solver_code.data_assumptions)} Annahmen).")
def solver_error_summary(run) -> str:
"""Erste aussagekräftige Zeile des Fehlers – der volle Text steht im Report unter „Ergebnis“."""
zeilen = [z.strip() for z in (run.error or "").splitlines() if z.strip()]
return (zeilen[-1] if zeilen else "das Programm meldete selbst Status 'error'")[:200]
def _log_usage(session, result: PipelineResult) -> None:
result.usage = session.usage
cost = f", Kosten ${session.usage.cost_usd:.4f}" if session.usage.cost_usd is not None else ""
providers = f", Anbieter: {session.usage.providers}" if session.usage.providers else ""
if session.usage.responded_models:
providers += f", Modell laut Antwort: {session.usage.responded_models}"
usage = session.usage
cache = (f" (davon {usage.cached_tokens} aus dem Cache, {100 * usage.cached_tokens / usage.prompt_tokens:.0f} %)"
if usage.prompt_tokens else "")
log(f"LLM-Nutzung: {usage.calls} Aufrufe, {usage.prompt_tokens} Input-{cache}/"
f"{usage.completion_tokens} Output-Tokens (davon {usage.reasoning_tokens} Reasoning){cost}{providers}")
for step, data in session.usage.by_step().items():
log(f" {step:18} {data['calls']}x {data['seconds']:6.1f} s {data['completion_tokens']:6} Output-Tokens "
f"(davon {data['reasoning_tokens']} Reasoning) {data['prompt_tokens']:6} Input (Cache {data['cached_tokens']})")
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,
) -> 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)
try:
return _run_pipeline(session, task, figures, model, max_revisions, solve_model, solver_retries,
max_restarts, data_files)
except LLMUnavailable as exc:
# Vor der Solver-Stufe gibt es nichts Verwertbares; statt Absturz ein ehrliches, bezahltes Nicht-Ergebnis
log(f"ERGEBNIS UNZUVERLÄSSIG – {exc}")
result = PipelineResult(task=task, figures=figures, reliable=False,
reliability_issues=[f"LLM-Fehler: {exc}"])
_log_usage(session, result)
return result
def _run_pipeline(session, task: str, figures, model: str, max_revisions: int, solve_model: bool,
solver_retries: int, max_restarts: int, data_files) -> PipelineResult:
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:
log(f"Datei {p.name}: " + (p.error if p.error else f"{p.rows} Zeilen, {len(p.columns)} Spalten"))
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)
if understanding is not None and 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,
reliable=False, reliability_issues=[grund], checks=verstehen_issues)
_log_usage(session, result)
return result
attempts = [_model_attempt(session, task, max_revisions, model, understanding=understanding,
data_prompt=data_prompt)]
# Beobachtet: das LLM modelliert gelegentlich ein Fremdproblem; Korrekturrunden reparieren das nicht,
# ein frischer Neustart der Extraktion schon eher.
while _unresolved(attempts[-1]) and len(attempts) <= max_restarts:
log(f"[{model}] Modell nach allen Korrekturrunden fehlerhaft – Neustart {len(attempts)}/{max_restarts}: "
+ "; ".join(_unresolved(attempts[-1]))[:300])
attempts.append(_model_attempt(session, task, max_revisions, f"{model} Neustart {len(attempts)}",
understanding=understanding, data_prompt=data_prompt))
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"]
for issue in issues:
log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}")
if problems:
log("ERGEBNIS UNZUVERLÄSSIG: " + "; ".join(problems))
elif review.needs_revision:
log("WARNUNG: Die Prüfung hat noch Anmerkungen zu Annahmen.")
else:
log("Prüfung ohne Beanstandung.")
result = PipelineResult(
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,
)
if solve_model:
result = _solver_stage(session, task, result, model, max_revisions, solver_retries,
data_files=data_files, data_prompt=data_prompt)
_log_usage(session, result)
return result
STAGES = ("pruefung", "solver")
def continue_pipeline(
result_json: str, stage: str, model: str = DEFAULT_MODEL, max_revisions: int = MAX_REVISIONS,
solver_retries: int = SOLVER_RETRIES, session: LLMSession | None = None, data_files=(),
) -> PipelineResult:
"""Gespeichertes Ergebnis ab einer Stufe fortsetzen – die davor liegenden LLM-Aufrufe entfallen.
'pruefung' behält die Spezifikation und prüft/korrigiert sie neu; 'solver' übernimmt auch den
Prüfbericht und erzeugt nur den Solver-Code neu. Aufgabentext, Verständnis und Abbildungen kommen aus
dem gespeicherten Ergebnis; beigelegte Daten (`data_files`) sind dort nur als Profil gespeichert und
müssen erneut angegeben werden, sonst findet das Programm seine Dateien nicht.
"""
if stage not in STAGES:
raise ValueError(f"Unbekannte Stufe '{stage}' (erlaubt: {', '.join(STAGES)})")
stored = PipelineResult.model_validate_json(Path(result_json).read_text(encoding="utf-8"))
if stored.rejected or stored.specification is None:
raise ValueError(f"{result_json} ist eine abgelehnte Aufgabe (keine Optimierungsaufgabe) – nichts fortzusetzen")
session = session or LLMSession(model)
task, understanding = stored.task, stored.understanding
profile = data_profile.profile_files(data_files) if data_files else stored.data_files
data_prompt = data_profile.to_prompt(profile)
if stored.data_files and not data_files:
log("WARNUNG – das gespeicherte Ergebnis hatte beigelegte Daten; ohne --daten findet das Programm sie nicht.")
log(f"Setze {result_json} ab Stufe '{stage}' fort – Extraktion entfällt.")
if stage == "solver":
checks = check_spec(stored.specification, task, understanding) # deterministisch, kostet nichts
problems = _unresolved({"review": stored.review, "issues": checks})
update = {"checks": checks}
else:
try:
attempt = _model_attempt(session, task, max_revisions, model, spec=stored.specification,
understanding=understanding, data_prompt=data_prompt)
except LLMUnavailable as exc:
log(f"ERGEBNIS UNZUVERLÄSSIG – {exc}")
result = stored.model_copy(update={"solver_code": None, "solver_run": None, "reliable": False,
"reliability_issues": [f"LLM-Fehler: {exc}"]})
_log_usage(session, result)
return result
problems = _unresolved(attempt)
update = {"specification": attempt["spec"], "review": attempt["review"], "revisions": attempt["revisions"],
"selected_revision": attempt["selected"], "restarts": 0, "checks": attempt["issues"]}
# Verständnis, Abbildungen und Datenprofil bleiben erhalten; alles ab der Fortsetzungsstufe entsteht neu
result = stored.model_copy(update={
**update, "data_files": profile, "solver_code": None, "solver_run": None, "usage": None,
"infeasible_confirmed": False, "reliable": not problems, "reliability_issues": problems,
})
for issue in result.checks:
log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}")
if result.reliability_issues:
log("ERGEBNIS UNZUVERLÄSSIG: " + "; ".join(result.reliability_issues))
result = _solver_stage(session, task, result, model, max_revisions, solver_retries,
data_files=data_files, data_prompt=data_prompt)
_log_usage(session, result)
return result
def resolve_result(result_json: str, data_file: str | None = None, solver_timeout: int | None = None,
data_files=()) -> PipelineResult:
"""Gespeichertes Ergebnis mit (geänderten) Daten neu rechnen – ohne einen einzigen LLM-Aufruf.
Das erzeugte Programm hält alle Eingabezahlen in DATA; nur diese werden ersetzt. Liest es beigelegte
Dateien, müssen sie über `data_files` erneut angegeben werden.
"""
result = PipelineResult.model_validate_json(Path(result_json).read_text(encoding="utf-8"))
if not result.solver_code:
raise ValueError(f"{result_json} enthält keinen Solver-Code (Lauf mit --no-solve?)")
data = json.loads(Path(data_file).read_text(encoding="utf-8")) if data_file else None
log(f"Rechne {result_json} neu" + (f" mit Daten aus {data_file}" if data_file else "") + " – ohne LLM ...")
run = run_solver_code(result.solver_code.code, timeout=solver_timeout, data=data, data_files=data_files)
run.feasibility = check_feasibility(run, result.specification)
run.warnings += check_sensitivity(run, result.specification.objective.sense.value)
run.warnings += check_multistart(run, result.specification.objective.sense.value)
result.solver_run = run
# Frühere Befunde (Zielgröße, Abbildung, Datenherkunft) gelten weiter – nur Lauf und Zulässigkeit sind neu geprüft
result.reliability_issues = ([p for p in result.reliability_issues
if not p.startswith(("Zulässigkeitsprüfung: ", "Solver-Fehler: "))]
+ ([f"Solver-Fehler: {solver_error_summary(run)}"]
if run.status == SolverStatus.ERROR else [])
+ [f"Zulässigkeitsprüfung: {p}" for p in run.feasibility])
result.reliable = not result.reliability_issues
result.usage = None # keine LLM-Nutzung in diesem Lauf
log(f"Solver: {run.status.value}, Zielwert: {run.objective_value}" + (f"\n {run.error}" if run.error else ""))
for problem in run.feasibility:
log(f" {problem}")
return result
def read_task(task_file: str | None) -> str:
"""Aufgabe aus Datei, aus stdin ('-' oder umgeleitet) oder – ohne beides – die Beispielaufgabe."""
if task_file and task_file != "-":
task = Path(task_file).read_text(encoding="utf-8")
elif task_file == "-" or not sys.stdin.isatty():
task = sys.stdin.read()
else:
task = ""
# Leere Eingabe würde das LLM ein beliebiges Problem erfinden lassen
if not task.strip():
log("Keine Aufgabe angegeben – verwende die Beispielaufgabe (Schichtplanung).")
return task_description
return task
# Exit-Codes für Skripte. Aufruffehler bewusst nicht argparse' 2 – das hieße hier „unzuverlässig“.
EXIT_OK, EXIT_ERROR, EXIT_UNRELIABLE, EXIT_REJECTED, EXIT_USAGE, EXIT_INTERRUPTED = 0, 1, 2, 3, 64, 130
EPILOG = f"""\
Beispiele:
%(prog)s aufgabe.md --ausgabe output/aufgabe # schreibt output/aufgabe.json, .html und .py
%(prog)s aufgabe.md --daten kurse.csv -o output/aufgabe.json --report output/aufgabe.html
echo "Ein Betrieb …" | %(prog)s - --report output/modell.md
%(prog)s aufgabe.md --no-solve --max-revisions 0 -m deepseek/deepseek-v4-pro-0813
%(prog)s --resolve output/aufgabe.json --data neue_werte.json -o output/variante.json # ohne LLM
%(prog)s --von solver output/aufgabe.json -o output/neu.json # nur Solver-Stufe neu
Ausgabe: Ohne -o/--ausgabe geht das Ergebnis-JSON nach stdout, wenn stdout umgeleitet ist; im Terminal
erscheint stattdessen eine Kurzfassung. Der Fortschritt steht immer auf stderr.
Exit-Codes: {EXIT_OK} zuverlässiges Ergebnis · {EXIT_ERROR} Fehler (Datei, Abbildung, gespeichertes Ergebnis) ·
{EXIT_UNRELIABLE} Ergebnis unzuverlässig · {EXIT_REJECTED} keine Optimierungsaufgabe · {EXIT_USAGE} falscher Aufruf · \
{EXIT_INTERRUPTED} abgebrochen
Optionen ohne Angabe übernehmen die Umgebungsvariable in Klammern bzw. deren Default.
Modell je Stufe nur über die Umgebung: LLM_MODEL_UNDERSTANDING, _EXTRACTION, _REVIEW, _REVISION, _SOLVER."""
class _Parser(argparse.ArgumentParser):
def error(self, message):
self.print_usage(sys.stderr)
self.exit(EXIT_USAGE, f"{self.prog}: Fehler: {message}\n")
def build_parser() -> argparse.ArgumentParser:
parser = _Parser(
prog="TextToModel.py", description="Textaufgabe → Optimierungsmodell → Prüfung → Lösung",
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter,
)
eingabe = parser.add_argument_group("Eingabe")
eingabe.add_argument("aufgabe", nargs="?", metavar="AUFGABE",
help="Datei mit der Aufgabe (Text/Markdown); '-' = stdin. Fehlt sie und ist stdin nicht "
"umgeleitet, läuft die Beispielaufgabe")
eingabe.add_argument("--daten", nargs="+", metavar="DATEI",
help="beigelegte Datendateien (CSV/Excel); ihr Profil geht in die Prompts, die Dateien "
"ins Arbeitsverzeichnis des Programms (bei --resolve/--von erneut angeben)")
modus = parser.add_argument_group("Modus (höchstens einer; ohne Angabe: kompletter Lauf)")
exklusiv = modus.add_mutually_exclusive_group()
exklusiv.add_argument("--no-solve", action="store_true",
help="nur verstehen, modellieren und prüfen – kein Solver-Code")
exklusiv.add_argument("--von", nargs=2, metavar=("STUFE", "ERGEBNIS.JSON"),
help="gespeichertes Ergebnis fortsetzen: 'pruefung' (Spezifikation behalten, neu prüfen "
"und korrigieren) oder 'solver' (auch Prüfbericht behalten, nur Solver-Code neu)")
exklusiv.add_argument("--resolve", metavar="ERGEBNIS.JSON",
help="gespeichertes Ergebnis ohne LLM neu rechnen (z. B. nach Datenänderung)")
modus.add_argument("--data", metavar="DATEN.JSON",
help="mit --resolve: Eingabewerte (DATA) des Programms ganz oder teilweise ersetzen")
ablauf = parser.add_argument_group("Ablauf")
ablauf.add_argument("--max-revisions", type=int, default=MAX_REVISIONS, metavar="N",
help=f"Korrekturrunden je Modellierung (MAX_REVISIONS, {MAX_REVISIONS})")
ablauf.add_argument("--max-restarts", type=int, default=MAX_RESTARTS, metavar="N",
help=f"Neustarts der Modellierung, wenn sie fehlerhaft bleibt (MAX_RESTARTS, {MAX_RESTARTS})")
ablauf.add_argument("--solver-retries", type=int, default=SOLVER_RETRIES, metavar="N",
help=f"Reparaturen des Solver-Codes (SOLVER_RETRIES, {SOLVER_RETRIES})")
ablauf.add_argument("--understand-retries", type=int, metavar="N",
help=f"Nachfassen beim Verstehen, -1 = Stufe aus (UNDERSTAND_RETRIES, {UNDERSTAND_RETRIES})")
ablauf.add_argument("--infeasible-recheck", type=int, choices=(0, 1), metavar="{0,1}",
help=f"Gegenprobe bei 'unlösbar' (INFEASIBLE_RECHECK, {INFEASIBLE_RECHECK})")
ki = parser.add_argument_group("LLM")
ki.add_argument("-m", "--model", default=DEFAULT_MODEL,
help=f"Modell; mit '/' → OpenRouter, sonst Gemini (LLM_MODEL, {DEFAULT_MODEL})")
ki.add_argument("--reasoning", choices=("low", "medium", "high"),
help="Denkaufwand, nur OpenRouter (LLM_REASONING_EFFORT, Modellvorgabe)")
ki.add_argument("--max-tokens", type=int, metavar="N",
help=f"Antwortlänge je Aufruf (LLM_MAX_TOKENS, {llm.LLM_MAX_TOKENS})")
ki.add_argument("--llm-timeout", type=int, metavar="SEK",
help=f"Zeitlimit je Aufruf (LLM_TIMEOUT, {llm.LLM_TIMEOUT})")
solver = parser.add_argument_group("Solver")
solver.add_argument("--solver-timeout", type=int, metavar="SEK",
help=f"Zeitlimit je Programmlauf, geht auch als Zeitbudget ans LLM "
f"(SOLVER_TIMEOUT, {solver_stage.SOLVER_TIMEOUT})")
solver.add_argument("--sandbox", choices=("auto", "bwrap", "aus"),
help=f"Isolierung des erzeugten Codes (SOLVER_SANDBOX, {solver_stage.SANDBOX})")
ausgabe = parser.add_argument_group("Ausgabe")
ausgabe.add_argument("-o", "--json", metavar="DATEI", help="Ergebnis-JSON in diese Datei statt nach stdout")
ausgabe.add_argument("--report", metavar="DATEI", help="Report mit Formeln und Solver-Code (.md oder .html)")
ausgabe.add_argument("--code", metavar="DATEI", help="Solver-Code als eigenständiges Python-Skript")
ausgabe.add_argument("-a", "--ausgabe", metavar="PRÄFIX",
help="Kurzform für -o PRÄFIX.json --report PRÄFIX.html --code PRÄFIX.py "
"(ausdrückliche Angaben haben Vorrang)")
return parser
def _check_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
if args.ausgabe:
args.json = args.json or f"{args.ausgabe}.json"
args.report = args.report or f"{args.ausgabe}.html"
if not args.no_solve:
args.code = args.code or f"{args.ausgabe}.py"
if args.report and Path(args.report).suffix.lower() not in (".md", ".html", ".htm"):
parser.error("--report braucht die Endung .md oder .html")
if args.code and args.no_solve:
parser.error("--code ist mit --no-solve nicht möglich (es wird kein Solver-Code erzeugt)")
if args.data and not args.resolve:
parser.error("--data wirkt nur zusammen mit --resolve")
if args.von and args.von[0] not in STAGES:
parser.error(f"--von braucht eine Stufe aus {{{', '.join(STAGES)}}}, nicht '{args.von[0]}'")
if (args.von or args.resolve) and args.aufgabe:
parser.error("--von/--resolve nehmen die Aufgabe aus dem gespeicherten Ergebnis – keine Aufgabendatei angeben")
for name, wert in (("--max-revisions", args.max_revisions), ("--max-restarts", args.max_restarts),
("--solver-retries", args.solver_retries)):
if wert < 0:
parser.error(f"{name} darf nicht negativ sein")
for name, wert in (("--max-tokens", args.max_tokens), ("--llm-timeout", args.llm_timeout),
("--solver-timeout", args.solver_timeout)):
if wert is not None and wert <= 0:
parser.error(f"{name} muss positiv sein")
eingaben = [args.aufgabe if args.aufgabe != "-" else None, args.von[1] if args.von else None,
args.resolve, args.data, *(args.daten or [])]
for datei in eingaben:
if datei and not Path(datei).is_file():
parser.error(f"Datei nicht gefunden: {datei}")
def apply_settings(args: argparse.Namespace) -> None:
"""Optionen, die sonst nur als Umgebungsvariable wirken, für diesen Prozess setzen.
Die Module lesen die Umgebung beim Import; deshalb werden ihre Konstanten direkt ersetzt – sie werden
zur Aufrufzeit gelesen. Modell je Stufe liest `LLMSession` aus der Umgebung und bleibt dort.
"""
global UNDERSTAND_RETRIES, INFEASIBLE_RECHECK
if args.understand_retries is not None:
UNDERSTAND_RETRIES = args.understand_retries
if args.infeasible_recheck is not None:
INFEASIBLE_RECHECK = args.infeasible_recheck
if args.reasoning:
llm.REASONING_EFFORT = args.reasoning
if args.max_tokens:
llm.LLM_MAX_TOKENS = args.max_tokens
if args.llm_timeout:
llm.LLM_TIMEOUT = args.llm_timeout
if args.solver_timeout:
solver_stage.SOLVER_TIMEOUT = args.solver_timeout
if args.sandbox:
solver_stage.SANDBOX = args.sandbox
def summary(result: PipelineResult) -> str:
"""Kurzfassung fürs Terminal: Status, Zielwert, Antworten, Zuverlässigkeit."""
if result.rejected:
return "Abgelehnt – " + "; ".join(result.reliability_issues)
zeilen = []
run = result.solver_run
if result.specification is None:
zeilen.append("Kein Modell entstanden.")
elif run is None:
spec = result.specification
zeilen.append(f"Modell: {spec.problem_class.value} → {spec.target_stack.value} (kein Solver-Lauf)")
else:
zeilen.append(f"Status: {run.status.value}" + (f" · Zielwert: {run.objective_value:g}"
if run.objective_value is not None else ""))
if run.headline:
zeilen.append(run.headline)
zeilen += [f" {a.question_id}) {a.text}" for a in run.answers]
zeilen.append("Zuverlässig." if result.reliable
else "UNZUVERLÄSSIG: " + "; ".join(result.reliability_issues))
return "\n".join(zeilen)
def _write_outputs(args: argparse.Namespace, result: PipelineResult) -> None:
daten = result.model_dump_json(indent=2)
if args.json:
Path(args.json).parent.mkdir(parents=True, exist_ok=True)
Path(args.json).write_text(daten + "\n", encoding="utf-8")
log(f"Ergebnis geschrieben: {args.json}")
elif sys.stdout.isatty():
print(summary(result))
log("(JSON: mit -o DATEI speichern oder stdout umleiten)")
else:
print(daten)
if args.report:
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
write_report(result, args.report)
log(f"Report geschrieben: {args.report}")
if args.code and result.solver_code:
Path(args.code).parent.mkdir(parents=True, exist_ok=True)
Path(args.code).write_text(build_solver_script(result), encoding="utf-8")
Path(args.code).chmod(0o755)
log(f"Solver-Code geschrieben: {args.code} (ausführen: .venv/bin/python {args.code})")
elif args.code:
log(f"Kein Solver-Code geschrieben ({args.code}): Solver wurde nicht gestartet.")
def main(argv: List[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
_check_args(parser, args)
apply_settings(args)
daten = args.daten or ()
try:
if args.resolve or args.von:
try:
if args.resolve:
result = resolve_result(args.resolve, args.data, data_files=daten)
else:
result = continue_pipeline(args.von[1], args.von[0], model=args.model,
max_revisions=args.max_revisions,
solver_retries=args.solver_retries, data_files=daten)
except ValueError as exc: # auch pydantic.ValidationError: gespeichertes Ergebnis passt nicht
log(f"Fehler in {args.resolve or args.von[1]}: {exc}")
return EXIT_ERROR
else:
task_dir = Path(args.aufgabe).parent if args.aufgabe and args.aufgabe != "-" else None
result = run_pipeline(
read_task(args.aufgabe), model=args.model, max_revisions=args.max_revisions,
solve_model=not args.no_solve, solver_retries=args.solver_retries, max_restarts=args.max_restarts,
data_files=daten, task_dir=task_dir,
)
except KeyboardInterrupt:
log("Abgebrochen.")
return EXIT_INTERRUPTED
except (FigureError, OSError) as exc:
log(f"Fehler: {exc}")
return EXIT_ERROR
_write_outputs(args, result)
if result.rejected:
return EXIT_REJECTED # keine Optimierungsaufgabe, nichts gerechnet
if not result.reliable:
return EXIT_UNRELIABLE # Ergebnis vorhanden, aber unzuverlässig
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())