feat: Warnung bei ungeschützter Sandbox, Prüf-Regel für konstante Kosten, Grund je Korrekturrunde
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
26f988a953
commit
0f1ab04a8d
9 changed files with 148 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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") == []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue