_unresolved prüft jetzt auch, ob die LLM-Prüfung nach allen Korrekturrunden noch ein fehlerhaftes Annahmen-Urteil trägt (contradicts_task, stricter_than_task, weaker_than_task). Ist das der Fall, wird das Ergebnis als unzuverlässig markiert und der Solver nicht gestartet (wie bei falscher Zielgröße). Anlass: Portfoliomanagement – Prüfung erkannte x als Stückzahl statt als Betrag (contradicts_task), 2 Korrekturrunden reparierten es nicht, aber der Solver lief als reliable=True. Die Prüfung war wirksam, die Pipeline behandelte den Befund aber nicht als Zuverlässigkeitsproblem. 439 Tests grün.
731 lines
40 KiB
Python
731 lines
40 KiB
Python
#!/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})")
|
||
# Eine Annahme, die auch nach allen Korrekturrunden noch dem Text widerspricht (contradicts_task),
|
||
# die Lösung stärker einschränkt (stricter_than_task) oder mehr zulässt (weaker_than_task), macht
|
||
# das Ergebnis unzuverlässig – der Solver rechnet ein falsches Modell. Beobachtet am
|
||
# Portfoliomanagement: Prüfung erkannte x als Stückzahl statt als Betrag (contradicts_task),
|
||
# aber 2 Korrekturrunden reparierten es nicht, der Solver lief als reliable=True.
|
||
faulty = {"contradicts_task", "stricter_than_task", "weaker_than_task"}
|
||
for check in attempt["review"].checks:
|
||
if check.verdict.value in faulty:
|
||
problems.append(f"Prüfung: Annahme {check.verdict.value} – {check.unclear_point[:120]}")
|
||
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("--max-tokens-solver", type=int, metavar="N",
|
||
help=f"Antwortlänge für die Solver-Stufe, 0 = wie --max-tokens "
|
||
f"(LLM_MAX_TOKENS_SOLVER, {llm.LLM_MAX_TOKENS_SOLVER})")
|
||
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), ("--max-tokens-solver", args.max_tokens_solver),
|
||
("--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.max_tokens_solver is not None:
|
||
llm.LLM_MAX_TOKENS_SOLVER = args.max_tokens_solver
|
||
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())
|