From 8697400dc27fdd6dc73d39e8b6834a914c8885a8 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 17 Sep 2026 12:54:54 +0200 Subject: [PATCH] feat: Nicht-Optimierungsaufgaben ablehnen statt modellieren Bisher erzeugte is_optimization_task=False nur eine Log-Warnung; eine reine Gleichung wurde trotzdem in ein Optimierungsmodell gepresst. Jetzt bricht run_pipeline direkt nach dem Verstehen ab: kein Nachfassen, keine Extraktion, kein Solver. PipelineResult.rejected, reliable=False, specification/review optional; Report "Aufgabe abgelehnt", CLI-Exit-Code 3, Benchmark-Status "abgelehnt" (auch die Ergebnistabelle verkraftet die Zeile), --von verweigert. Der Verstehens-Prompt nennt reine Gleichungen/Rechenaufgaben als Nicht-Aufgaben und Zulaessigkeitsfragen sowie moeglicherweise unloesbare Planungen ausdruecklich als Aufgaben. Unvermessen; Prompt-Kennung jetzt 0333bfd5f9fe. Co-Authored-By: Claude Opus 5 --- README.md | 7 +++--- TextToModel.py | 18 +++++++++++--- benchmark/benchmark.py | 13 ++++++++++ core/model_schema.py | 9 ++++--- reporting/report.py | 16 ++++++++++++ tests/test_pipeline.py | 39 ++++++++++++++++++++++++++++++ understanding/Verstehen-Prompt.txt | 2 +- 7 files changed, 93 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3c8bdb9..629da53 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ Aufgabentext (+ optional beigelegte Daten: --daten kurse.csv) │ ohne LLM: Profil je Datei – Zeilen, Spalten, Spannweiten, Lücken ▼ 1. Verstehen ───────── LLM: Teilfragen a)/b)/…, alle Zahlen des Textes mit Fundstelle, - │ understanding/ übergangene Zahlen mit Grund; Hinweis, wenn es keine - │ Optimierungsaufgabe ist (die Pipeline läuft trotzdem weiter) + │ understanding/ übergangene Zahlen mit Grund; keine Optimierungsaufgabe + │ (z. B. reine Gleichung) ⇒ abgelehnt, Ende │ ohne LLM geprüft: keine Zahl, keine Teilfrage übersehen ▼ 2. Extraktion ──────── LLM: Problemklasse, Solver-Wahl, Indizes, Parameter, @@ -93,7 +93,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 ``` -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, `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. | Option / Variable | Bedeutung | Standard | |---|---|---| @@ -138,6 +138,7 @@ cp kurse.csv output/ && (cd output && ../.venv/bin/python solve.py) Das LLM kann Fehler machen – deshalb gibt es mehrere Sicherungen: +- **Nur echte Aufgaben:** Verlangt der Text keine Entscheidung – etwa eine Gleichung, die nur ausgerechnet werden soll –, wird er abgelehnt, statt ein Optimierungsmodell zu erfinden. - **Zielgrößen-Prüfung:** erkennt z. B., wenn die Varianz statt der gefragten Volatilität minimiert wird. - **Annahmen-Prüfung:** Unklarheiten im Text müssen als Annahme ausgewiesen werden und werden gegen den Text geprüft (z. B. „wenn A, dann B“ ist keine Äquivalenz). - **Zulässigkeitsprüfung ohne LLM:** Die gefundene Lösung wird in alle Restriktionen eingesetzt, Schranken und Ganzzahligkeit geprüft und abgeglichen, ob jede Restriktion des Modells im Programm vorkommt. Verletzungen lösen eine Reparatur aus – Solver-Toleranzen (beobachtet: 8 Cent über dem Budget) fallen damit auf. diff --git a/TextToModel.py b/TextToModel.py index f98eb07..01a6d6c 100644 --- a/TextToModel.py +++ b/TextToModel.py @@ -55,8 +55,8 @@ def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple: understanding = understand_task(session, task, data_prompt=data_prompt) issues = check_understanding(understanding, task) for versuch in range(UNDERSTAND_RETRIES): - if not issues: - break + if not issues or not understanding.is_optimization_task: + break # eine Nicht-Aufgabe wird abgelehnt, nicht vervollständigt log(f"[{label}] Verständnis unvollständig – fasse nach ({versuch + 1}/{UNDERSTAND_RETRIES}): " + "; ".join(i.code for i in issues)) understanding = understand_task(session, task, issues, data_prompt) @@ -65,8 +65,6 @@ def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple: log(f" {len(understanding.sub_questions)} Teilfrage(n): " + ", ".join(q.id for q in understanding.sub_questions) + f"; {len(understanding.facts)} Zahlenwerte erfasst") - if not understanding.is_optimization_task: - log(f" WARNUNG: Das ist laut Verständnis keine Optimierungsaufgabe – {understanding.reason}") return understanding, issues @@ -221,6 +219,14 @@ def run_pipeline( log(f"[{model}] Stufe '{step}' läuft mit abweichendem Modell: {step_model}") understanding, verstehen_issues = _understand(session, task, model, data_prompt) + if not understanding.is_optimization_task: + # Keine Entscheidung verlangt: jedes Modell wäre erfunden, jede Zahl ohne Frage dahinter + grund = f"Keine Optimierungsaufgabe: {understanding.reason}" + log(f"ABGELEHNT – {grund} Es wird weder modelliert noch gerechnet.") + result = PipelineResult(task=task, understanding=understanding, data_files=profile, rejected=True, + reliable=False, reliability_issues=[grund], checks=verstehen_issues) + _log_usage(session, result) + return result attempts = [_model_attempt(session, task, max_revisions, model, understanding=understanding, data_prompt=data_prompt)] @@ -273,6 +279,8 @@ def continue_pipeline( if stage not in STAGES: raise ValueError(f"Unbekannte Stufe '{stage}' (erlaubt: {', '.join(STAGES)})") stored = PipelineResult.model_validate_json(Path(result_json).read_text(encoding="utf-8")) + if stored.rejected or stored.specification is None: + raise ValueError(f"{result_json} ist eine abgelehnte Aufgabe (keine Optimierungsaufgabe) – nichts fortzusetzen") session = session or LLMSession(model) task = stored.task @@ -400,6 +408,8 @@ if __name__ == "__main__": 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 diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 7a1a309..1b783b6 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -115,6 +115,19 @@ def run_case(case: dict, model: str, run_no: int) -> dict: return row spec, solver_run = result.specification, result.solver_run + if spec is None: # abgelehnt: keine Optimierungsaufgabe erkannt + fragen = len(result.understanding.sub_questions) if result.understanding else 0 + row.update(seconds=round(time.monotonic() - start), problem_class=None, target_stack=None, + class_ok=False, stack_ok=False, check_errors=0, check_warnings=len(result.checks), + revisions=0, review_ok=None, data_assumptions=0, solver_status="abgelehnt", solver_attempts=0, + objective_value=None, objective_ok=False, questions=fragen, answered=0, answer_findings=0, + task_ok=False, solver_error="; ".join(result.reliability_issues), reliable=False, restarts=0, + providers=result.usage.providers if result.usage else {}, + llm_calls=result.usage.calls if result.usage else 0, + reasoning_tokens=result.usage.reasoning_tokens if result.usage else 0, + cost_usd=result.usage.cost_usd if result.usage else None, + result=json.loads(result.model_dump_json())) + return row # "Aufgabe erfüllt" statt nur "Zahl richtig": alle Teilfragen beantwortet, keine unbelegte Zahl fragen = {q.id for q in result.understanding.sub_questions} if result.understanding else set() antworten = {a.question_id for a in solver_run.answers} if solver_run else set() diff --git a/core/model_schema.py b/core/model_schema.py index 6c7befd..17e313d 100644 --- a/core/model_schema.py +++ b/core/model_schema.py @@ -424,9 +424,12 @@ class PipelineResult(BaseModel): default_factory=list, description="Profile der beigelegten Datendateien (--daten)") understanding: Optional[TaskUnderstanding] = Field( default=None, description="Ergebnis der Verstehensstufe (Teilfragen, Fakten); None bei alten Ergebnissen") - specification: OptimizationModelSpecification - review: AssumptionReview - revisions: int = Field(description="Anzahl durchgeführter Korrekturrunden") + rejected: bool = Field( + default=False, description="True, wenn der Text keine Optimierungsaufgabe ist – dann wird nicht modelliert") + specification: Optional[OptimizationModelSpecification] = Field( + default=None, description="Modellspezifikation; None nur bei abgelehnten Aufgaben") + review: Optional[AssumptionReview] = Field(default=None, description="Prüfbericht; None nur bei abgelehnten Aufgaben") + revisions: int = Field(default=0, description="Anzahl durchgeführter Korrekturrunden") selected_revision: int = Field(default=0, description="Runde, deren Spezifikation verwendet wurde (0 = Erstentwurf)") restarts: int = Field(default=0, description="Anzahl kompletter Neustarts der Modellierung") reliable: bool = Field(default=True, description="False, wenn nach allen Runden/Neustarts Zielgröße oder Konsistenz nicht stimmen") diff --git a/reporting/report.py b/reporting/report.py index dc5e69f..9d07d9d 100644 --- a/reporting/report.py +++ b/reporting/report.py @@ -303,8 +303,24 @@ def _fmt_number(value) -> str: return str(value) +def _rejected_document(result: PipelineResult) -> list: + """Abgelehnte Aufgabe: Grund und Aufgabentext – kein Modell, keine Zahlen.""" + grund = result.understanding.reason if result.understanding else "; ".join(result.reliability_issues) + doc = [("h1", "Aufgabe abgelehnt"), + ("warn", f"Keine Optimierungsaufgabe – es wurde weder modelliert noch gerechnet. {grund}")] + if result.usage: + cost = f", Kosten USD {result.usage.cost_usd:.4f}" if result.usage.cost_usd is not None else "" + doc.append(("list", [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}", f"Modell: {result.usage.model}", + f"LLM: {result.usage.calls} Aufrufe{cost}"])) + if result.task.strip(): + doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + return doc + + def build_document(result: PipelineResult, model: str = "") -> list: spec, review = result.specification, result.review + if result.rejected or spec is None: + return _rejected_document(result) doc = [("h1", "Optimierungsmodell")] if not result.reliable: doc.append(("warn", "Ergebnis unzuverlässig – bitte nicht verwenden. " diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 4555c42..ab3a431 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -543,3 +543,42 @@ def test_persistently_undeclared_data_is_unreliable(): result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, solver_retries=1) assert not result.reliable assert any(p.startswith("Datenherkunft") for p in result.reliability_issues) + + +# Beobachtet: eine reine Gleichung (x! - 2x^2 + 1337 = 3629937) wurde nur mit Log-Warnung weiter modelliert +KEINE_AUFGABE = TaskUnderstanding( + is_optimization_task=False, reason="Eine Gleichung soll gelöst werden; es gibt nichts zu entscheiden.", + facts=[], ignored_numbers=[], + sub_questions=[SubQuestion(id="1", quote="x! - 2x**2 + 1337 = 3629937", kind="rechnen", needs_solver=True)]) + + +def test_non_optimization_task_is_rejected_without_modeling(tmp_path): + from reporting.report import write_report + session = FakeSession(specs=[], reviews=[], understandings=[KEINE_AUFGABE]) + result = run_pipeline("x! - 2x**2 + 1337 = 3629937", session=session) + assert result.rejected and not result.reliable + assert result.specification is None and result.solver_run is None + assert session.steps == ["Verstehen"] # kein Nachfassen, keine Extraktion, kein Solver + assert "Keine Optimierungsaufgabe" in result.reliability_issues[0] + for endung in ("md", "html"): + ziel = tmp_path / f"abgelehnt.{endung}" + write_report(result, str(ziel)) + text = ziel.read_text(encoding="utf-8") + assert "Aufgabe abgelehnt" in text and "nichts zu entscheiden" in text + + +def test_rejected_result_cannot_be_continued_and_counts_as_failure(tmp_path, monkeypatch): + import pytest + from benchmark import benchmark + from TextToModel import continue_pipeline + session = FakeSession(specs=[], reviews=[], understandings=[KEINE_AUFGABE]) + result = run_pipeline("x! = 120", session=session) + gespeichert = tmp_path / "abgelehnt.json" + gespeichert.write_text(result.model_dump_json()) + with pytest.raises(ValueError, match="abgelehnt"): + continue_pipeline(str(gespeichert), "solver", session=session) + + monkeypatch.setattr(benchmark, "run_pipeline", lambda *a, **k: result) + zeile = benchmark.run_case({"name": "gleichung", "task": "x! = 120", "expected": {}}, "fake", 1) + assert zeile["solver_status"] == "abgelehnt" and zeile["task_ok"] is False + benchmark.print_table([zeile]) # die Ergebnistabelle verkraftet die Zeile diff --git a/understanding/Verstehen-Prompt.txt b/understanding/Verstehen-Prompt.txt index a176b54..6950aee 100644 --- a/understanding/Verstehen-Prompt.txt +++ b/understanding/Verstehen-Prompt.txt @@ -8,7 +8,7 @@ GEMEINSAMER RAHMEN (gilt für alle vier Stufen dieser Pipeline: Verstehen, Model - Keine Angabe raten, um ein Feld zu füllen: Was nicht im Aufgabentext steht, gehört als Annahme ausgewiesen, nicht stillschweigend erfunden. -1. IST DAS EINE AUFGABE? Setze is_optimization_task nur auf True, wenn der Text eine Entscheidung oder Optimierung verlangt (etwas soll maximiert, minimiert, zugeordnet, geplant, ausgewählt oder als kürzester/billigster Weg bestimmt werden). Eine bloße Sammlung von Zahlen, ein Einkaufszettel oder eine reine Wissensfrage ist keine. Begründe in reason in einem Satz, worum es geht. +1. IST DAS EINE AUFGABE? Setze is_optimization_task nur auf True, wenn der Text eine Entscheidung oder Optimierung verlangt (etwas soll maximiert, minimiert, zugeordnet, geplant, ausgewählt oder als kürzester/billigster Weg bestimmt werden). Eine bloße Sammlung von Zahlen, ein Einkaufszettel, eine reine Wissensfrage oder eine Gleichung bzw. Rechenaufgabe, die nur ausgerechnet werden soll und keine Wahl zwischen Alternativen lässt, ist keine. Steht is_optimization_task auf False, wird die Aufgabe abgelehnt und nicht bearbeitet – setze es deshalb nur dann auf False, wenn wirklich nichts zu entscheiden ist; auch „Gibt es eine zulässige Lösung?" oder eine kostenminimale Planung, die sich als unlösbar herausstellen könnte, ist eine Aufgabe. Begründe in reason in einem Satz, worum es geht. 2. TEILFRAGEN: Erfasse JEDE Frage des Textes als eigenen Eintrag in sub_questions, in der Reihenfolge des Textes. - id genau wie im Text: „a“, „b“, „Teil 2“, „3“. Gibt es keine Nummerierung, vergib „1“, „2“, …