"""run_pipeline ohne LLM: Neustart bei Fremdproblem und Kennzeichnung unzuverlässiger Ergebnisse.""" from benchmark.benchmark import result_correct from core.model_schema import ( Answer, AssumptionReview, LLMUsage, OptimizationModelSpecification, SolverCode, SolverStatus, SubQuestion, TaskUnderstanding, ) from TextToModel import run_pipeline from test_model_checks import make_spec from test_review import make_review from test_script_export import STRUCTURED_CODE # Standardverstaendnis: eine Frage, keine Zahlen - reicht fuer Tests, die das Verstehen nicht pruefen EINFACHES_VERSTAENDNIS = TaskUnderstanding( is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], sub_questions=[SubQuestion(id="1", quote="-", kind="rechnen", needs_solver=True)]) class FakeSession: """Liefert vorgegebene Antworten je Antwort-Typ in fester Reihenfolge.""" def __init__(self, specs, reviews, codes=(), understandings=None, readings=()): from core.model_schema import FigureReading self.queues = { OptimizationModelSpecification: list(specs), AssumptionReview: list(reviews), SolverCode: list(codes), TaskUnderstanding: list(understandings if understandings is not None else []), FigureReading: list(readings), } self.contents = [] # (Stufe, Inhalt der letzten Nachricht) – prüft, wer Bilder zu sehen bekommt self.usage = LLMUsage(model="fake", provider="fake") self.calls = {kind: 0 for kind in self.queues} self.steps = [] self.tasks = [] self.letzter_prompt = "" def overrides(self): return {} def create(self, response_model, messages, step="unbenannt"): self.letzter_prompt = messages[-1]["content"] self.contents.append((step, messages[-1]["content"])) if step == "Extraktion": self.tasks.append(messages[-1]["content"]) self.calls[response_model] += 1 self.usage.calls += 1 self.steps.append(step) warteschlange = self.queues[response_model] if response_model is TaskUnderstanding and not warteschlange: return EINFACHES_VERSTAENDNIS # Standard, solange ein Test nichts anderes vorgibt return warteschlange.pop(0) HALLUCINATED = make_spec(executive_summary="Produktionsplanung mit Drehen und Fräsen") # Fremdproblem GOOD = make_spec(executive_summary="Rohstoffeinkauf mit minimalen Kosten") MISMATCH = make_review(matches=False) OK = make_review() def test_clean_first_attempt_needs_no_restart(): session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)]) result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1) assert result.reliable and result.restarts == 0 and result.revisions == 0 assert result.solver_run.status == SolverStatus.OPTIMAL def test_restart_recovers_from_hallucinated_problem(): # Versuch 1: Fremdproblem, beide Korrekturrunden erfolglos; Neustart liefert das richtige Modell session = FakeSession( specs=[HALLUCINATED, HALLUCINATED, HALLUCINATED, GOOD], reviews=[MISMATCH, MISMATCH, MISMATCH, OK], codes=[SolverCode(code=STRUCTURED_CODE)], ) result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1) assert result.restarts == 1 assert result.reliable and result.reliability_issues == [] assert result.specification.executive_summary == GOOD.executive_summary assert result.solver_run.status == SolverStatus.OPTIMAL and result.solver_run.objective_value == 9 def test_persistent_mismatch_is_unreliable_and_not_solved(): # beobachtet im Benchmark: Fremdproblem wurde als "optimal, 2550" ausgegeben session = FakeSession(specs=[HALLUCINATED] * 6, reviews=[MISMATCH] * 6) result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1) assert result.restarts == 1 assert not result.reliable assert any("Zielfunktion passt nicht" in issue for issue in result.reliability_issues) assert result.solver_run is None and result.solver_code is None assert session.calls[SolverCode] == 0 # kein Solver-Code für ein falsches Problem assert not result_correct(result.solver_run, {"objective_value": 9, "tolerance": 1e-6}) def test_better_earlier_attempt_wins_over_worse_restart(): # Neustart ist schlechter (Zielgröße falsch) als Versuch 1 (nur Check-Error) -> Versuch 1 verwenden stack_error = make_spec(executive_summary="richtig, aber Stack falsch", problem_class="Quadratic Programming (QP)") session = FakeSession( specs=[stack_error, stack_error, stack_error, HALLUCINATED, HALLUCINATED, HALLUCINATED], reviews=[OK, OK, OK, MISMATCH, MISMATCH, MISMATCH], codes=[SolverCode(code=STRUCTURED_CODE)], ) result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1) assert result.specification.executive_summary == "richtig, aber Stack falsch" assert not result.reliable # STACK_MISMATCH bleibt bestehen assert any("STACK_MISMATCH" in issue for issue in result.reliability_issues) assert result.solver_run is not None # Zielgröße passt -> Solver läuft, Ergebnis aber als unzuverlässig markiert def test_no_restart_when_disabled(): session = FakeSession(specs=[HALLUCINATED] * 3, reviews=[MISMATCH] * 3) result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0) assert result.restarts == 0 and not result.reliable def test_report_shows_prominent_warning_for_unreliable_result(): from reporting.report import build_document, to_html, to_markdown session = FakeSession(specs=[HALLUCINATED] * 3, reviews=[MISMATCH] * 3) result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0) doc = build_document(result) assert doc[1][0] == "warn" # direkt unter der Überschrift assert '
⚠ Ergebnis unzuverlässig' in to_html(doc) assert "> **⚠ Ergebnis unzuverlässig" in to_markdown(doc) assert "Der Solver wurde nicht gestartet." in doc[1][1] def test_report_shows_the_rejected_reading_of_each_assumption(): from reporting.report import build_document, to_markdown spec = make_spec(open_assumptions=[{ "unclear_point": "„aufeinanderfolgend“", "assumption": "Kalendertage, auch über das Wochenende", "alternative": "Nur Werktage; verworfen, weil der Text durchgehenden Betrieb nennt", "impact": "Restriktion Ruhezeit greift auch am Wochenende", }]) session = FakeSession([spec], [OK], [SolverCode(code=STRUCTURED_CODE)]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, solve_model=False) markdown = to_markdown(build_document(result)) assert "Verworfene Lesart" in markdown assert "verworfen, weil der Text durchgehenden Betrieb nennt" in markdown def test_pipeline_labels_every_llm_call_for_the_timing_breakdown(): session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)]) run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1) assert session.steps == ["Verstehen", "Extraktion", "Prüfung", "Solver-Code"] def test_revision_and_repair_steps_are_labelled(): session = FakeSession(specs=[HALLUCINATED, GOOD], reviews=[MISMATCH, OK], codes=[SolverCode(code="x = 1"), SolverCode(code=STRUCTURED_CODE)]) run_pipeline("Aufgabe", session=session, max_revisions=1, max_restarts=0) assert session.steps == ["Verstehen", "Extraktion", "Prüfung", "Korrektur", "Prüfung", "Solver-Code", "Solver-Reparatur"] # --- Zulässigkeitsprüfung im Zusammenspiel --- VIOLATING_CODE = ''' def solve_model(): """Liefert eine Lösung, die das Budget um 8 Cent überschreitet (beobachteter Fall).""" return 100000.08 def main(): """Meldet die Lösung samt Restriktionen zur Nachprüfung.""" invested = solve_model() report("optimal", 12041.74, {"x": invested}, constraints=[{"name": "Bedarf", "lhs": invested, "sense": "<=", "rhs": 100000.0}], variables=[{"name": "x", "value": invested, "var_type": "continuous", "lb": 0}]) if __name__ == "__main__": main() ''' def test_infeasible_solution_triggers_repair_and_is_marked_unreliable(): session = FakeSession([GOOD], [OK], codes=[SolverCode(code=VIOLATING_CODE)] * 3) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, solver_retries=2) assert session.calls[SolverCode] == 3 # Erstversuch + 2 Reparaturen assert result.solver_run.feasibility and "Bedarf" in result.solver_run.feasibility[0] assert not result.reliable assert any("Zulässigkeitsprüfung" in issue for issue in result.reliability_issues) def test_feasible_solution_stays_reliable(): session = FakeSession([GOOD], [OK], codes=[SolverCode(code=STRUCTURED_CODE)]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) assert result.solver_run.feasibility == [] assert result.reliable and session.calls[SolverCode] == 1 # --- Gegenprobe bei "unlösbar" --- INFEASIBLE_CODE = ''' def main(): """Meldet das Modell als unlösbar.""" report("infeasible", None, {}) if __name__ == "__main__": main() ''' def test_infeasible_is_rechecked_and_can_be_refuted(): # beobachtet bei qwen3-235b: "unlösbar" war ein Modellierungsfehler, die Aufgabe ist lösbar session = FakeSession(specs=[GOOD, GOOD], reviews=[OK, OK], codes=[SolverCode(code=INFEASIBLE_CODE), SolverCode(code=STRUCTURED_CODE)]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) assert result.solver_run.status == SolverStatus.OPTIMAL and result.solver_run.objective_value == 9 assert not result.infeasible_confirmed and result.restarts == 1 assert "HINWEIS" in session.tasks[1] and "unabhängige Modellierung" in session.tasks[1] def test_infeasible_confirmed_by_second_modelling(): session = FakeSession(specs=[GOOD, GOOD], reviews=[OK, OK], codes=[SolverCode(code=INFEASIBLE_CODE)] * 2) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) assert result.solver_run.status == SolverStatus.INFEASIBLE assert result.infeasible_confirmed and result.reliable def test_infeasible_without_recheck_is_flagged(monkeypatch): import TextToModel monkeypatch.setattr(TextToModel, "INFEASIBLE_RECHECK", 0) session = FakeSession([GOOD], [OK], codes=[SolverCode(code=INFEASIBLE_CODE)]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) assert not result.reliable and any("ohne Gegenprobe" in i for i in result.reliability_issues) # --- Wiedereinstieg ab einer Stufe (continue_pipeline / CLI --von) --- def stored_result(tmp_path, spec=GOOD, review=OK): """Ergebnis eines früheren Laufs als Datei – Grundlage für --von.""" session = FakeSession([spec], [review], [SolverCode(code=STRUCTURED_CODE)]) result = run_pipeline("Rohstoffeinkauf", session=session, max_revisions=0, max_restarts=0, solve_model=False) path = tmp_path / "ergebnis.json" path.write_text(result.model_dump_json(indent=2), encoding="utf-8") return path def test_continue_from_solver_skips_extraction_and_review(tmp_path): from TextToModel import continue_pipeline path = stored_result(tmp_path) session = FakeSession([], [], [SolverCode(code=STRUCTURED_CODE)]) result = continue_pipeline(str(path), "solver", session=session) assert session.steps == ["Solver-Code"] # kein Extraktions-, kein Prüfaufruf assert result.specification.executive_summary == GOOD.executive_summary assert result.task == "Rohstoffeinkauf" # Aufgabentext kommt aus dem Ergebnis assert result.solver_run.status == SolverStatus.OPTIMAL def test_continue_from_review_keeps_specification_but_checks_again(tmp_path): from TextToModel import continue_pipeline path = stored_result(tmp_path) session = FakeSession([], [OK], [SolverCode(code=STRUCTURED_CODE)]) result = continue_pipeline(str(path), "pruefung", session=session) assert session.steps == ["Prüfung", "Solver-Code"] # Extraktion entfällt assert result.specification.executive_summary == GOOD.executive_summary def test_continue_from_review_can_still_revise(tmp_path): from TextToModel import continue_pipeline path = stored_result(tmp_path) session = FakeSession([GOOD], [MISMATCH, OK], [SolverCode(code=STRUCTURED_CODE)]) result = continue_pipeline(str(path), "pruefung", session=session, max_revisions=1) assert session.steps == ["Prüfung", "Korrektur", "Prüfung", "Solver-Code"] assert result.revisions == 1 def test_continue_from_solver_refuses_when_objective_does_not_match(tmp_path): # gespeicherte Zielgrößen-Abweichung bleibt bindend: kein Solver, kein LLM-Aufruf from TextToModel import continue_pipeline path = stored_result(tmp_path, review=MISMATCH) session = FakeSession([], [], []) result = continue_pipeline(str(path), "solver", session=session) assert session.steps == [] assert result.solver_run is None and not result.reliable def test_unknown_stage_is_rejected(tmp_path): import pytest from TextToModel import continue_pipeline path = stored_result(tmp_path) with pytest.raises(ValueError, match="Unbekannte Stufe"): continue_pipeline(str(path), "extraktion", session=FakeSession([], [], [])) def test_warnings_reach_the_revision_prompt(): # Warnungen loesen keine Runde aus, muessen aber mitkorrigiert werden, wenn ohnehin eine stattfindet from core.model_schema import CheckIssue, CheckSeverity from modeling.extraction import revise_model session = FakeSession([GOOD], [], []) revise_model(session, "Aufgabe", GOOD, OK, issues=[ CheckIssue(severity=CheckSeverity.WARNING, code="UNEXPLAINED_JARGON", message="Fachbegriffe: Fluss"), CheckIssue(severity=CheckSeverity.ERROR, code="STACK_MISMATCH", message="passt nicht"), ]) prompt = session.letzter_prompt assert "STACK_MISMATCH" in prompt.split("HINWEISE")[0] # Fehler im Fehlerteil assert "UNEXPLAINED_JARGON" in prompt.split("HINWEISE")[1] # Warnung im Hinweisteil # --- Benchmark haelt fest, womit gemessen wurde --- def test_benchmark_records_the_prompt_state(): from benchmark.benchmark import PROMPT_FILES, prompt_state stand = prompt_state() assert {"prompts", "commit", "dirty"} <= set(stand) assert len(stand["prompts"]) == 12 and stand["commit"] assert "modeling/Routing-und-Extraktions-Prompt.txt" in PROMPT_FILES assert "core/model_schema.py" in PROMPT_FILES # Feldbeschreibungen wirken wie Prompt-Anweisungen assert "understanding/Verstehen-Prompt.txt" in PROMPT_FILES def test_deviating_settings_are_recorded(monkeypatch): # Ohne die Umgebung liesse sich ein A/B-Lauf (Stufe abgeschaltet, anderes Modell) nicht zuordnen from benchmark import benchmark benchmark.prompt_state.cache_clear() monkeypatch.setenv("UNDERSTAND_RETRIES", "-1") monkeypatch.setenv("LLM_MODEL_SOLVER", "billig/modell") stand = benchmark.prompt_state() assert stand["config"] == {"UNDERSTAND_RETRIES": "-1", "LLM_MODEL_SOLVER": "billig/modell"} benchmark.prompt_state.cache_clear() def test_prompt_digest_changes_with_the_prompt(tmp_path): from benchmark.benchmark import prompt_digest a, b = tmp_path / "eins.txt", tmp_path / "zwei.txt" a.write_text("Regel A"), b.write_text("Regel B") vorher = prompt_digest([a, b]) assert prompt_digest([a, b]) == vorher # gleicher Inhalt, gleiche Kennung b.write_text("Regel B, nun schaerfer") assert prompt_digest([a, b]) != vorher # eine geaenderte Regel aendert die Kennung assert prompt_digest([b, a]) != prompt_digest([a, b]) # Reihenfolge ist Teil der Kennung def test_unexplained_jargon_triggers_a_revision_but_stays_reliable(): # Erklaeren ist Kernziel: die Warnung ist eine Runde wert, macht das Ergebnis aber nicht unzuverlaessig from checks.model_checks import check_spec unerklaert = make_spec( executive_summary="Der Betrieb entscheidet ueber Mengen.", constraints=[{"identifier": "Flusserhaltung", "latex_formula": "\\sum_j x_j = 1", "description": "-"}]) assert "UNEXPLAINED_JARGON" in {i.code for i in check_spec(unerklaert)} session = FakeSession(specs=[unerklaert, GOOD], reviews=[OK, OK], codes=[SolverCode(code=STRUCTURED_CODE)]) result = run_pipeline("Aufgabe", session=session, max_revisions=1, max_restarts=0) assert session.steps == ["Verstehen", "Extraktion", "Prüfung", "Korrektur", "Prüfung", "Solver-Code"] assert result.revisions == 1 assert result.reliable # Warnung, kein Fehler def test_clean_model_still_needs_no_revision(): session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)]) result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0) assert result.revisions == 0 # --- Verstehensstufe im Ablauf --- def test_understanding_runs_first_and_is_kept_in_the_result(): from core.model_schema import Fact verstanden = TaskUnderstanding( is_optimization_task=True, reason="Kostenminimierung", ignored_numbers=[], sub_questions=[SubQuestion(id="a", quote="Wie viel?", kind="rechnen", needs_solver=True)], facts=[Fact(value="12", unit="€", meaning="Deckungsbeitrag je Stueck", quote="12 € je Stueck")]) session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[verstanden]) result = run_pipeline("Der Deckungsbeitrag ist 12 € je Stueck.", session=session, max_revisions=0, max_restarts=0) assert session.steps[0] == "Verstehen" assert result.understanding.facts[0].value == "12" assert [q.id for q in result.understanding.sub_questions] == ["a"] def test_incomplete_understanding_is_asked_again(): # erste Antwort uebersieht die Zahl, zweite erfasst sie from core.model_schema import Fact luecke = TaskUnderstanding(is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], sub_questions=[SubQuestion(id="1", quote="-", kind="rechnen", needs_solver=True)]) vollstaendig = luecke.model_copy(update={ "facts": [Fact(value="12", unit="€", meaning="Deckungsbeitrag", quote="12 € je Stueck")]}) session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[luecke, vollstaendig]) result = run_pipeline("12 € je Stueck.", session=session, max_revisions=0, max_restarts=0) assert session.steps[:2] == ["Verstehen", "Verstehen"] # genau ein Nachfassen assert result.understanding.facts[0].value == "12" def test_report_shows_questions_and_numbers(): from core.model_schema import Fact from reporting.report import build_document, to_markdown verstanden = TaskUnderstanding( is_optimization_task=True, reason="-", sub_questions=[SubQuestion(id="a", quote="Formulieren Sie das Problem!", kind="formulieren", needs_solver=False), SubQuestion(id="b", quote="Loesen Sie es!", kind="rechnen", needs_solver=True)], facts=[Fact(value="8", unit="€", meaning="Kosten im ersten Jahr", quote="Zeile 0: 8 | 18 | 31")], ignored_numbers=[]) session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[verstanden]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) markdown = to_markdown(build_document(result)) assert "Gestellte Fragen" in markdown and "Formulieren Sie das Problem!" in markdown assert "Gegebene Zahlen" in markdown and "Kosten im ersten Jahr" in markdown def test_report_puts_the_answers_before_the_numbers(): from reporting.report import build_document, to_markdown code = ''' def main(): """Loest und beantwortet.""" report("optimal", 9.0, {"x": 1.0}, headline="Die Kosten betragen 9 Geldeinheiten.", tables=[{"title": "Zielwert-Zerlegung", "columns": ["Bestandteil", "Wert"], "objective_column": "Wert", "rows": [["Kosten", 9.0]]}], constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}], variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}], answers=[{"question_id": "a", "text": "Das Modell ist ein lineares Programm."}, {"question_id": "b", "text": "Die minimalen Kosten betragen 9 Geldeinheiten."}]) if __name__ == "__main__": main() ''' verstanden = TaskUnderstanding( is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], sub_questions=[SubQuestion(id="a", quote="Formulieren Sie das Modell!", kind="formulieren", needs_solver=False), SubQuestion(id="b", quote="Berechnen Sie die Kosten!", kind="rechnen", needs_solver=True)]) session = FakeSession([GOOD], [OK], [SolverCode(code=code)], understandings=[verstanden]) result = run_pipeline("Minimiere die Kosten.", session=session, max_revisions=0, max_restarts=0) markdown = to_markdown(build_document(result)) assert "Antworten auf die Aufgabe" in markdown assert markdown.index("Formulieren Sie das Modell!") < markdown.index("| Status |") assert result.solver_run.warnings == [] # Abnahme ohne Befund def test_unanswered_sub_question_is_a_warning_not_a_wrong_number(): code = ''' def main(): """Beantwortet nur die zweite Frage.""" report("optimal", 9.0, {"x": 1.0}, constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}], variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}], answers=[{"question_id": "b", "text": "Die minimalen Kosten betragen 9 Geldeinheiten."}]) if __name__ == "__main__": main() ''' verstanden = TaskUnderstanding( is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], sub_questions=[SubQuestion(id="a", quote="Zeichnen Sie den Graphen!", kind="zeichnen", needs_solver=False), SubQuestion(id="b", quote="Berechnen Sie die Kosten!", kind="rechnen", needs_solver=True)]) session = FakeSession([GOOD], [OK], [SolverCode(code=code)] * 3, understandings=[verstanden]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) assert any("Teilfragen ohne Antwort" in w for w in result.solver_run.warnings) assert result.solver_run.objective_value == 9.0 # die Zahl bleibt richtig def test_report_shows_data_profile_and_scenarios(tmp_path): from reporting.report import build_document, to_markdown datei = tmp_path / "werte.csv" datei.write_text("name,menge\nA,3\nB,4\n") code = ''' DATA = {"faktor": 1.0} def main(): """Meldet zwei Datenvarianten.""" report("optimal", 7.0, {"x": 1.0}, tables=[{"title": "Zielwert-Zerlegung", "columns": ["Bestandteil", "Wert"], "objective_column": "Wert", "rows": [["Kosten", 7.0]]}], constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}], variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}], scenarios=[{"name": "Doppelt", "description": "Faktor verdoppelt", "data_override": {"faktor": 2.0}, "objective_value": 7.0}]) if __name__ == "__main__": main() ''' session = FakeSession([GOOD], [OK], [SolverCode(code=code)]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, data_files=[datei]) markdown = to_markdown(build_document(result)) assert "Beigelegte Daten" in markdown and "werte.csv (2 Zeilen)" in markdown assert "Verglichene Konfigurationen" in markdown and "Doppelt" in markdown assert result.data_files[0].rows == 2 assert result.solver_run.scenarios[0].verified_value == 7.0 # ohne LLM nachgerechnet # --- Benchmark misst "Aufgabe erfuellt", nicht nur "Zahl richtig" --- def test_benchmark_counts_unanswered_questions_as_not_fulfilled(monkeypatch): from benchmark import benchmark from core.model_schema import Answer, SubQuestion, TaskUnderstanding def ergebnis(antworten, warnungen=()): session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[TaskUnderstanding( is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], sub_questions=[SubQuestion(id=f, quote="-", kind="rechnen", needs_solver=True) for f in ("a", "b")])]) r = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) r.solver_run.answers = [Answer(question_id=k, text="-") for k in antworten] r.solver_run.warnings = list(warnungen) return r fall = {"name": "test", "task": "Aufgabe", "expected": {"objective_value": 9, "tolerance": 1e-6}} for antworten, warnungen, erwartet in [ (["a", "b"], [], True), # alles beantwortet (["b"], ["Teilfragen ohne Antwort: a"], False), # eine Frage offen (["a", "b"], ["Antwort zu 'b' nennt Zahlen …"], False), # unbelegte Zahl ]: monkeypatch.setattr(benchmark, "run_pipeline", lambda *a, **k: ergebnis(antworten, warnungen)) zeile = benchmark.run_case(fall, "fake", 1) assert zeile["objective_ok"] is True # die Zahl stimmt immer assert zeile["task_ok"] is erwartet, (antworten, warnungen) def test_benchmark_case_data_files_are_collected(tmp_path, monkeypatch): from benchmark import benchmark fall = tmp_path / "cases" / "mit_daten" fall.mkdir(parents=True) (fall / "task.txt").write_text("Aufgabe") (fall / "expected.json").write_text('{"objective_value": 1, "tolerance": 1e-6}') (fall / "werte.csv").write_text("a,b\n1,2\n") monkeypatch.setattr(benchmark, "CASES_DIR", tmp_path / "cases") faelle = benchmark.load_cases() assert [p.split("/")[-1] for p in faelle[0]["data_files"]] == ["werte.csv"] # Spezifikation ohne Kostendaten, Programm erfindet sie per Zufallsgenerator (Muster "Portfolio Nr. 2") OHNE_DATEN = make_spec(parameters=[ {"symbol": "c_j", "dimension": "Vektor", "unit": "€/t", "description": "Kosten", "extracted_values": ""}, {"symbol": "b", "dimension": "Skalar", "unit": "t", "description": "Bedarf", "extracted_values": "4"}]) ERFUNDENE_DATEN = STRUCTURED_CODE.replace( "COSTS = [3, 2]", "import numpy as np\nCOSTS = [float(v) for v in np.random.default_rng(0).integers(2, 4, 2)]") def test_undeclared_generated_data_is_repaired(): session = FakeSession([OHNE_DATEN], [OK], [ SolverCode(code=ERFUNDENE_DATEN), SolverCode(code=ERFUNDENE_DATEN, data_assumptions=["c_j: zufällig erzeugt (Seed 0), keine Daten im Text"])]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, solver_retries=1) assert session.calls[SolverCode] == 2 # der Befund hat eine Reparatur ausgelöst assert result.reliable and result.solver_run.undeclared_data == [] def test_persistently_undeclared_data_is_unreliable(): # Nachmessung "Portfolio Nr. 2": data_assumptions leer, Antwort behauptete geladene Kursdaten session = FakeSession([OHNE_DATEN], [OK], [SolverCode(code=ERFUNDENE_DATEN)] * 2) 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 def _mit_szenario(wert): return STRUCTURED_CODE.replace( " )\n\n\nif __name__", ' scenarios=[{"name": "Ausgewogen", "description": "-", "data_override": {"faktor": 2}, ' f'"objective_value": {wert}}}],\n )\n\n\nif __name__') def test_scenario_without_result_is_repaired(): # beobachtet (Portfolio Nr. 2): Zeitlimit ohne Lösung wurde als „keine Lösung" gemeldet session = FakeSession([GOOD], [OK], [SolverCode(code=_mit_szenario(None)), SolverCode(code=_mit_szenario(8.0))]) result = run_pipeline("Minimiere die Kosten.", session=session, max_revisions=0, max_restarts=0, solver_retries=1) assert session.calls[SolverCode] == 2 # der Befund hat eine Reparatur ausgelöst assert result.reliable and result.solver_run.unsolved_scenarios == [] def test_persistent_scenario_without_result_is_unreliable(): session = FakeSession([GOOD], [OK], [SolverCode(code=_mit_szenario(None))] * 2) result = run_pipeline("Minimiere die Kosten.", session=session, max_revisions=0, max_restarts=0, solver_retries=1) assert not result.reliable assert any(p.startswith("Szenario ohne Ergebnis") for p in result.reliability_issues) # --- Nebenpfade: Verstehensstufe aus, Gegenprobe und --von mit vollem Kontext --- def test_pipeline_runs_with_understanding_stage_disabled(monkeypatch): # UNDERSTAND_RETRIES=-1 war der A/B-Arm der Verstehensstufe; das Ablehnungstor griff dann auf None zu import TextToModel monkeypatch.setattr(TextToModel, "UNDERSTAND_RETRIES", -1) session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) assert result.understanding is None and not result.rejected assert session.steps[0] == "Extraktion" assert result.solver_run.status == SolverStatus.OPTIMAL def test_infeasible_recheck_keeps_understanding_and_data_files(tmp_path, monkeypatch): # Ohne Datendateien fand das Programm der Gegenprobe seine CSV nicht, ohne Verständnis fehlten die Antworten import TextToModel aufrufe = [] echtes_solve = TextToModel.solve def protokoll(*args, **kwargs): aufrufe.append(kwargs) return echtes_solve(*args, **kwargs) monkeypatch.setattr(TextToModel, "solve", protokoll) csv = tmp_path / "werte.csv" csv.write_text("a,b\n1,2\n", encoding="utf-8") session = FakeSession(specs=[GOOD, GOOD], reviews=[OK, OK], codes=[SolverCode(code=INFEASIBLE_CODE), SolverCode(code=STRUCTURED_CODE)]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, data_files=[str(csv)]) assert result.restarts == 1 and len(aufrufe) == 2 gegenprobe = aufrufe[1] assert gegenprobe["understanding"] is not None assert list(gegenprobe["data_files"]) == [str(csv)] and "werte.csv" in gegenprobe["data_prompt"] assert "werte.csv" in session.tasks[1] # auch die neue Extraktion sieht das Datenprofil def test_continue_from_review_keeps_understanding(tmp_path): from TextToModel import continue_pipeline path = stored_result(tmp_path) session = FakeSession([], [OK], [SolverCode(code=STRUCTURED_CODE)]) result = continue_pipeline(str(path), "pruefung", session=session) assert result.understanding is not None assert [q.id for q in result.understanding.sub_questions] == ["1"] assert "ZU BEANTWORTENDE TEILFRAGEN" in session.letzter_prompt # der Solver bekommt die Teilfragen def test_review_and_revision_see_the_data_profile(): from checks.review import review_assumptions from modeling.extraction import revise_model session = FakeSession([GOOD], [OK], []) review_assumptions(session, "Aufgabe", GOOD, "BEIGELEGTE DATEN: kurse.csv") assert "kurse.csv" in session.letzter_prompt revise_model(session, "Aufgabe", GOOD, OK, data_prompt="BEIGELEGTE DATEN: kurse.csv") assert "kurse.csv" in session.letzter_prompt def test_repair_message_names_every_kind_of_finding(): # Vorher erfuhr das LLM nur von der ersten Befundart und verbrauchte für die zweite einen eigenen Versuch from solving.solver_stage import solve code = ''' import numpy as np def main(): """Verletzt die Restriktion und erzeugt Zahlen per Zufall, ohne sie auszuweisen.""" np.random.seed(0) report("optimal", 9.0, {"x": 1.0}, constraints=[{"name": "Bedarf", "lhs": 1.0, "sense": ">=", "rhs": 4.0}], variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0, "ub": None}]) if __name__ == "__main__": main() ''' session = FakeSession([], [], [SolverCode(code=code), SolverCode(code=code)]) solve(session, "Aufgabe", GOOD, retries=1) assert session.steps == ["Solver-Code", "Solver-Reparatur"] assert "hält das Modell nicht ein" in session.letzter_prompt assert "Eingabedaten nicht ausgewiesen" in session.letzter_prompt # --- Zielwert-Zerlegung in der Solver-Stufe --- OHNE_ZERLEGUNG = STRUCTURED_CODE.split(" tables=[")[0] + " )\n\n\nif __name__ == \"__main__\":\n main()\n" def test_missing_decomposition_is_repaired(): assert "tables" not in OHNE_ZERLEGUNG session = FakeSession([GOOD], [OK], [SolverCode(code=OHNE_ZERLEGUNG), SolverCode(code=STRUCTURED_CODE)]) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) assert session.steps.count("Solver-Reparatur") == 1 assert "Zielwert-Zerlegung" in session.letzter_prompt assert result.solver_run.attempts == 2 and result.reliable def test_persistently_missing_decomposition_is_only_a_warning(): # Die Lösung ist geprüft zulässig – nur ihr Zielwert ist nicht aus Bestandteilen belegt from solving.solver_stage import DECOMPOSITION_MISSING session = FakeSession([GOOD], [OK], [SolverCode(code=OHNE_ZERLEGUNG)] * 3) result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) assert result.reliable and result.solver_run.objective_value == 9 assert any(w.startswith(DECOMPOSITION_MISSING) for w in result.solver_run.warnings) def test_untracked_files_do_not_mark_the_measurement_dirty(monkeypatch): # Eine abgelegte Aufgabe in tasks/ ließ bisher jede Messung als „nicht eingecheckte Änderungen“ erscheinen import subprocess from benchmark import benchmark aufrufe = [] def git(befehl, **_): aufrufe.append(befehl) return subprocess.CompletedProcess(befehl, 0, stdout="abc1234\n" if "rev-parse" in befehl else "") monkeypatch.setattr(benchmark.subprocess, "run", git) benchmark.prompt_state.cache_clear() try: stand = benchmark.prompt_state() finally: benchmark.prompt_state.cache_clear() assert stand["dirty"] is False assert any("--untracked-files=no" in befehl for befehl in aufrufe) # --- Benchmarkfälle „ehrlich scheitern“ (expected["behavior"]) --- def _ergebnisse(): """Drei Ausgänge derselben Pipeline: abgelehnt, mit ausgewiesenen Annahmen gelöst, unzuverlässig.""" abgelehnt = run_pipeline("4x - 7 = 25", session=FakeSession([], [], understandings=[KEINE_AUFGABE])) mit_annahme = run_pipeline("Aufgabe", session=FakeSession( [GOOD], [OK], [SolverCode(code=STRUCTURED_CODE, data_assumptions=["Bedarf G3 = 0 Kisten (nicht genannt)"])]), max_revisions=0, max_restarts=0) unzuverlaessig = run_pipeline("Aufgabe", session=FakeSession([HALLUCINATED] * 3, [MISMATCH] * 3), max_revisions=2, max_restarts=0) return abgelehnt, mit_annahme, unzuverlaessig def test_observed_behaviors(): from benchmark.benchmark import observed_behaviors abgelehnt, mit_annahme, unzuverlaessig = _ergebnisse() assert observed_behaviors(abgelehnt) == {"rejected"} assert observed_behaviors(mit_annahme) == {"assumed_data"} assert observed_behaviors(unzuverlaessig) == {"unreliable"} def test_honest_failure_cases_are_scored_by_behavior(monkeypatch): from benchmark import benchmark abgelehnt, mit_annahme, unzuverlaessig = _ergebnisse() fehlende_daten = {"behavior": ["assumed_data", "unreliable"]} for ergebnis, expected, treffer in [ (abgelehnt, {"behavior": ["rejected"]}, True), (mit_annahme, {"behavior": ["rejected"]}, False), # Gleichung modelliert statt abgelehnt (mit_annahme, fehlende_daten, True), # Annahme ausgewiesen (unzuverlaessig, fehlende_daten, True), # als unzuverlässig gekennzeichnet (abgelehnt, fehlende_daten, False), # lösbare Aufgabe abgelehnt ]: monkeypatch.setattr(benchmark, "run_pipeline", lambda *a, **k: ergebnis) zeile = benchmark.run_case({"name": "ehrlich", "task": "-", "expected": expected}, "fake", 1) assert zeile["objective_ok"] is treffer, (zeile["behavior"], expected) # mit ausgewiesener Annahme und beantworteter Teilfrage ist die Aufgabe erfüllt monkeypatch.setattr(benchmark, "run_pipeline", lambda *a, **k: mit_annahme) mit_annahme.solver_run.answers = [Answer(question_id="1", text="Kosten 9 bei angenommenem Bedarf.")] mit_annahme.solver_run.warnings = [] assert benchmark.run_case({"name": "e", "task": "-", "expected": fehlende_daten}, "fake", 1)["task_ok"] is True def test_unknown_behavior_is_refused(): import pytest from benchmark.benchmark import behavior_matches abgelehnt = _ergebnisse()[0] with pytest.raises(ValueError, match="Unbekanntes Verhalten"): behavior_matches(abgelehnt, {"behavior": ["ehrlich"]}) def test_honest_failure_cases_are_well_formed(): from benchmark.benchmark import BEHAVIORS, load_cases faelle = [f for f in load_cases() if "behavior" in f["expected"]] assert len(faelle) >= 4 for fall in faelle: assert fall["expected"]["behavior"] and set(fall["expected"]["behavior"]) <= set(BEHAVIORS) assert "objective_value" not in fall["expected"] # kein Zielwert: bewertet wird das Verhalten def test_expected_rejection_has_no_routing_to_score(monkeypatch): from benchmark import benchmark abgelehnt = _ergebnisse()[0] monkeypatch.setattr(benchmark, "run_pipeline", lambda *a, **k: abgelehnt) erwartet = benchmark.run_case({"name": "g", "task": "-", "expected": {"behavior": ["rejected"]}}, "fake", 1) falsch = benchmark.run_case({"name": "lp", "task": "-", "expected": {"objective_value": 9, "tolerance": 1e-6}}, "fake", 1) assert erwartet["class_ok"] is None and falsch["class_ok"] is False # eine unerwartete Ablehnung bleibt ein Fehler