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 ## 5. Aufrufvarianten
```bash ```bash
# Aufgabe aus einer Datei # Aufgabe aus einer Datei – JSON, Report und Solver-Programm auf einen Schlag
.venv/bin/python TextToModel.py aufgabe.md --report output/aufgabe.html > output/aufgabe.json .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 # Aufgabe über die Standardeingabe
echo "Ein Betrieb …" | .venv/bin/python TextToModel.py --report output/aufgabe.md > output/aufgabe.json 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`. - `--report` braucht die Endung `.html`, `.htm` oder `.md`.
- `--code` ist mit `--no-solve` nicht möglich – es entsteht dann kein Programm. - `--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. - 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 | | Code | Bedeutung |
|---|---| |---|---|
| `0` | Ergebnis zuverlässig | | `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 | | `2` | Ergebnis vorhanden, aber **unzuverlässig** – Gründe in `reliability_issues` und im Warnkasten des Reports |
| `3` | **abgelehnt** – keine Optimierungsaufgabe, nichts gerechnet | | `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: 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 | | 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` | | `--report DATEI` | Report als `.html`/`.htm` oder `.md` |
| `--code DATEI` | Solver-Programm als ausführbares Skript speichern | | `--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 | | `--daten DATEI …` | beigelegte CSV-/Excel-Dateien |
| `--no-solve` | keinen Solver-Code erzeugen | | `--no-solve` | keinen Solver-Code erzeugen |
| `--max-revisions N` | Korrekturrunden | | `--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)**. Ausführliche Schritt-für-Schritt-Anleitung – Aufgaben formulieren, Ergebnis lesen, Weiterarbeiten ohne LLM-Kosten, Fehlerbehebung: **[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)**.
```bash ```bash
# Aufgabe aus Datei lösen, Report und Solver-Code speichern # Aufgabe aus Datei lösen: output/ergebnis.json, .html (Report) und .py (Solver-Programm)
.venv/bin/python TextToModel.py aufgabe.txt --report output/modell.html --code output/solve.py > output/ergebnis.json .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 # Aufgabe über stdin
echo "Ein Betrieb ..." | .venv/bin/python TextToModel.py --report output/modell.md 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 .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 | | 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.model_checks import check_figure_readings, check_spec, check_understanding
from checks.review import needs_work, review_assumptions, revision_reasons, select_best 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.llm import DEFAULT_MODEL, LLMSession
from core.model_schema import ( from core.model_schema import (
SolverStatus, SolverStatus,
@ -18,8 +19,9 @@ from core.model_schema import (
from data import data_profile from data import data_profile
from modeling.extraction import revise_model, text_to_model from modeling.extraction import revise_model, text_to_model
from reporting.report import write_report from reporting.report import write_report
from solving import solver_stage
from solving.script_export import build_solver_script 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) run_solver_code, solve)
from understanding.figures import FigureError, normalize_figure_ids, split_figures, with_transcriptions from understanding.figures import FigureError, normalize_figure_ids, split_figures, with_transcriptions
from understanding.understand import read_figures, understand_task from understanding.understand import read_figures, understand_task
@ -386,7 +388,7 @@ def continue_pipeline(
return result 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: data_files=()) -> PipelineResult:
"""Gespeichertes Ergebnis mit (geänderten) Daten neu rechnen – ohne einen einzigen LLM-Aufruf. """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: 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") 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() task = sys.stdin.read()
else: else:
task = "" task = ""
# Leere Eingabe würde das LLM ein beliebiges Problem erfinden lassen # 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__": # Exit-Codes für Skripte. Aufruffehler bewusst nicht argparse' 2 – das hieße hier „unzuverlässig“.
parser = argparse.ArgumentParser(description="Textaufgabe → Optimierungsmodell → Prüfung → Lösung") EXIT_OK, EXIT_ERROR, EXIT_UNRELIABLE, EXIT_REJECTED, EXIT_USAGE, EXIT_INTERRUPTED = 0, 1, 2, 3, 64, 130
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") EPILOG = f"""\
parser.add_argument("--max-revisions", type=int, default=MAX_REVISIONS) Beispiele:
parser.add_argument("--max-restarts", type=int, default=MAX_RESTARTS, help="komplette Neustarts, wenn das Modell nach allen Korrekturrunden fehlerhaft ist") %(prog)s aufgabe.md --ausgabe output/aufgabe # schreibt output/aufgabe.json, .html und .py
parser.add_argument( %(prog)s aufgabe.md --daten kurse.csv -o output/aufgabe.json --report output/aufgabe.html
"--model", default=DEFAULT_MODEL, echo "Ein Betrieb …" | %(prog)s - --report output/modell.md
help=f"Modell (Default: {DEFAULT_MODEL}, Umgebung: LLM_MODEL); mit '/' -> OpenRouter, sonst Gemini", %(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)") eingabe = parser.add_argument_group("Eingabe")
parser.add_argument("--von", nargs=2, metavar=("STUFE", "ERGEBNIS.JSON"), eingabe.add_argument("aufgabe", nargs="?", metavar="AUFGABE",
help="gespeichertes Ergebnis ab einer Stufe fortsetzen, statt alles zu wiederholen: " help="Datei mit der Aufgabe (Text/Markdown); '-' = stdin. Fehlt sie und ist stdin nicht "
"'pruefung' (Spezifikation behalten, neu prüfen und korrigieren) oder " "umgeleitet, läuft die Beispielaufgabe")
"'solver' (auch den Prüfbericht behalten, nur Solver-Code neu erzeugen)") eingabe.add_argument("--daten", nargs="+", metavar="DATEI",
parser.add_argument("--daten", nargs="+", metavar="DATEI", help="beigelegte Datendateien (CSV/Excel); ihr Profil geht in die Prompts, die Dateien "
help="beigelegte Datendateien (CSV/Excel); ihr Profil geht in die Prompts, " "ins Arbeitsverzeichnis des Programms (bei --resolve/--von erneut angeben)")
"die Dateien selbst ins Arbeitsverzeichnis des Programms "
"(mit --resolve/--von erneut angeben, gespeichert ist nur ihr Profil)") modus = parser.add_argument_group("Modus (höchstens einer; ohne Angabe: kompletter Lauf)")
parser.add_argument("--data", metavar="DATEN.JSON", exklusiv = modus.add_mutually_exclusive_group()
help="mit --resolve: Eingabewerte (DATA) des Programms ganz oder teilweise ersetzen") exklusiv.add_argument("--no-solve", action="store_true",
parser.add_argument("--report", metavar="DATEI", help="Report mit gerenderten Formeln und Solver-Code (.md oder .html)") help="nur verstehen, modellieren und prüfen – kein Solver-Code")
parser.add_argument("--code", metavar="DATEI", help="erzeugten Solver-Code als eigenständiges Python-Skript speichern") exklusiv.add_argument("--von", nargs=2, metavar=("STUFE", "ERGEBNIS.JSON"),
args = parser.parse_args() 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"): if args.report and Path(args.report).suffix.lower() not in (".md", ".html", ".htm"):
parser.error("--report braucht die Endung .md oder .html") parser.error("--report braucht die Endung .md oder .html")
if args.code and args.no_solve: if args.code and args.no_solve:
parser.error("--code ist mit --no-solve nicht möglich (es wird kein Solver-Code erzeugt)") parser.error("--code ist mit --no-solve nicht möglich (es wird kein Solver-Code erzeugt)")
if args.data and not args.resolve: if args.data and not args.resolve:
parser.error("--data wirkt nur zusammen mit --resolve") parser.error("--data wirkt nur zusammen mit --resolve")
if args.von: if args.von and args.von[0] not in STAGES:
if args.resolve: parser.error(f"--von braucht eine Stufe aus {{{', '.join(STAGES)}}}, nicht '{args.von[0]}'")
parser.error("--von und --resolve schließen sich aus") if (args.von or args.resolve) and args.aufgabe:
if args.von[0] not in STAGES: parser.error("--von/--resolve nehmen die Aufgabe aus dem gespeicherten Ergebnis – keine Aufgabendatei angeben")
parser.error(f"--von braucht eine Stufe aus {{{', '.join(STAGES)}}}, nicht '{args.von[0]}'") for name, wert in (("--max-revisions", args.max_revisions), ("--max-restarts", args.max_restarts),
if args.task_file: ("--solver-retries", args.solver_retries)):
parser.error("--von nimmt die Aufgabe aus dem gespeicherten Ergebnis – keine Aufgabendatei angeben") if wert < 0:
if args.no_solve: parser.error(f"{name} darf nicht negativ sein")
parser.error("--von endet immer mit der Solver-Stufe; --no-solve wäre wirkungslos") 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 ()) def apply_settings(args: argparse.Namespace) -> None:
elif args.von: """Optionen, die sonst nur als Umgebungsvariable wirken, für diesen Prozess setzen.
result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions,
data_files=args.daten or ()) 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: else:
try: zeilen.append(f"Status: {run.status.value}" + (f" · Zielwert: {run.objective_value:g}"
result = run_pipeline( if run.objective_value is not None else ""))
read_task(args.task_file), model=args.model, if run.headline:
max_revisions=args.max_revisions, solve_model=not args.no_solve, max_restarts=args.max_restarts, zeilen.append(run.headline)
data_files=args.daten or (), task_dir=Path(args.task_file).parent if args.task_file else None, zeilen += [f" {a.question_id}) {a.text}" for a in run.answers]
) zeilen.append("Zuverlässig." if result.reliable
except FigureError as exc: else "UNZUVERLÄSSIG: " + "; ".join(result.reliability_issues))
parser.error(str(exc)) return "\n".join(zeilen)
print(result.model_dump_json(indent=2))
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: if args.report:
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
write_report(result, args.report) write_report(result, args.report)
log(f"Report geschrieben: {args.report}") log(f"Report geschrieben: {args.report}")
if args.code and result.solver_code: 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).write_text(build_solver_script(result), encoding="utf-8")
Path(args.code).chmod(0o755) Path(args.code).chmod(0o755)
log(f"Solver-Code geschrieben: {args.code} (ausführen: .venv/bin/python {args.code})") log(f"Solver-Code geschrieben: {args.code} (ausführen: .venv/bin/python {args.code})")
elif args.code: elif args.code:
log(f"Kein Solver-Code geschrieben ({args.code}): Solver wurde nicht gestartet.") 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 def main(argv: List[str] | None = None) -> int:
# result.specification.problem_class -> ProblemClass.CP 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"} 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: data_files=()) -> SolverRun:
timeout = timeout or SOLVER_TIMEOUT # zur Aufrufzeit, damit --solver-timeout wirkt
try: try:
validate_imports(code) validate_imports(code)
except CodeRejected as exc: 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) 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. """Jedes Szenario mit übersteuerten Daten NEU rechnen und den gemeldeten Zielwert bestätigen.
Ein Szenario ist eine Datenvariante desselben Programms – dieselbe Maschinerie wie `--resolve`. 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, 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: data_files=(), data_prompt: str = "") -> tuple:
"""Code erzeugen und ausführen; bei Fehlern bis zu `retries` Reparaturversuche. """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