feat: TextToModel.py als CLI-Programm mit main(), gruppierten Optionen und Defaults

- main(argv) -> int, build_parser() mit Gruppen Eingabe/Modus/Ablauf/LLM/Solver/Ausgabe,
  Hilfe mit Defaults, Umgebungsvariablen, Beispielen und Exit-Codes
- Optionen für bisher reine Umgebungsvariablen (--solver-timeout, --sandbox, --max-tokens,
  --llm-timeout, --reasoning, --solver-retries, --understand-retries, --infeasible-recheck)
- -o/--json und -a/--ausgabe PRÄFIX; im Terminal Kurzfassung statt JSON
- Exit-Codes: 1 Eingabefehler, 64 Aufruffehler (nicht argparse' 2 = unzuverlässig), 130 Abbruch
- solver_stage: Zeitlimit-Default zur Aufrufzeit auflösen, damit --solver-timeout wirkt
- tests/test_cli.py

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-18 18:34:42 +02:00
commit 422360ffa5
5 changed files with 382 additions and 70 deletions

View file

@ -133,8 +133,11 @@ Die Aufgabe kann als `.txt` oder `.md` vorliegen; Markdown-Tabellen und Formeln
## 5. Aufrufvarianten
```bash
# Aufgabe aus einer Datei
.venv/bin/python TextToModel.py aufgabe.md --report output/aufgabe.html > output/aufgabe.json
# Aufgabe aus einer Datei – JSON, Report und Solver-Programm auf einen Schlag
.venv/bin/python TextToModel.py aufgabe.md --ausgabe output/aufgabe # output/aufgabe.json, .html, .py
# dasselbe einzeln
.venv/bin/python TextToModel.py aufgabe.md -o output/aufgabe.json --report output/aufgabe.html
# Aufgabe über die Standardeingabe
echo "Ein Betrieb …" | .venv/bin/python TextToModel.py --report output/aufgabe.md > output/aufgabe.json
@ -156,7 +159,10 @@ Hinweise:
- `--report` braucht die Endung `.html`, `.htm` oder `.md`.
- `--code` ist mit `--no-solve` nicht möglich – es entsteht dann kein Programm.
- Das **Ergebnis-JSON geht auf die Standardausgabe**. Ohne `> datei.json` erscheint es im Terminal und ist danach für `--resolve` und `--von` nicht verfügbar. Es empfiehlt sich, es immer umzuleiten.
- Das **Ergebnis-JSON** schreibt `-o datei.json` (oder `--ausgabe PRÄFIX`) in eine Datei; fehlende Ordner werden angelegt. Ohne diese Option geht es auf die Standardausgabe, wenn sie umgeleitet ist (`> datei.json`). Im Terminal erscheint stattdessen nur eine Kurzfassung (Status, Zielwert, Antworten, Zuverlässigkeit) – das JSON ist dann für `--resolve` und `--von` nicht gespeichert. Es empfiehlt sich, immer `-o` oder `--ausgabe` zu verwenden.
- `--ausgabe PRÄFIX` ist die Kurzform für `-o PRÄFIX.json --report PRÄFIX.html --code PRÄFIX.py`; ausdrücklich angegebene Einzeloptionen haben Vorrang.
- Aufgabe über die Standardeingabe: `-` als Aufgabendatei oder einfach umleiten.
- `.venv/bin/python TextToModel.py --help` zeigt alle Optionen mit ihren Defaults und Beispielen.
- Der Ordner `output/` ist von Git ausgeschlossen und eignet sich für alle Ergebnisse.
---
@ -209,9 +215,12 @@ print(d['reliable'], r['status'], r['objective_value']); [print(a['question_id']
| Code | Bedeutung |
|---|---|
| `0` | Ergebnis zuverlässig |
| `1` | Fehler bei der Eingabe: Datei unlesbar, Abbildung zu groß, gespeichertes Ergebnis ungültig |
| `2` | Ergebnis vorhanden, aber **unzuverlässig** – Gründe in `reliability_issues` und im Warnkasten des Reports |
| `3` | **abgelehnt** – keine Optimierungsaufgabe, nichts gerechnet |
| andere | Programmfehler oder Abbruch (z. B. ungültiger API-Schlüssel), Meldung im Terminal |
| `64` | falscher Aufruf (unbekannte Option, fehlende Datei, unverträgliche Optionen) |
| `130` | mit Strg+C abgebrochen |
| andere | Programmfehler (z. B. ungültiger API-Schlüssel), Meldung im Terminal |
Damit lässt sich das Programm in Skripten einsetzen:
@ -416,10 +425,19 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav
| Option | Bedeutung |
|---|---|
| `AUFGABE` | Aufgabendatei (`.txt`, `.md`); ohne: Standardeingabe, sonst Beispielaufgabe |
| `AUFGABE` | Aufgabendatei (`.txt`, `.md`); `-` oder ohne: Standardeingabe, sonst Beispielaufgabe |
| `-o`, `--json DATEI` | Ergebnis-JSON in eine Datei (sonst stdout bzw. Kurzfassung im Terminal) |
| `--report DATEI` | Report als `.html`/`.htm` oder `.md` |
| `--code DATEI` | Solver-Programm als ausführbares Skript speichern |
| `--model NAME` | LLM-Modell (mit `/` → OpenRouter, sonst Gemini) |
| `-a`, `--ausgabe PRÄFIX` | Kurzform für `-o PRÄFIX.json --report PRÄFIX.html --code PRÄFIX.py` |
| `-m`, `--model NAME` | LLM-Modell (mit `/` → OpenRouter, sonst Gemini) |
| `--reasoning low\|medium\|high` | Denkaufwand (wie `LLM_REASONING_EFFORT`) |
| `--max-tokens N`, `--llm-timeout SEK` | wie `LLM_MAX_TOKENS`, `LLM_TIMEOUT` |
| `--solver-retries N` | Reparaturen des Solver-Codes (wie `SOLVER_RETRIES`) |
| `--understand-retries N` | Nachfassen beim Verstehen, `-1` = Stufe aus (wie `UNDERSTAND_RETRIES`) |
| `--infeasible-recheck 0\|1` | Gegenprobe bei „unlösbar“ (wie `INFEASIBLE_RECHECK`) |
| `--solver-timeout SEK` | Zeitlimit je Programmlauf (wie `SOLVER_TIMEOUT`) |
| `--sandbox auto\|bwrap\|aus` | Isolierung des Solver-Codes (wie `SOLVER_SANDBOX`) |
| `--daten DATEI …` | beigelegte CSV-/Excel-Dateien |
| `--no-solve` | keinen Solver-Code erzeugen |
| `--max-revisions N` | Korrekturrunden |

View file

@ -71,8 +71,11 @@ Der Anbieter wird am Modellnamen erkannt. Das kostenlose Gemini-Kontingent reich
Ausführliche Schritt-für-Schritt-Anleitung – Aufgaben formulieren, Ergebnis lesen, Weiterarbeiten ohne LLM-Kosten, Fehlerbehebung: **[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)**.
```bash
# Aufgabe aus Datei lösen, Report und Solver-Code speichern
.venv/bin/python TextToModel.py aufgabe.txt --report output/modell.html --code output/solve.py > output/ergebnis.json
# Aufgabe aus Datei lösen: output/ergebnis.json, .html (Report) und .py (Solver-Programm)
.venv/bin/python TextToModel.py aufgabe.txt --ausgabe output/ergebnis
# alle Optionen mit Defaults und Beispielen
.venv/bin/python TextToModel.py --help
# Aufgabe über stdin
echo "Ein Betrieb ..." | .venv/bin/python TextToModel.py --report output/modell.md
@ -96,7 +99,7 @@ echo "Ein Betrieb ..." | .venv/bin/python TextToModel.py --report output/modell.
.venv/bin/python TextToModel.py --von solver output/ergebnis.json > output/neu.json
```
Exit-Codes: `0` Ergebnis zuverlässig, `2` Ergebnis vorhanden, aber unzuverlässig, `3` abgelehnt – der Text ist keine Optimierungsaufgabe (es wird weder modelliert noch gerechnet; Report und JSON nennen den Grund). Der Ordner `output/` ist in `.gitignore` eingetragen. Ohne Eingabe wird eine eingebaute Beispielaufgabe verwendet. Fortschritt und Kosten erscheinen auf stderr, das Ergebnis-JSON auf stdout.
Exit-Codes: `0` Ergebnis zuverlässig, `1` Eingabefehler (Datei, Abbildung, gespeichertes Ergebnis), `2` Ergebnis vorhanden, aber unzuverlässig, `64` falscher Aufruf, `3` abgelehnt – der Text ist keine Optimierungsaufgabe (es wird weder modelliert noch gerechnet; Report und JSON nennen den Grund). Der Ordner `output/` ist in `.gitignore` eingetragen. Ohne Eingabe wird eine eingebaute Beispielaufgabe verwendet. Fortschritt und Kosten erscheinen auf stderr; das Ergebnis-JSON geht mit `-o`/`--ausgabe` in eine Datei, sonst auf eine umgeleitete Standardausgabe – im Terminal erscheint stattdessen eine Kurzfassung. Umgebungsvariablen wie `SOLVER_TIMEOUT` haben gleichnamige Optionen (`--solver-timeout`), die Vorrang haben.
| Option / Variable | Bedeutung | Standard |
|---|---|---|

View file

@ -7,6 +7,7 @@ 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
from core.model_schema import (
SolverStatus,
@ -18,8 +19,9 @@ from core.model_schema import (
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 (SOLVER_TIMEOUT, check_feasibility, check_multistart, check_sensitivity,
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
@ -386,7 +388,7 @@ def continue_pipeline(
return result
def resolve_result(result_json: str, data_file: str | None = None, solver_timeout: int = SOLVER_TIMEOUT,
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.
@ -420,86 +422,250 @@ def resolve_result(result_json: str, data_file: str | None = None, solver_timeou
def read_task(task_file: str | None) -> str:
if task_file:
"""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 not sys.stdin.isatty():
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
return task if task.strip() else task_description
if not task.strip():
log("Keine Aufgabe angegeben – verwende die Beispielaufgabe (Schichtplanung).")
return task_description
return task
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Textaufgabe → Optimierungsmodell → Prüfung → Lösung")
parser.add_argument("task_file", nargs="?", help="Datei mit der Aufgabe (sonst stdin, sonst Beispielaufgabe)")
parser.add_argument("--no-solve", action="store_true", help="nur modellieren und prüfen, keinen Solver-Code ausführen")
parser.add_argument("--max-revisions", type=int, default=MAX_REVISIONS)
parser.add_argument("--max-restarts", type=int, default=MAX_RESTARTS, help="komplette Neustarts, wenn das Modell nach allen Korrekturrunden fehlerhaft ist")
parser.add_argument(
"--model", default=DEFAULT_MODEL,
help=f"Modell (Default: {DEFAULT_MODEL}, Umgebung: LLM_MODEL); mit '/' -> OpenRouter, sonst Gemini",
# 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,
)
parser.add_argument("--resolve", metavar="ERGEBNIS.JSON",
help="gespeichertes Ergebnis erneut rechnen, ohne LLM (z. B. nach Datenänderung)")
parser.add_argument("--von", nargs=2, metavar=("STUFE", "ERGEBNIS.JSON"),
help="gespeichertes Ergebnis ab einer Stufe fortsetzen, statt alles zu wiederholen: "
"'pruefung' (Spezifikation behalten, neu prüfen und korrigieren) oder "
"'solver' (auch den Prüfbericht behalten, nur Solver-Code neu erzeugen)")
parser.add_argument("--daten", nargs="+", metavar="DATEI",
help="beigelegte Datendateien (CSV/Excel); ihr Profil geht in die Prompts, "
"die Dateien selbst ins Arbeitsverzeichnis des Programms "
"(mit --resolve/--von erneut angeben, gespeichert ist nur ihr Profil)")
parser.add_argument("--data", metavar="DATEN.JSON",
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")
parser.add_argument("--report", metavar="DATEI", help="Report mit gerenderten Formeln und Solver-Code (.md oder .html)")
parser.add_argument("--code", metavar="DATEI", help="erzeugten Solver-Code als eigenständiges Python-Skript speichern")
args = parser.parse_args()
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:
if args.resolve:
parser.error("--von und --resolve schließen sich aus")
if args.von[0] not in STAGES:
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.task_file:
parser.error("--von nimmt die Aufgabe aus dem gespeicherten Ergebnis – keine Aufgabendatei angeben")
if args.no_solve:
parser.error("--von endet immer mit der Solver-Stufe; --no-solve wäre wirkungslos")
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}")
if args.resolve:
result = resolve_result(args.resolve, args.data, data_files=args.daten or ())
elif args.von:
result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions,
data_files=args.daten or ())
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 run is None:
spec = result.specification
zeilen.append(f"Modell: {spec.problem_class.value} → {spec.target_stack.value} (kein Solver-Lauf)")
else:
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))
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.")
if result.rejected:
sys.exit(3) # für Skripte: keine Optimierungsaufgabe, nichts gerechnet
if not result.reliable:
sys.exit(2) # für Skripte: Ergebnis vorhanden, aber unzuverlässig
# Erwartet für das Beispiel:
# result.specification.target_stack -> TargetStack.OR_TOOLS_CPSAT
# result.specification.problem_class -> ProblemClass.CP
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())

View file

@ -572,8 +572,9 @@ def solver_env(tmp: str) -> dict:
return {**env, "HOME": tmp, "TMPDIR": tmp, "PYTHONDONTWRITEBYTECODE": "1"}
def run_solver_code(code: str, timeout: int = SOLVER_TIMEOUT, data: dict | None = None,
def run_solver_code(code: str, timeout: int | None = None, data: dict | None = None,
data_files=()) -> SolverRun:
timeout = timeout or SOLVER_TIMEOUT # zur Aufrufzeit, damit --solver-timeout wirkt
try:
validate_imports(code)
except CodeRejected as exc:
@ -815,7 +816,7 @@ def check_text_quality(run, summary: str = "") -> list:
MAX_SCENARIOS = 5 # jedes Szenario kostet einen weiteren Programmlauf (kein LLM-Aufruf)
def verify_scenarios(run, code: str, timeout: int = SOLVER_TIMEOUT, data_files=()) -> list:
def verify_scenarios(run, code: str, timeout: int | None = None, data_files=()) -> list:
"""Jedes Szenario mit übersteuerten Daten NEU rechnen und den gemeldeten Zielwert bestätigen.
Ein Szenario ist eine Datenvariante desselben Programms – dieselbe Maschinerie wie `--resolve`.
@ -1163,7 +1164,7 @@ def _check_and_run(code: str, timeout: int, spec: OptimizationModelSpecification
def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification,
retries: int = 2, timeout: int = SOLVER_TIMEOUT, log=lambda msg: None, understanding=None,
retries: int = 2, timeout: int | None = None, log=lambda msg: None, understanding=None,
data_files=(), data_prompt: str = "") -> tuple:
"""Code erzeugen und ausführen; bei Fehlern bis zu `retries` Reparaturversuche.

124
tests/test_cli.py Normal file
View file

@ -0,0 +1,124 @@
"""CLI `TextToModel.main`: Optionen, Defaults, Ausgabe und Exit-Codes – ohne LLM."""
import json
import pytest
import TextToModel
from core import llm
from core.model_schema import PipelineResult
from solving import solver_stage
from test_resolve import gespeichertes_ergebnis
@pytest.fixture(autouse=True)
def einstellungen_zuruecksetzen(monkeypatch):
# main() setzt Modulkonstanten; monkeypatch stellt sie nach jedem Test wieder her
for modul, name in ((TextToModel, "UNDERSTAND_RETRIES"), (TextToModel, "INFEASIBLE_RECHECK"),
(llm, "REASONING_EFFORT"), (llm, "LLM_MAX_TOKENS"), (llm, "LLM_TIMEOUT"),
(solver_stage, "SOLVER_TIMEOUT"), (solver_stage, "SANDBOX")):
monkeypatch.setattr(modul, name, getattr(modul, name))
def aufruffehler(argv, capsys) -> str:
with pytest.raises(SystemExit) as exc:
TextToModel.main(argv)
assert exc.value.code == TextToModel.EXIT_USAGE # nicht 2 – das hieße „unzuverlässig“
return capsys.readouterr().err
def test_resolve_writes_json_report_and_code_with_prefix(tmp_path):
ergebnis = gespeichertes_ergebnis(tmp_path)
praefix = tmp_path / "neu" / "lauf" # Ordner existiert noch nicht
assert TextToModel.main(["--resolve", str(ergebnis), "-a", str(praefix)]) == TextToModel.EXIT_OK
neu = PipelineResult.model_validate_json((tmp_path / "neu" / "lauf.json").read_text(encoding="utf-8"))
assert neu.solver_run.objective_value == pytest.approx(9)
assert (tmp_path / "neu" / "lauf.html").exists() and (tmp_path / "neu" / "lauf.py").exists()
def test_explicit_output_wins_over_prefix(tmp_path):
ergebnis = gespeichertes_ergebnis(tmp_path)
TextToModel.main(["--resolve", str(ergebnis), "-a", str(tmp_path / "p"), "--report", str(tmp_path / "r.md")])
assert (tmp_path / "r.md").exists() and not (tmp_path / "p.html").exists()
def test_json_goes_to_redirected_stdout(tmp_path, capsys):
ergebnis = gespeichertes_ergebnis(tmp_path)
daten = tmp_path / "daten.json"
daten.write_text(json.dumps({"bedarf": 5}), encoding="utf-8")
assert TextToModel.main(["--resolve", str(ergebnis), "--data", str(daten)]) == TextToModel.EXIT_OK
assert json.loads(capsys.readouterr().out)["solver_run"]["objective_value"] == pytest.approx(12)
def test_terminal_gets_a_summary_instead_of_json(tmp_path, capsys, monkeypatch):
monkeypatch.setattr("sys.stdout.isatty", lambda: True)
TextToModel.main(["--resolve", str(gespeichertes_ergebnis(tmp_path))])
out = capsys.readouterr().out
assert "Zielwert: 9" in out and "Zuverlässig." in out and not out.lstrip().startswith("{")
def test_unreliable_result_exits_with_2(tmp_path):
daten = tmp_path / "daten.json"
daten.write_text(json.dumps({"kosten": "keine Zahl"}), encoding="utf-8")
code = TextToModel.main(["--resolve", str(gespeichertes_ergebnis(tmp_path)), "--data", str(daten),
"-o", str(tmp_path / "x.json")])
assert code == TextToModel.EXIT_UNRELIABLE
def test_broken_result_file_is_an_error_not_a_traceback(tmp_path):
kaputt = tmp_path / "kaputt.json"
kaputt.write_text("{}", encoding="utf-8")
assert TextToModel.main(["--resolve", str(kaputt)]) == TextToModel.EXIT_ERROR
@pytest.mark.parametrize("argv, meldung", [
(["--data", "d.json"], "--data wirkt nur"),
(["a.txt", "--report", "r.pdf"], "Endung"),
(["a.txt", "--no-solve", "--code", "c.py"], "--no-solve"),
(["--von", "extraktion", "e.json"], "Stufe"),
(["--von", "solver", "e.json", "--no-solve"], "not allowed"),
(["--max-revisions", "-1"], "negativ"),
(["--solver-timeout", "0"], "positiv"),
(["fehlt.txt"], "Datei nicht gefunden"),
])
def test_wrong_calls_are_usage_errors(argv, meldung, capsys):
assert meldung in aufruffehler(argv, capsys)
def test_task_file_with_resolve_is_rejected(tmp_path, capsys):
ergebnis = gespeichertes_ergebnis(tmp_path)
assert "keine Aufgabendatei" in aufruffehler([str(ergebnis), "--resolve", str(ergebnis)], capsys)
def test_options_reach_pipeline_and_modules(tmp_path, monkeypatch):
aufgabe = tmp_path / "aufgabe.md"
aufgabe.write_text("Maximiere x unter x ≤ 3.", encoding="utf-8")
gesehen = {}
def fake_pipeline(task, **kwargs):
gesehen.update(kwargs, task=task)
return PipelineResult(task=task, rejected=True, reliable=False, reliability_issues=["keine Aufgabe"])
monkeypatch.setattr(TextToModel, "run_pipeline", fake_pipeline)
code = TextToModel.main([str(aufgabe), "-m", "x/y", "--max-revisions", "0", "--solver-retries", "4",
"--max-restarts", "0", "--understand-retries", "-1", "--infeasible-recheck", "0",
"--reasoning", "low", "--max-tokens", "1000", "--llm-timeout", "30",
"--solver-timeout", "90", "--sandbox", "bwrap", "-o", str(tmp_path / "e.json")])
assert code == TextToModel.EXIT_REJECTED
assert gesehen["task"].startswith("Maximiere") and gesehen["task_dir"] == tmp_path
assert (gesehen["model"], gesehen["max_revisions"], gesehen["solver_retries"], gesehen["max_restarts"]) \
== ("x/y", 0, 4, 0)
assert gesehen["solve_model"] is True
assert (TextToModel.UNDERSTAND_RETRIES, TextToModel.INFEASIBLE_RECHECK) == (-1, 0)
assert (llm.REASONING_EFFORT, llm.LLM_MAX_TOKENS, llm.LLM_TIMEOUT) == ("low", 1000, 30)
assert (solver_stage.SOLVER_TIMEOUT, solver_stage.SANDBOX) == (90, "bwrap")
assert "90" in solver_stage.solver_prompt() # Zeitbudget im Prompt folgt der Option
def test_without_options_the_defaults_stay(tmp_path, monkeypatch):
vorher = (llm.LLM_MAX_TOKENS, solver_stage.SOLVER_TIMEOUT, solver_stage.SANDBOX, TextToModel.UNDERSTAND_RETRIES)
monkeypatch.setattr(TextToModel, "run_pipeline", lambda task, **kw: PipelineResult(task=task, reliable=True))
aufgabe = tmp_path / "a.txt"
aufgabe.write_text("Minimiere x.", encoding="utf-8")
TextToModel.main([str(aufgabe), "-o", str(tmp_path / "e.json")])
assert (llm.LLM_MAX_TOKENS, solver_stage.SOLVER_TIMEOUT, solver_stage.SANDBOX,
TextToModel.UNDERSTAND_RETRIES) == vorher