From c68a2937554457ce7270ed54dcbf2b272ddcc133 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 17 Sep 2026 14:07:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Erfundene=20Einheiten=20in=20Antworten?= =?UTF-8?q?=20pr=C3=BCfen;=20data=5Fassumptions-Kanal=20klargestellt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_answer_units meldet Einheiten hinter Zahlen, die weder Aufgabentext noch Datenprofil hergeben (nach Größenart, GE/ME neutral bei Kosten-/Mengenwörtern); Befund löst Solver-Reparatur aus, bleibt er, Warnung. Reparaturmeldung und Solver-Prompt nennen data_assumptions jetzt als Antwortfeld, nicht report()-Argument. Co-Authored-By: Claude Opus 5 --- solving/Solver-Prompt.txt | 3 +- solving/solver_stage.py | 65 +++++++++++++++++++++++++++++++++++-- tests/test_pipeline.py | 2 +- tests/test_result_tables.py | 42 ++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/solving/Solver-Prompt.txt b/solving/Solver-Prompt.txt index d0194f4..7e06819 100644 --- a/solving/Solver-Prompt.txt +++ b/solving/Solver-Prompt.txt @@ -11,7 +11,7 @@ Regeln für das Modell: - Implementiere Variablen, Zielfunktion und Restriktionen exakt wie in der Spezifikation. Lasse keine Restriktion weg, lockere keine und füge keine hinzu. - Ist das Modell unlösbar oder unbeschränkt, melde genau das (Status infeasible bzw. unbounded). Verändere das Modell nicht, um eine Lösung zu erzwingen. - Daten: Verwende die Werte aus extracted_values bzw. dem Aufgabentext. Fehlen Werte, wähle kleine, plausible Beispieldaten und führe JEDEN angenommenen Wert in data_assumptions auf (Symbol, Wert, Begründung). Stehen alle Werte im Text, bleibt data_assumptions leer. -- Erzeugst du Eingabedaten selbst – Zufallszahlen, ein synthetischer Markt, ein verkleinertes Universum, weil die echten Daten nicht erreichbar sind –, gehört das IMMER nach data_assumptions, nicht nur in eine Tabelle oder einen Antwortsatz. Sonst ist dem Ergebnis maschinell nicht anzusehen, dass es nicht auf den Daten der Aufgabe beruht. [→ check_generated_data] +- Erzeugst du Eingabedaten selbst – Zufallszahlen, ein synthetischer Markt, ein verkleinertes Universum, weil die echten Daten nicht erreichbar sind –, gehört das IMMER nach data_assumptions (Feld deiner Antwort neben code, kein Argument von report()), nicht nur in eine Tabelle oder einen Antwortsatz. Sonst ist dem Ergebnis maschinell nicht anzusehen, dass es nicht auf den Daten der Aufgabe beruht. [→ check_generated_data] - Die offenen Annahmen (open_assumptions) der Spezifikation gelten und sind umzusetzen. Erlaubte Imports (andere sind verboten und führen zum Abbruch): json, math, itertools, functools, collections, numpy, scipy, highspy, ortools, cvxpy, pyscipopt, cyipopt, networkx, pandas. @@ -85,6 +85,7 @@ Ergebnisausgabe: - Auch bei Status infeasible oder unbounded wird JEDE Teilfrage beantwortet – dann lautet die Antwort, dass und warum die Aufgabe keine zulässige Lösung hat (welche Bedingungen sich widersprechen). Eine unbeantwortete Frage ist schlechter als die Antwort „unlösbar“. - Teilfragen ohne Rechnung (formulieren, zeichnen, erklaeren) werden ebenfalls beantwortet – dort beschreibt der Text das aufgestellte Modell bzw. die Struktur der Zeichnung in Worten. - Verwende NUR Zahlen, die dieses Programm berechnet hat oder die im Aufgabentext stehen. [→ check_answers] + - Ebenso bei Einheiten: nur solche, die im Aufgabentext oder in beigelegten Daten stehen. Nennt der Text keine, steht die Zahl ohne Einheit („Der kleinste Zielwert ist 459.“); GE bzw. ME nur, wenn der Text von Kosten, Preisen bzw. Mengen spricht. [→ check_answer_units] - WICHTIG, häufigster Fehler: Sobald eine Antwort einen Vergleichs- oder Zwischenwert nennt, muss dieser Wert auch in einer Ergebnistabelle stehen. Beobachtet: „der beste Weg kostet 29, die Alternative 30“ – die 30 stand nirgends, der Leser konnte sie nicht prüfen. Richtig ist eine zusätzliche Tabelle („Alle Wege“, „Vergleich der Varianten“, „Kostenbestandteile“) mit genau diesen Zahlen. Ebenso für Summen wie „insgesamt 2,32 % Gebühren“: entweder eine Summenzeile in der Tabelle oder die Zahl weglassen. - Fehlt eine Teilfrage, ist die Aufgabe nicht erfüllt – auch wenn der Zielwert stimmt. [→ check_answers] - scenarios: NUR wenn die Aufgabe mehrere Konfigurationen vergleicht (z. B. drei Gewichtungen), sonst leer. Ein Szenario ist eine DATENVARIANTE desselben Modells, kein zweites Modell: diff --git a/solving/solver_stage.py b/solving/solver_stage.py index 59e2322..9220824 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -743,7 +743,8 @@ def check_generated_data(code: str, solver_code, spec=None) -> list: and all(numbers_in(p.extracted_values or "") for p in spec.parameters)): return [] return ["Das Programm erzeugt Zahlen mit einem Zufallsgenerator, weist aber keine angenommenen " - "Daten aus. Selbst erzeugte Eingabedaten gehören in data_assumptions – sonst ist dem " + "Daten aus. Selbst erzeugte Eingabedaten gehören in data_assumptions – das Feld der Antwort " + "neben code, kein Argument von report() –, sonst ist dem " "Ergebnis nicht anzusehen, dass es nicht auf den Daten der Aufgabe beruht."] @@ -802,6 +803,57 @@ def check_answers(run, understanding, task: str = "", data_summary: str = "") -> return befunde +# Einheiten nach Größenart: gedeckt ist eine Einheit, wenn der Text irgendeine derselben Art nennt +# ("Tausend Euro" deckt "€", "Stunden" deckt "min"). Prozent fehlt bewusst – es ist keine Einheit, die +# man erfinden kann, sondern eine Schreibweise für Anteile. +UNIT_FAMILIES = { + "Geld": ("€", "EUR", "Euro", "$", "USD", "Dollar", "CHF", "Cent", "ct", "GE", "Geldeinheit", "Geldeinheiten"), + "Menge": ("Stück", "Stk", "ME", "Mengeneinheit", "Mengeneinheiten"), + "Zeit": ("h", "Std", "Stunde", "Stunden", "min", "Minute", "Minuten", "s", "Sekunden", "Tag", "Tage", "Tagen", + "Woche", "Wochen", "Monat", "Monate", "Monaten", "Jahr", "Jahre", "Jahren"), + "Masse": ("g", "kg", "t", "Tonne", "Tonnen"), + "Länge": ("mm", "cm", "m", "km", "Meter", "Kilometer"), + "Volumen": ("l", "Liter", "hl", "m³"), +} +_UNIT_OF = {einheit: art for art, einheiten in UNIT_FAMILIES.items() for einheit in einheiten} +_UNIT_ALT = "|".join(sorted(map(re.escape, _UNIT_OF), key=len, reverse=True)) +# Kurze Einheiten (h, s, t, kg) decken nur mit Zahl davor oder in Klammern – sonst deckt „s.t." oder x_t alles +_UNIT_COVER = re.compile(r"(? list: + """Nennt eine Antwort eine Einheit, die weder Aufgabentext noch beigelegte Daten hergeben? + + Beobachtet an `tasks/nur_formeln.md` (reines ILP ohne jede Einheit): „459 Geldeinheiten (GE)", + obwohl die eigene Annahme die Zahlen dimensionslos nannte; in der Urfassung der Ersatzplanung + („Kosten" ohne Währung) dreimal „29 €". Die Regel im Erklär-Block allein wirkte nicht. + Belegt ist nur, was im Text steht – `ParameterDefinition.unit` taugt nicht, dort erfand dasselbe + LLM „Stück" und „Mengeneinheit". An 146 gespeicherten Läufen: 4 Treffer, alle echt, kein Fehlalarm. + Der Größenfaktor („Tausend Euro" gegen „€") wird bewusst nicht geprüft. + """ + gedeckt = {_UNIT_OF[m.group(1)] for m in _UNIT_AFTER_NUMBER.finditer(task)} + gedeckt |= {_UNIT_OF[m.group(2)] for m in _UNIT_COVER.finditer(task) + if len(m.group(2)) > 2 or not m.group(2).isalpha() or m.group(1)} + # Datenprofil: Spaltennamen wie emission_kg tragen die Einheit ohne Zahl davor + gedeckt |= {_UNIT_OF[m.group(2)] for m in _UNIT_COVER.finditer(data_summary.replace("_", " "))} + befunde = [] + for antwort in run.answers: + erfunden = sorted({m.group(1) for m in _UNIT_AFTER_NUMBER.finditer(antwort.text or "") + if _UNIT_OF[m.group(1)] not in gedeckt + and not (m.group(1) in _NEUTRAL_UNITS and _NEUTRAL_UNITS[m.group(1)].search(task))}) + if erfunden: + befunde.append(f"Antwort zu '{antwort.question_id}' nennt die Einheit {', '.join(erfunden)}, die weder im " + f"Aufgabentext noch in beigelegten Daten steht. Nennt der Text keine Einheit, steht die " + f"Zahl ohne Einheit – erfinde keine.") + return befunde + + def check_graph(run, sense: str) -> list: """Graph und kürzesten Weg deterministisch nachrechnen (ohne LLM). @@ -877,17 +929,24 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification, 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) + einheiten = check_answer_units(run, task, data_prompt) attempt = 1 - while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data) and attempt <= retries: + while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data or einheiten) \ + and attempt <= retries: fehler = run.error or (("Die Lösung hält das Modell nicht ein:\n- " + "\n- ".join(run.feasibility)) if run.feasibility else - ("Eingabedaten nicht ausgewiesen:\n- " + "\n- ".join(run.undeclared_data))) + ("Eingabedaten nicht ausgewiesen:\n- " + "\n- ".join(run.undeclared_data)) + if run.undeclared_data else + ("Einheit in der Antwort erfunden:\n- " + "\n- ".join(einheiten))) 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) 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) + einheiten = check_answer_units(run, task, data_prompt) attempt += 1 + # Bleibt die erfundene Einheit, ist das Ergebnis trotzdem richtig – Warnung, nicht unzuverlässig + run.warnings += einheiten run.attempts = attempt return solver_code, run diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index ee8339d..416db06 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -422,7 +422,7 @@ if __name__ == "__main__": 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("Aufgabe", session=session, max_revisions=0, max_restarts=0) + 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 |") diff --git a/tests/test_result_tables.py b/tests/test_result_tables.py index 9f6cc3a..0dace43 100644 --- a/tests/test_result_tables.py +++ b/tests/test_result_tables.py @@ -251,3 +251,45 @@ def test_decomposition_with_one_term_equal_to_objective_is_still_checked(): table = dict(DECOMPOSITION, rows=[["Rendite", 0.097557], ["Kosten", -0.05], ["Zielwert", 0.2]]) _, warnings = parse_result_tables([table], objective_value=0.097557) assert any("weicht vom Zielwert" in w for w in warnings) + + +# --- Einheiten in Antworten: nur, was der Text hergibt --- + +def _run_with_answer(text): + from core.model_schema import Answer + return SolverRun(status=SolverStatus.OPTIMAL, objective_value=459, answers=[Answer(question_id="1", text=text)]) + + +def test_invented_unit_in_answer_is_found(): + # beobachtet an tasks/nur_formeln.md: reines ILP ohne jede Einheit + from solving.solver_stage import check_answer_units + befunde = check_answer_units(_run_with_answer("Der minimale Zielfunktionswert beträgt 459 Geldeinheiten (GE)."), + "min z = 17*x1 + 23*x2 s.t. 6*x1 + 4*x2 >= 120") + assert len(befunde) == 1 and "Geldeinheiten" in befunde[0] + + +def test_currency_is_invented_when_the_text_only_says_costs(): + from solving.solver_stage import check_answer_units + aufgabe = "Die Kostenmatrix C enthält die Kosten je Nutzungsdauer." + assert check_answer_units(_run_with_answer("Die Gesamtkosten betragen 29 €."), aufgabe) + assert check_answer_units(_run_with_answer("Die Gesamtkosten betragen 29 GE."), aufgabe) == [] # neutral + + +def test_unit_of_the_same_kind_in_the_text_is_covered(): + from solving.solver_stage import check_answer_units + aufgabe = "Kosten in Tausend Euro; Rüstzeit 2 Stunden." + run = _run_with_answer("Die Kosten betragen 29 Tsd. € bei 90 min Rüstzeit, Auslastung 85 %.") + assert check_answer_units(run, aufgabe) == [] + + +def test_unit_from_attached_data_is_covered(): + from solving.solver_stage import check_answer_units + run = _run_with_answer("Der Transport kostet 120 kg CO2.") + assert check_answer_units(run, "Minimiere die Emissionen.") != [] + assert check_answer_units(run, "Minimiere die Emissionen.", data_summary="Spalte emission_kg") == [] + + +def test_numbers_and_words_are_not_mistaken_for_units(): + from solving.solver_stage import check_answer_units + run = _run_with_answer("Teil 2 zeigt: x1 = 6, 3 Maschinen, Stufe 2a, am Tag 3 sind alle 5 Aufträge erledigt.") + assert check_answer_units(run, "Plane die Aufträge.") == []