From 422360ffa5b661feb77cd7cfe9ac02ba05b9ca01 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 18 Sep 2026 18:34:42 +0200 Subject: [PATCH] feat: TextToModel.py als CLI-Programm mit main(), gruppierten Optionen und Defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- BEDIENUNGSANLEITUNG.md | 30 ++++- README.md | 9 +- TextToModel.py | 284 +++++++++++++++++++++++++++++++--------- solving/solver_stage.py | 7 +- tests/test_cli.py | 124 ++++++++++++++++++ 5 files changed, 383 insertions(+), 71 deletions(-) create mode 100644 tests/test_cli.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 1cb5170..33adecd 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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 | diff --git a/README.md b/README.md index bd2db8e..8b6a001 100644 --- a/README.md +++ b/README.md @@ -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 | |---|---|---| diff --git a/TextToModel.py b/TextToModel.py index eba6a27..0b6fa2c 100644 --- a/TextToModel.py +++ b/TextToModel.py @@ -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", - 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() + + eingabe = parser.add_argument_group("Eingabe") + eingabe.add_argument("aufgabe", nargs="?", metavar="AUFGABE", + help="Datei mit der Aufgabe (Text/Markdown); '-' = stdin. Fehlt sie und ist stdin nicht " + "umgeleitet, läuft die Beispielaufgabe") + eingabe.add_argument("--daten", nargs="+", metavar="DATEI", + help="beigelegte Datendateien (CSV/Excel); ihr Profil geht in die Prompts, die Dateien " + "ins Arbeitsverzeichnis des Programms (bei --resolve/--von erneut angeben)") + + modus = parser.add_argument_group("Modus (höchstens einer; ohne Angabe: kompletter Lauf)") + exklusiv = modus.add_mutually_exclusive_group() + exklusiv.add_argument("--no-solve", action="store_true", + help="nur verstehen, modellieren und prüfen – kein Solver-Code") + exklusiv.add_argument("--von", nargs=2, metavar=("STUFE", "ERGEBNIS.JSON"), + help="gespeichertes Ergebnis fortsetzen: 'pruefung' (Spezifikation behalten, neu prüfen " + "und korrigieren) oder 'solver' (auch Prüfbericht behalten, nur Solver-Code neu)") + exklusiv.add_argument("--resolve", metavar="ERGEBNIS.JSON", + help="gespeichertes Ergebnis ohne LLM neu rechnen (z. B. nach Datenänderung)") + modus.add_argument("--data", metavar="DATEN.JSON", + help="mit --resolve: Eingabewerte (DATA) des Programms ganz oder teilweise ersetzen") + + ablauf = parser.add_argument_group("Ablauf") + ablauf.add_argument("--max-revisions", type=int, default=MAX_REVISIONS, metavar="N", + help=f"Korrekturrunden je Modellierung (MAX_REVISIONS, {MAX_REVISIONS})") + ablauf.add_argument("--max-restarts", type=int, default=MAX_RESTARTS, metavar="N", + help=f"Neustarts der Modellierung, wenn sie fehlerhaft bleibt (MAX_RESTARTS, {MAX_RESTARTS})") + ablauf.add_argument("--solver-retries", type=int, default=SOLVER_RETRIES, metavar="N", + help=f"Reparaturen des Solver-Codes (SOLVER_RETRIES, {SOLVER_RETRIES})") + ablauf.add_argument("--understand-retries", type=int, metavar="N", + help=f"Nachfassen beim Verstehen, -1 = Stufe aus (UNDERSTAND_RETRIES, {UNDERSTAND_RETRIES})") + ablauf.add_argument("--infeasible-recheck", type=int, choices=(0, 1), metavar="{0,1}", + help=f"Gegenprobe bei 'unlösbar' (INFEASIBLE_RECHECK, {INFEASIBLE_RECHECK})") + + ki = parser.add_argument_group("LLM") + ki.add_argument("-m", "--model", default=DEFAULT_MODEL, + help=f"Modell; mit '/' → OpenRouter, sonst Gemini (LLM_MODEL, {DEFAULT_MODEL})") + ki.add_argument("--reasoning", choices=("low", "medium", "high"), + help="Denkaufwand, nur OpenRouter (LLM_REASONING_EFFORT, Modellvorgabe)") + ki.add_argument("--max-tokens", type=int, metavar="N", + help=f"Antwortlänge je Aufruf (LLM_MAX_TOKENS, {llm.LLM_MAX_TOKENS})") + ki.add_argument("--llm-timeout", type=int, metavar="SEK", + help=f"Zeitlimit je Aufruf (LLM_TIMEOUT, {llm.LLM_TIMEOUT})") + + solver = parser.add_argument_group("Solver") + solver.add_argument("--solver-timeout", type=int, metavar="SEK", + help=f"Zeitlimit je Programmlauf, geht auch als Zeitbudget ans LLM " + f"(SOLVER_TIMEOUT, {solver_stage.SOLVER_TIMEOUT})") + solver.add_argument("--sandbox", choices=("auto", "bwrap", "aus"), + help=f"Isolierung des erzeugten Codes (SOLVER_SANDBOX, {solver_stage.SANDBOX})") + + ausgabe = parser.add_argument_group("Ausgabe") + ausgabe.add_argument("-o", "--json", metavar="DATEI", help="Ergebnis-JSON in diese Datei statt nach stdout") + ausgabe.add_argument("--report", metavar="DATEI", help="Report mit Formeln und Solver-Code (.md oder .html)") + ausgabe.add_argument("--code", metavar="DATEI", help="Solver-Code als eigenständiges Python-Skript") + ausgabe.add_argument("-a", "--ausgabe", metavar="PRÄFIX", + help="Kurzform für -o PRÄFIX.json --report PRÄFIX.html --code PRÄFIX.py " + "(ausdrückliche Angaben haben Vorrang)") + return parser + + +def _check_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: + if args.ausgabe: + args.json = args.json or f"{args.ausgabe}.json" + args.report = args.report or f"{args.ausgabe}.html" + if not args.no_solve: + args.code = args.code or f"{args.ausgabe}.py" if args.report and Path(args.report).suffix.lower() not in (".md", ".html", ".htm"): parser.error("--report braucht die Endung .md oder .html") if args.code and args.no_solve: parser.error("--code ist mit --no-solve nicht möglich (es wird kein Solver-Code erzeugt)") if args.data and not args.resolve: parser.error("--data wirkt nur zusammen mit --resolve") - if args.von: - if args.resolve: - parser.error("--von und --resolve schließen sich aus") - if 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 and args.von[0] not in STAGES: + parser.error(f"--von braucht eine Stufe aus {{{', '.join(STAGES)}}}, nicht '{args.von[0]}'") + if (args.von or args.resolve) and args.aufgabe: + parser.error("--von/--resolve nehmen die Aufgabe aus dem gespeicherten Ergebnis – keine Aufgabendatei angeben") + for name, wert in (("--max-revisions", args.max_revisions), ("--max-restarts", args.max_restarts), + ("--solver-retries", args.solver_retries)): + if wert < 0: + parser.error(f"{name} darf nicht negativ sein") + for name, wert in (("--max-tokens", args.max_tokens), ("--llm-timeout", args.llm_timeout), + ("--solver-timeout", args.solver_timeout)): + if wert is not None and wert <= 0: + parser.error(f"{name} muss positiv sein") + eingaben = [args.aufgabe if args.aufgabe != "-" else None, args.von[1] if args.von else None, + args.resolve, args.data, *(args.daten or [])] + for datei in eingaben: + if datei and not Path(datei).is_file(): + parser.error(f"Datei nicht gefunden: {datei}") - 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()) diff --git a/solving/solver_stage.py b/solving/solver_stage.py index 164b613..27ef88f 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -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. diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..277e1f8 --- /dev/null +++ b/tests/test_cli.py @@ -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