From f101b63947be03e701abb9c5fbb777f2fe69775c Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 18 Sep 2026 18:15:22 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Solver-Fehler=20gilt=20als=20unzuverl?= =?UTF-8?q?=C3=A4ssig;=20Aufr=C3=A4umrunde=20ohne=20LLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status error nach allen Reparaturen -> reliable=False („Solver-Fehler: …“, auch bei --resolve); vorher Exit-Code 0 ohne Warnkasten. Benchmark zählt einen Absturz nicht als ehrliches Scheitern. - Report: „längster Weg“ bei maximize; Harness druckt „Gefundener Weg“. - check_requested_method: Verneinung kurz davor im selben Satz („ohne/nicht/statt/ anders als Dijkstra“) ist keine Forderung. - Reparatur-Prompt enthält die Fehlertexte aller früheren Versuche. - ruff (F, E9) als Test tests/test_lint.py, Konfiguration in pyproject.toml. Co-Authored-By: Claude Opus 5 --- BEDIENUNGSANLEITUNG.md | 1 + TextToModel.py | 18 ++++++++++++++++-- benchmark/benchmark.py | 3 ++- checks/model_checks.py | 12 +++++++++++- pyproject.toml | 8 ++++++++ reporting/report.py | 14 +++++++++----- requirements.txt | 1 + solving/solver_stage.py | 15 +++++++++++---- tests/test_charts.py | 11 +++++++++++ tests/test_lint.py | 19 +++++++++++++++++++ tests/test_model_checks.py | 17 +++++++++++++++++ tests/test_pipeline.py | 33 +++++++++++++++++++++++++++++++++ tests/test_resolve.py | 10 ++++++++++ 13 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 tests/test_lint.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index c3a6843..1cb5170 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -270,6 +270,7 @@ Der Report folgt dem Weg von der Aufgabe zur Lösung. Ganz oben stehen die **Lau | Zulässigkeitsprüfung | Die gemeldete Lösung verletzt Restriktionen, Schranken oder Ganzzahligkeit. | `--von solver` versuchen. | | Datenherkunft | Das Programm hat Eingabedaten erzeugt, ohne sie auszuweisen. | Daten beilegen (`--daten`) oder fehlende Werte in den Text schreiben. | | Szenario ohne Ergebnis | Eine verglichene Konfiguration hat keinen Wert, ist aber nicht als unlösbar belegt (z. B. Zeitlimit). | `--von solver`. | +| Solver-Fehler | Das erzeugte Programm ist auch nach allen Reparaturversuchen gescheitert (Absturz, Zeitlimit, kein Ergebnis). Die Fehlermeldung steht im Abschnitt „Ergebnis“. | `--von solver`; bei Zeitlimit `SOLVER_TIMEOUT` erhöhen. | | Unlösbar, Gegenprobe uneindeutig | Der Solver meldet „unlösbar“, eine unabhängige zweite Modellierung konnte das weder bestätigen noch widerlegen. | Aufgabe auf widersprüchliche Bedingungen prüfen. | ### Besondere Ergebnisse diff --git a/TextToModel.py b/TextToModel.py index ccf4b9b..eba6a27 100644 --- a/TextToModel.py +++ b/TextToModel.py @@ -213,6 +213,11 @@ def _assess_run(result: PipelineResult) -> None: """Abnahme eines Solver-Laufs: Befunde, die das Ergebnis unzuverlässig machen, in `result` übernehmen.""" run = result.solver_run log(f"Solver: {run.status.value}, Zielwert: {run.objective_value}" + (f"\n {run.error}" if run.error else "")) + if run.status == SolverStatus.ERROR: + # Auch nach allen Reparaturen kein Ergebnis – bisher blieb reliable=True und die CLI endete mit 0 + result.reliable = False + result.reliability_issues.append(f"Solver-Fehler: {solver_error_summary(run)}") + log("ERGEBNIS UNZUVERLÄSSIG – das Solver-Programm ist auch nach allen Reparaturversuchen gescheitert.") if run.feasibility: result.reliable = False result.reliability_issues += [f"Zulässigkeitsprüfung: {p}" for p in run.feasibility] @@ -233,6 +238,12 @@ def _assess_run(result: PipelineResult) -> None: log(f" Mit angenommenen Daten gelöst ({len(result.solver_code.data_assumptions)} Annahmen).") +def solver_error_summary(run) -> str: + """Erste aussagekräftige Zeile des Fehlers – der volle Text steht im Report unter „Ergebnis“.""" + zeilen = [z.strip() for z in (run.error or "").splitlines() if z.strip()] + return (zeilen[-1] if zeilen else "das Programm meldete selbst Status 'error'")[:200] + + def _log_usage(session, result: PipelineResult) -> None: result.usage = session.usage cost = f", Kosten ${session.usage.cost_usd:.4f}" if session.usage.cost_usd is not None else "" @@ -394,8 +405,11 @@ def resolve_result(result_json: str, data_file: str | None = None, solver_timeou run.warnings += check_multistart(run, result.specification.objective.sense.value) result.solver_run = run - # Frühere Befunde (Zielgröße, Abbildung, Datenherkunft) gelten weiter – nur die Zulässigkeit ist neu geprüft - result.reliability_issues = ([p for p in result.reliability_issues if not p.startswith("Zulässigkeitsprüfung: ")] + # Frühere Befunde (Zielgröße, Abbildung, Datenherkunft) gelten weiter – nur Lauf und Zulässigkeit sind neu geprüft + result.reliability_issues = ([p for p in result.reliability_issues + if not p.startswith(("Zulässigkeitsprüfung: ", "Solver-Fehler: "))] + + ([f"Solver-Fehler: {solver_error_summary(run)}"] + if run.status == SolverStatus.ERROR else []) + [f"Zulässigkeitsprüfung: {p}" for p in run.feasibility]) result.reliable = not result.reliability_issues result.usage = None # keine LLM-Nutzung in diesem Lauf diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 3eb5f07..751600e 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -105,7 +105,8 @@ def observed_behaviors(result) -> set: gezeigt = set() if result.rejected: return {"rejected"} - if not result.reliable: + # Ein Absturz ist kein Eingeständnis: nur Befunde außer „Solver-Fehler“ zählen als ehrlich unzuverlässig + if not result.reliable and any(not i.startswith("Solver-Fehler: ") for i in result.reliability_issues): gezeigt.add("unreliable") if result.solver_code is not None and result.solver_code.data_assumptions: gezeigt.add("assumed_data") diff --git a/checks/model_checks.py b/checks/model_checks.py index 9a849ee..42611a9 100644 --- a/checks/model_checks.py +++ b/checks/model_checks.py @@ -503,12 +503,22 @@ REQUESTED_METHOD = { } +# „ohne Dijkstra“, „nicht mit dem Simplex“, „statt Branch-and-Bound“: Erwähnung, keine Forderung. Nur im selben +# Satz und kurz davor – „Lösen Sie mit Dijkstra. Ohne Rechenweg gibt es keine Punkte.“ bleibt eine Forderung. +NEGATION_BEFORE = re.compile(r"\b(?:ohne|nicht|keine?[nmrs]?|statt|anstelle|anstatt|außer|ausser|anders als)\b[^.!?;\n]{0,30}$") + + +def _requested(name: str, text: str) -> bool: + """Kommt das Verfahren mindestens einmal ohne vorangehende Verneinung vor?""" + return any(not NEGATION_BEFORE.search(text[:m.start()]) for m in re.finditer(re.escape(name), text)) + + def check_requested_method(spec: OptimizationModelSpecification, task: str) -> List[CheckIssue]: """Ein im Aufgabentext ausdrücklich verlangtes Verfahren muss auch verwendet werden.""" text = (task or "").lower() issues = [] for name, stacks in REQUESTED_METHOD.items(): - if name in text and spec.target_stack not in stacks: + if _requested(name, text) and spec.target_stack not in stacks: issues.append(CheckIssue( severity=CheckSeverity.ERROR, code="REQUESTED_METHOD_IGNORED", message=f"Die Aufgabe verlangt ausdrücklich '{name}', das Modell nutzt aber " diff --git a/pyproject.toml b/pyproject.toml index b6115fb..16da3b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,11 @@ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = [".", "tests"] + +[tool.ruff] +extend-exclude = ["output", "benchmark/results"] + +[tool.ruff.lint] +# Fehlerklassen, die tatsächlich Fehler sind: unbenutzte/undefinierte Namen (F), Syntaxfehler (E9). +# Stilregeln bewusst nicht – der Code folgt eigenen Konventionen (deutsche Namen, lange Zeilen). +select = ["F", "E9"] diff --git a/reporting/report.py b/reporting/report.py index 765ee58..09756de 100644 --- a/reporting/report.py +++ b/reporting/report.py @@ -128,20 +128,24 @@ def split_math(text: str) -> list: return _join_text(parts) -def _graph_section(run) -> list: - """Zeichnung des Graphen mit hervorgehobenem Weg, darüber die Bedeutung von Knoten und Bogen.""" +def _graph_section(run, sense: str = "minimize") -> list: + """Zeichnung des Graphen mit hervorgehobenem Weg, darüber die Bedeutung von Knoten und Bogen. + + Bei maximize (längster Weg, z. B. kritischer Pfad) heißt der Weg nicht „kürzester“. + """ g = run.graph if g is None: return [] + weg = "längste" if sense == "maximize" else "kürzeste" doc = [("p", f"Knoten des Graphen: {g.node_meaning} Ein Bogen: {g.arc_meaning}")] zeichnung = charts.graph_chart([n for n in g.nodes], [(a.source, a.target, a.weight) for a in g.arcs], - path=list(g.path), title="Graph mit kürzestem Weg") + path=list(g.path), title=f"Graph mit {weg}m Weg") if zeichnung: doc.append(("svg", zeichnung)) if g.path and run.objective_value is not None: auf_weg = set(zip(g.path, g.path[1:])) summanden = " + ".join(_fmt_number(a.weight) for a in g.arcs if (a.source, a.target) in auf_weg) - doc.append(("p", "Hervorgehoben ist der kürzeste Weg " + " → ".join(g.path) + doc.append(("p", f"Hervorgehoben ist der {weg} Weg " + " → ".join(g.path) + f" mit der Länge {summanden} = {_fmt_number(run.objective_value)}.")) if g.steps: doc += [("h4", "Markierungsverlauf des Dijkstra-Algorithmus"), @@ -182,7 +186,7 @@ def _result_section(result) -> list: f"{len(run.variables)} Variablen wurden aus der Lösung nachgerechnet und eingehalten.")) # Die Erklärung („Wie das Modell die Aufgabe sieht“) steht als eigener Abschnitt vor dem Modell, also schon # vor diesem Ergebnis – die Fachbegriffe der Tabellen sind damit eingeführt, bevor sie auftauchen. - doc += _graph_section(run) + doc += _graph_section(run, spec.objective.sense.value) for table in run.tables: doc += [("h3", table.title), ("table", table.columns, [[_fmt_cell(c) for c in row] for row in table.rows])] diff --git a/requirements.txt b/requirements.txt index 8569ee0..5fecf20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ pandas>=2.2 markdown-it-py>=3.0 # Aufgabentext im HTML-Report (bisher nur indirekt über rich) numpy pytest +ruff # tests/test_lint.py: unbenutzte und undefinierte Namen diff --git a/solving/solver_stage.py b/solving/solver_stage.py index 5f6aa70..164b613 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -115,7 +115,7 @@ def report(status, objective_value, solution, tables=None, headline=None, constr print(" Ein Knoten ist: " + str(graph.get("node_meaning", ""))) print(" Ein Bogen ist: " + str(graph.get("arc_meaning", ""))) if graph.get("path"): - print(" Kürzester Weg: " + " → ".join(str(k) for k in graph["path"])) + print(" Gefundener Weg: " + " → ".join(str(k) for k in graph["path"])) # kürzester oder längster for schritt in graph.get("steps") or []: print(f" Marke endgültig: {{schritt.get('node')}} = {{schritt.get('label')}}") if solution: @@ -644,7 +644,7 @@ def solver_prompt() -> str: def generate_solver_code( session: LLMSession, task: str, spec: OptimizationModelSpecification, previous: Optional[SolverCode] = None, previous_error: Optional[str] = None, understanding=None, - data_prompt: str = "", + data_prompt: str = "", earlier_errors=(), ) -> SolverCode: content = f"AUFGABENTEXT:\n{task}\n\nMODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}" if data_prompt: @@ -655,9 +655,13 @@ def generate_solver_code( content += ("\n\nZU BEANTWORTENDE TEILFRAGEN (für jede genau ein Eintrag in answers, " f"question_id wie angegeben):\n{fragen}") if previous is not None: + # Ältere Fehlversuche nur als Fehlertext (nicht der ganze Code): sonst behebt die Reparatur den letzten Fehler + # und führt den vorletzten wieder ein – die Versuche gehen im Kreis. + frueher = "".join(f"\n- Versuch {n}: {fehler[:400]}" for n, fehler in enumerate(earlier_errors, 1)) content += ( f"\n\nDER VORHERIGE CODE IST FEHLGESCHLAGEN.\nCODE:\n{previous.code}\n\nFEHLER:\n{previous_error}\n\n" - "Behebe den Fehler. Das Modell selbst darf dabei nicht verändert werden." + + (f"FRÜHERE FEHLVERSUCHE (schon einmal aufgetreten – nicht wieder einführen):{frueher}\n\n" if frueher else "") + + "Behebe den Fehler. Das Modell selbst darf dabei nicht verändert werden." ) return session.create( SolverCode, @@ -1176,6 +1180,7 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification, zerlegung = check_objective_decomposition(run, spec) verschwiegen = check_answers_disclose_assumptions(run, solver_code.code, solver_code, spec) attempt = 1 + fehlerverlauf = [] # Fehler aller bisherigen Versuche, für die Reparatur while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data or run.unsolved_scenarios or einheiten or zerlegung or verschwiegen) and attempt <= retries: # Alle Befundarten zugleich melden – sonst kostet jede weitere Art einen eigenen Reparaturversuch @@ -1190,7 +1195,9 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification, if befunde) log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}") solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler, - understanding=understanding, data_prompt=data_prompt) + understanding=understanding, data_prompt=data_prompt, + earlier_errors=fehlerverlauf) + fehlerverlauf.append(fehler) solver_code.code = clean_generated_code(solver_code.code) run = _check_and_run(solver_code.code, timeout, spec, understanding, task, data_files, data_prompt) run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec, data_files) diff --git a/tests/test_charts.py b/tests/test_charts.py index 4619c46..0f56022 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -191,3 +191,14 @@ def test_line_breaks_of_the_task_are_kept(): from reporting.report import task_html seite = task_html("Pfeile:\n1 → 2: 3\n2 → 4: 5") assert seite.count("