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:
parent
cb402996ad
commit
8697400dc2
7 changed files with 93 additions and 11 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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. "
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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“, …
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue