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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-17 12:54:54 +02:00
commit 8697400dc2
7 changed files with 93 additions and 11 deletions

View file

@ -7,8 +7,8 @@ Aufgabentext (+ optional beigelegte Daten: --daten kurse.csv)
│ ohne LLM: Profil je Datei – Zeilen, Spalten, Spannweiten, Lücken │ ohne LLM: Profil je Datei – Zeilen, Spalten, Spannweiten, Lücken
▼ ▼
1. Verstehen ───────── LLM: Teilfragen a)/b)/…, alle Zahlen des Textes mit Fundstelle, 1. Verstehen ───────── LLM: Teilfragen a)/b)/…, alle Zahlen des Textes mit Fundstelle,
│ understanding/ übergangene Zahlen mit Grund; Hinweis, wenn es keine │ understanding/ übergangene Zahlen mit Grund; keine Optimierungsaufgabe
│ Optimierungsaufgabe ist (die Pipeline läuft trotzdem weiter) │ (z. B. reine Gleichung) ⇒ abgelehnt, Ende
│ ohne LLM geprüft: keine Zahl, keine Teilfrage übersehen │ ohne LLM geprüft: keine Zahl, keine Teilfrage übersehen
▼ ▼
2. Extraktion ──────── LLM: Problemklasse, Solver-Wahl, Indizes, Parameter, 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 .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 | | 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: 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. - **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). - **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. - **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.

View file

@ -55,8 +55,8 @@ def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple:
understanding = understand_task(session, task, data_prompt=data_prompt) understanding = understand_task(session, task, data_prompt=data_prompt)
issues = check_understanding(understanding, task) issues = check_understanding(understanding, task)
for versuch in range(UNDERSTAND_RETRIES): for versuch in range(UNDERSTAND_RETRIES):
if not issues: if not issues or not understanding.is_optimization_task:
break break # eine Nicht-Aufgabe wird abgelehnt, nicht vervollständigt
log(f"[{label}] Verständnis unvollständig – fasse nach ({versuch + 1}/{UNDERSTAND_RETRIES}): " log(f"[{label}] Verständnis unvollständig – fasse nach ({versuch + 1}/{UNDERSTAND_RETRIES}): "
+ "; ".join(i.code for i in issues)) + "; ".join(i.code for i in issues))
understanding = understand_task(session, task, issues, data_prompt) 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): " log(f" {len(understanding.sub_questions)} Teilfrage(n): "
+ ", ".join(q.id for q in understanding.sub_questions) + ", ".join(q.id for q in understanding.sub_questions)
+ f"; {len(understanding.facts)} Zahlenwerte erfasst") + 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 return understanding, issues
@ -221,6 +219,14 @@ def run_pipeline(
log(f"[{model}] Stufe '{step}' läuft mit abweichendem Modell: {step_model}") log(f"[{model}] Stufe '{step}' läuft mit abweichendem Modell: {step_model}")
understanding, verstehen_issues = _understand(session, task, model, data_prompt) 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, attempts = [_model_attempt(session, task, max_revisions, model, understanding=understanding,
data_prompt=data_prompt)] data_prompt=data_prompt)]
@ -273,6 +279,8 @@ def continue_pipeline(
if stage not in STAGES: if stage not in STAGES:
raise ValueError(f"Unbekannte Stufe '{stage}' (erlaubt: {', '.join(STAGES)})") raise ValueError(f"Unbekannte Stufe '{stage}' (erlaubt: {', '.join(STAGES)})")
stored = PipelineResult.model_validate_json(Path(result_json).read_text(encoding="utf-8")) 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) session = session or LLMSession(model)
task = stored.task task = stored.task
@ -400,6 +408,8 @@ if __name__ == "__main__":
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: if not result.reliable:
sys.exit(2) # für Skripte: Ergebnis vorhanden, aber unzuverlässig sys.exit(2) # für Skripte: Ergebnis vorhanden, aber unzuverlässig

View file

@ -115,6 +115,19 @@ def run_case(case: dict, model: str, run_no: int) -> dict:
return row return row
spec, solver_run = result.specification, result.solver_run 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 # "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() 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() antworten = {a.question_id for a in solver_run.answers} if solver_run else set()

View file

@ -424,9 +424,12 @@ class PipelineResult(BaseModel):
default_factory=list, description="Profile der beigelegten Datendateien (--daten)") default_factory=list, description="Profile der beigelegten Datendateien (--daten)")
understanding: Optional[TaskUnderstanding] = Field( understanding: Optional[TaskUnderstanding] = Field(
default=None, description="Ergebnis der Verstehensstufe (Teilfragen, Fakten); None bei alten Ergebnissen") default=None, description="Ergebnis der Verstehensstufe (Teilfragen, Fakten); None bei alten Ergebnissen")
specification: OptimizationModelSpecification rejected: bool = Field(
review: AssumptionReview default=False, description="True, wenn der Text keine Optimierungsaufgabe ist – dann wird nicht modelliert")
revisions: int = Field(description="Anzahl durchgeführter Korrekturrunden") 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)") 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") 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") reliable: bool = Field(default=True, description="False, wenn nach allen Runden/Neustarts Zielgröße oder Konsistenz nicht stimmen")

View file

@ -303,8 +303,24 @@ def _fmt_number(value) -> str:
return str(value) 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: def build_document(result: PipelineResult, model: str = "") -> list:
spec, review = result.specification, result.review spec, review = result.specification, result.review
if result.rejected or spec is None:
return _rejected_document(result)
doc = [("h1", "Optimierungsmodell")] doc = [("h1", "Optimierungsmodell")]
if not result.reliable: if not result.reliable:
doc.append(("warn", "Ergebnis unzuverlässig – bitte nicht verwenden. " doc.append(("warn", "Ergebnis unzuverlässig – bitte nicht verwenden. "

View file

@ -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) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, solver_retries=1)
assert not result.reliable assert not result.reliable
assert any(p.startswith("Datenherkunft") for p in result.reliability_issues) 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

View file

@ -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. - 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. 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“, … - id genau wie im Text: „a“, „b“, „Teil 2“, „3“. Gibt es keine Nummerierung, vergib „1“, „2“, …