From 0f1ab04a8d9cf8be365d9d56a265daaee9224550 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 17 Sep 2026 16:28:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Warnung=20bei=20ungesch=C3=BCtzter=20Sa?= =?UTF-8?q?ndbox,=20Pr=C3=BCf-Regel=20f=C3=BCr=20konstante=20Kosten,=20Gru?= =?UTF-8?q?nd=20je=20Korrekturrunde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sandbox_warning: fehlt bubblewrap oder ist SOLVER_SANDBOX=aus, steht das im Log und in SolverRun.warnings (vorher lief der Code stillschweigend ungeschützt) - Dijkstra-Markierung: der Report erklärt den Begriff selbst (DIJKSTRA_EXPLANATION), check_text_quality zählt das als Erklärung; Stamm 'markier' -> 'markierung' - Pruef-Prompt Regel 7: entscheidungsunabhängige Terme ohne Zahlenwert dürfen weggelassen oder symbolisch mitgeführt werden (wagner_whitin_dijkstra: 1+1 statt 2+1 Korrekturrunden, n=2) - revision_reasons: jede Korrekturrunde nennt ihren Auslöser im Log Co-Authored-By: Claude Opus 5 --- TextToModel.py | 4 +++- checks/Pruef-Prompt.txt | 1 + checks/model_checks.py | 3 ++- checks/review.py | 26 +++++++++++++++++++++++++- reporting/report.py | 6 ++---- solving/solver_stage.py | 35 ++++++++++++++++++++++++++++++++++- tests/test_result_tables.py | 28 ++++++++++++++++++++++++++++ tests/test_review.py | 30 ++++++++++++++++++++++++++++++ tests/test_solver_stage.py | 23 +++++++++++++++++++++++ 9 files changed, 148 insertions(+), 8 deletions(-) diff --git a/TextToModel.py b/TextToModel.py index 89d232f..32ecd88 100644 --- a/TextToModel.py +++ b/TextToModel.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import List from checks.model_checks import check_spec, check_understanding, has_errors -from checks.review import needs_work, review_assumptions, select_best +from checks.review import needs_work, review_assumptions, revision_reasons, select_best from core.llm import DEFAULT_MODEL, LLMSession from core.model_schema import ( AssumptionReview, @@ -89,6 +89,8 @@ def _model_attempt(session, task: str, max_revisions: int, label: str, hint: str while needs_work(review, issues) and revisions < max_revisions: revisions += 1 log(f"[{label}] Probleme gefunden – Korrekturrunde {revisions}/{max_revisions} ...") + for grund in revision_reasons(review, issues): + log(f" Grund: {grund}") spec = revise_model(session, task, spec, review, issues) issues = check_spec(spec, task, understanding) review = review_assumptions(session, task, spec) diff --git a/checks/Pruef-Prompt.txt b/checks/Pruef-Prompt.txt index ee28f4c..5369c97 100644 --- a/checks/Pruef-Prompt.txt +++ b/checks/Pruef-Prompt.txt @@ -22,6 +22,7 @@ Schritt 1 – Zielgröße (objective_check), immer zuerst: - Kosten in Tsd. €, Erlöse in €. Bei False gehört in correction die korrigierte Zielfunktion mit einheitlicher Einheit (z. B. Risiko mit dem Kapital multipliziert). 6. Bei matches False gib in correction die korrigierte Zielfunktion in LaTeX an (ohne Begrenzer). +7. Ausnahme zu 4 – entscheidungsunabhängige Terme, deren Wert der Text NICHT nennt: Hat ein Term für jede zulässige Lösung denselben Wert (z. B. gleichbleibender Einkaufspreis × fest vorgegebener Gesamtbedarf), ändert er die optimale Entscheidung nicht, und ohne Zahl im Text lässt er sich nicht ausrechnen. Dann sind zwei Formen gleich richtig: (a) den Term weglassen oder (b) ihn als Konstante mit Symbol mitführen. Beide erfüllen matches = True, wenn eine Annahme in open_assumptions ausdrücklich sagt, dass der Zielwert diesen Term nicht bzw. nur symbolisch enthält und warum die Entscheidung davon unberührt ist. Verlange keine Umstellung von (a) auf (b) oder umgekehrt, und führe den fehlenden Zahlenwert nicht als missing_assumptions auf, wenn eine solche Annahme existiert. Fehlt die Annahme, gehört genau sie in missing_assumptions. Nennt der Text den Wert, gilt Regel 4: dann gehört der Term in die gefragte Gesamtgröße. Schreibweise: In allen Textfeldern (task_evidence ausgenommen, das ist ein wörtliches Zitat) steht jede Formel und jedes Symbol in \( … \), z. B. „modelliert wird \(w^\top \Sigma w\)“. Keine $-Zeichen als Formelbegrenzer. diff --git a/checks/model_checks.py b/checks/model_checks.py index f4487b6..a30ad4c 100644 --- a/checks/model_checks.py +++ b/checks/model_checks.py @@ -69,7 +69,8 @@ JARGON = { "Epigraph": ("epigraph",), "Schattenpreis": ("schattenpreis", "dualwert", "dualvariable"), "Konvexität": ("konvex",), - "Markierung": ("marke", "markier"), + # nicht "markier": „die mit ja markierten Zeilen“ ist Alltagssprache (Fehlalarm in 20260917-160618) + "Markierung": ("marke", "markierung"), "Inzidenz": ("inzidenz",), "Big-M": ("big-m", "big m"), "Subtour": ("subtour",), diff --git a/checks/review.py b/checks/review.py index 68e1a21..24555d6 100644 --- a/checks/review.py +++ b/checks/review.py @@ -4,7 +4,7 @@ from typing import List from checks.model_checks import has_errors from core.llm import LLMSession -from core.model_schema import AssumptionReview, CheckIssue, CheckSeverity, OptimizationModelSpecification +from core.model_schema import AssumptionReview, AssumptionVerdict, CheckIssue, CheckSeverity, OptimizationModelSpecification REVIEW_PROMPT_FILE = Path(__file__).parent / "Pruef-Prompt.txt" @@ -34,6 +34,30 @@ def needs_work(review: AssumptionReview, issues: List[CheckIssue]) -> bool: or any(i.code in REVISION_WORTHY_WARNINGS for i in issues)) +def revision_reasons(review: AssumptionReview, issues: List[CheckIssue]) -> List[str]: + """Warum eine Korrekturrunde nötig ist – fürs Log. + + Bisher stand dort nur „Probleme gefunden“; welche Beanstandung eine Runde auslöste, ließ sich im + Nachhinein nicht mehr feststellen (beobachtet an wagner_whitin_dijkstra, 20260917-160618). + """ + gruende = [] + oc = review.objective_check + if not oc.matches: + gruende.append(f"Zielgröße passt nicht: {oc.explanation[:160]}") + if not oc.units_consistent: + gruende.append(f"Einheiten der Zielfunktion uneinheitlich: {oc.unit_explanation[:160]}") + faulty = {AssumptionVerdict.CONTRADICTS_TASK, AssumptionVerdict.STRICTER_THAN_TASK, AssumptionVerdict.WEAKER_THAN_TASK} + for check in review.checks: + if check.verdict in faulty: + gruende.append(f"Annahme {check.verdict.value}: {check.unclear_point[:120]}") + for fehlend in review.missing_assumptions: + gruende.append(f"fehlende Annahme: {fehlend.unclear_point[:120]}") + for issue in issues: + if issue.severity == CheckSeverity.ERROR or issue.code in REVISION_WORTHY_WARNINGS: + gruende.append(f"[{issue.code}] {issue.message[:120]}") + return gruende + + def select_best(candidates: List[tuple]) -> int: """Index der besten (spec, issues, review)-Runde. diff --git a/reporting/report.py b/reporting/report.py index 9d07d9d..507f136 100644 --- a/reporting/report.py +++ b/reporting/report.py @@ -12,7 +12,7 @@ from pathlib import Path from reporting import charts from core.model_schema import PipelineResult, SolverStatus from solving.script_export import build_solver_script -from solving.solver_stage import _is_sum_row +from solving.solver_stage import DIJKSTRA_EXPLANATION, _is_sum_row class Math(str): @@ -140,9 +140,7 @@ def _graph_section(run) -> list: doc += [("h4", "Markierungsverlauf des Dijkstra-Algorithmus"), ("table", ["Schritt", "Knoten wird endgültig", "Marke (Entfernung von der Quelle)"], [[str(i), s.node, _fmt_number(s.label)] for i, s in enumerate(g.steps, 1)]), - ("p", "Dijkstra nimmt in jedem Schritt den noch nicht endgültigen Knoten mit der kleinsten Marke. " - "Weil die Marken dabei nie fallen, steht jede endgültige Marke schon fest, sobald sie " - "vergeben wird – das ist der Grund, warum ein Durchlauf genügt.")] + ("p", DIJKSTRA_EXPLANATION)] return doc diff --git a/solving/solver_stage.py b/solving/solver_stage.py index e16c315..b73bab8 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -444,6 +444,23 @@ def check_constraint_coverage(constraints: list, spec: OptimizationModelSpecific return problems +def sandbox_warning() -> Optional[str]: + """Hinweis, wenn der erzeugte Code ungeschützt läuft – sonst None. + + Mit SOLVER_SANDBOX=auto lief der Code ohne bubblewrap bisher stillschweigend mit den Rechten des + Nutzers; aufgefallen beim Schreiben der Bedienungsanleitung. Der Hinweis steht deshalb im Log und + in `SolverRun.warnings` (damit im Report), bewusst ohne den Lauf zu verweigern – dafür gibt es `bwrap`. + """ + if SANDBOX == "aus": + return ("Sandbox abgeschaltet (SOLVER_SANDBOX=aus): der vom LLM erzeugte Code lief ohne Isolierung " + "mit den Rechten des Benutzers.") + if SANDBOX != "bwrap" and not shutil.which("bwrap"): + return ("Sandbox fehlt: bubblewrap ist nicht installiert, der vom LLM erzeugte Code lief ohne Isolierung " + "mit den Rechten des Benutzers. Abhilfe: 'sudo apt install bubblewrap'; " + "SOLVER_SANDBOX=bwrap verweigert ungeschützte Läufe.") + return None + + def sandbox_command(script: Path, tmp: str) -> List[str]: """Aufrufzeile für den Solver-Lauf – mit bubblewrap isoliert, sonst direkt. @@ -667,18 +684,30 @@ def fix_transliterations(text: str, extra: dict | None = None) -> str: return re.sub(r"\w+", wort, text or "") +# Feste Erklärung im Report (Graphen-Abschnitt, steht VOR den Ergebnistabellen), sobald Dijkstra-Schritte da sind +DIJKSTRA_EXPLANATION = ("Dijkstra nimmt in jedem Schritt den noch nicht endgültigen Knoten mit der kleinsten Marke. " + "Weil die Marken dabei nie fallen, steht jede endgültige Marke schon fest, sobald sie " + "vergeben wird – das ist der Grund, warum ein Durchlauf genügt.") + + def check_text_quality(run, summary: str = "") -> list: """Texte für Menschen prüfen (ohne LLM): unerklärte Fachbegriffe in Antwortsatz und Tabellen. Umschriebene Umlaute werden nicht mehr gemeldet, sondern still berichtigt (`repair_texts`): die Warnung stand in fast jedem Lauf und bewirkte nichts – im Code sind Umschreibungen erlaubt. + + Als Erklärung zählt neben `executive_summary` auch, was der Report selbst vor den Tabellen erklärt: + der Markierungsverlauf bei Dijkstra. Beobachtet (20260917-160618): „Marke“ in beiden Dijkstra-Läufen + gemeldet, obwohl die Aufgabe Dijkstra ausdrücklich verlangt und der Report den Begriff erklärt – die + Zusammenfassung entsteht vor der Solver-Stufe und kennt ihn noch nicht. """ texte = [("headline", run.headline or "")] for table in run.tables: texte.append((f"Tabelle '{table.title}'", " ".join([table.title, *map(str, table.columns), table.note or ""]))) befunde = [] if summary: - offen = unexplained_jargon(" ".join(text for _, text in texte), summary) + erklaert = summary + (" " + DIJKSTRA_EXPLANATION if run.graph and run.graph.steps else "") + offen = unexplained_jargon(" ".join(text for _, text in texte), erklaert) if offen: befunde.append(f"Fachbegriffe in Antwortsatz oder Ergebnistabellen, die die Zusammenfassung nicht " f"erklärt: {', '.join(offen)}.") @@ -953,6 +982,8 @@ def _check_and_run(code: str, timeout: int, spec: OptimizationModelSpecification except CodeRejected as exc: return SolverRun(status=SolverStatus.ERROR, error=f"Code abgelehnt: {exc}") run = run_solver_code(code, timeout=timeout, data_files=data_files) + if (ungeschuetzt := sandbox_warning()): + run.warnings.append(ungeschuetzt) run.feasibility = check_feasibility(run, spec) run.warnings += check_sensitivity(run, spec.objective.sense.value) run.warnings += check_multistart(run, spec.objective.sense.value) @@ -974,6 +1005,8 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification, infeasible/unbounded gelten nicht als Fehler und werden nicht repariert. """ + if (ungeschuetzt := sandbox_warning()): + log(f"WARNUNG – {ungeschuetzt}") solver_code = generate_solver_code(session, task, spec, understanding=understanding, data_prompt=data_prompt) solver_code.code = clean_generated_code(solver_code.code) diff --git a/tests/test_result_tables.py b/tests/test_result_tables.py index ed3da8b..09daeb9 100644 --- a/tests/test_result_tables.py +++ b/tests/test_result_tables.py @@ -309,3 +309,31 @@ def test_repair_leaves_correct_words_and_identifiers_alone(): assert fix_transliterations(richtig) == richtig namen = "Bindend: Spaet_zu_Frueh_Verbot und KeinFruehNachSpaet sowie Kapazitaet_M1." assert fix_transliterations(namen) == namen + + +def _dijkstra_run(title, note, steps=True): + from core.model_schema import GraphSolution, ResultTable + graph = GraphSolution.model_validate({ + "nodes": ["0", "1"], "arcs": [{"source": "0", "target": "1", "weight": 8}], "path": ["0", "1"], + "steps": [{"node": "0", "label": 0}, {"node": "1", "label": 8}] if steps else [], + "node_meaning": "Zeitpunkt", "arc_meaning": "Kauf und Nutzung"}) + return SolverRun(status=SolverStatus.OPTIMAL, objective_value=8, graph=graph, + tables=[ResultTable(title=title, columns=["Knoten", "Marke"], rows=[["0", 0]], note=note)]) + + +def test_dijkstra_marks_explained_by_the_report_are_not_flagged(): + # beobachtet: „Dijkstra-Markierungen“ gemeldet, obwohl der Report den Markierungsverlauf erklärt + from solving.solver_stage import check_text_quality + run = _dijkstra_run("Dijkstra-Markierungen", "Die Knoten in der Reihenfolge ihrer Markierung.") + assert check_text_quality(run, "Ersatzplanung als kürzester Weg.") == [] + + +def test_marks_without_dijkstra_steps_are_still_flagged(): + from solving.solver_stage import check_text_quality + run = _dijkstra_run("Marken je Knoten", None, steps=False) + assert any("Markierung" in b for b in check_text_quality(run, "Ersatzplanung als kürzester Weg.")) + + +def test_everyday_marked_is_no_jargon(): + from checks.model_checks import unexplained_jargon + assert unexplained_jargon("nur die mit 'ja' markierten Kombinationen gehören zum Plan", "Zusammenfassung") == [] diff --git a/tests/test_review.py b/tests/test_review.py index e85b3b1..3dc72bf 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -64,3 +64,33 @@ def test_fewer_errors_preferred_when_all_have_errors(): def test_mixed_units_trigger_revision(): # beobachtet: Rendite/Kosten in Euro, Risiko als dimensionslose Summe w_i^2 sigma_i^2 (Faktor 100.000 zu klein) assert make_review(units_consistent=False).needs_revision + + +# --- Grund jeder Korrekturrunde im Log (vorher nur „Probleme gefunden“) --- + +def test_revision_reasons_name_every_trigger(): + from checks.review import needs_work, revision_reasons + from core.model_schema import CheckIssue, CheckSeverity + fehlend = {"unclear_point": "Einkaufspreis nicht beziffert", "assumption": "-", "alternative": "-", "impact": "-"} + review = make_review(matches=False, verdict="weaker_than_task", missing=[fehlend]) + issues = [CheckIssue(severity=CheckSeverity.ERROR, code="MIXED_OBJECTIVE_UNITS", message="Risiko dimensionslos"), + CheckIssue(severity=CheckSeverity.WARNING, code="MISSING_DATA", message="kein Wert für p")] + gruende = revision_reasons(review, issues) + assert needs_work(review, issues) + assert any(g.startswith("Zielgröße passt nicht") for g in gruende) + assert any("weaker_than_task" in g for g in gruende) + assert any("Einkaufspreis nicht beziffert" in g for g in gruende) + assert any("MIXED_OBJECTIVE_UNITS" in g for g in gruende) + assert not any("MISSING_DATA" in g for g in gruende) # löst keine Runde aus, gehört nicht in den Grund + + +def test_clean_state_has_no_revision_reasons(): + from checks.review import revision_reasons + assert revision_reasons(make_review(), []) == [] + + +def test_review_prompt_allows_both_forms_of_decision_independent_costs(): + # wagner_whitin_dijkstra: Einkaufspreis ohne Zahl – je 1–2 Korrekturrunden, weil Regel 4 beide Formen beanstanden konnte + from pathlib import Path + prompt = (Path(__file__).parent.parent / "checks" / "Pruef-Prompt.txt").read_text(encoding="utf-8") + assert "entscheidungsunabhängige Terme" in prompt and "Verlange keine Umstellung" in prompt diff --git a/tests/test_solver_stage.py b/tests/test_solver_stage.py index 13a4dbc..57dd5d3 100644 --- a/tests/test_solver_stage.py +++ b/tests/test_solver_stage.py @@ -811,3 +811,26 @@ def test_scenario_without_result_is_a_finding_unless_proven_unsolvable(wert, sta assert bool(befunde) == befund if befund: assert "Ausgewogene Skalarisierung" in befunde[0] and "Startlösung" in befunde[0] + + +@pytest.mark.parametrize("modus, bwrap_da, erwartet", [ + ("auto", False, "bubblewrap ist nicht installiert"), # früher stillschweigend ungeschützt + ("aus", True, "abgeschaltet"), + ("auto", True, None), + ("bwrap", False, None), # Lauf wird ohnehin verweigert +]) +def test_sandbox_warning_names_unprotected_runs(monkeypatch, modus, bwrap_da, erwartet): + from solving import solver_stage + monkeypatch.setattr(solver_stage, "SANDBOX", modus) + monkeypatch.setattr(solver_stage.shutil, "which", lambda name: "/usr/bin/bwrap" if bwrap_da else None) + warnung = solver_stage.sandbox_warning() + assert (warnung is None) if erwartet is None else (erwartet in warnung) + + +def test_unprotected_run_carries_the_warning_into_the_result(monkeypatch): + from solving import solver_stage + from test_model_checks import make_spec + monkeypatch.setattr(solver_stage, "SANDBOX", "aus") + code = 'def main():\n """Test."""\n report("optimal", 1.0, {})\n\n\nif __name__ == "__main__":\n main()\n' + run = solver_stage._check_and_run(code, 30, make_spec()) + assert any("Sandbox abgeschaltet" in w for w in run.warnings)