feat: Erfundene Einheiten in Antworten prüfen; data_assumptions-Kanal klargestellt

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-17 14:07:57 +02:00
commit c68a293755
4 changed files with 107 additions and 5 deletions

View file

@ -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. - 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. - 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. - 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. - 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. 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“. - 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. - 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] - 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. - 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] - 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: - scenarios: NUR wenn die Aufgabe mehrere Konfigurationen vergleicht (z. B. drei Gewichtungen), sonst leer. Ein Szenario ist eine DATENVARIANTE desselben Modells, kein zweites Modell:

View file

@ -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)): and all(numbers_in(p.extracted_values or "") for p in spec.parameters)):
return [] return []
return ["Das Programm erzeugt Zahlen mit einem Zufallsgenerator, weist aber keine angenommenen " 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."] "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 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"(?<![\wäöüß])([(\[]\s*|in\s+)?(" + _UNIT_ALT + r")(?![\wäöüß])")
_UNIT_AFTER_NUMBER = re.compile(r"\d(?:[\d.,]*\d)?\s*(?:(?:Tsd|Mio|Mrd)\.?\s*)?(" + _UNIT_ALT + r")(?![\wäöüß])")
# Neutrale Platzhalter sind keine Erfindung, wenn der Text die Größe benennt, nur nicht ihre Einheit
_NEUTRAL_GELD = re.compile(r"kost|preis|gewinn|erlös|erloes|umsatz|budget|kapital|deckungsbeitrag|ausgabe|einnahme", re.I)
_NEUTRAL_MENGE = re.compile(r"menge|stück|stueck|produz|liefer|bedarf|nachfrage", re.I)
_NEUTRAL_UNITS = {"GE": _NEUTRAL_GELD, "Geldeinheit": _NEUTRAL_GELD, "Geldeinheiten": _NEUTRAL_GELD,
"ME": _NEUTRAL_MENGE, "Mengeneinheit": _NEUTRAL_MENGE, "Mengeneinheiten": _NEUTRAL_MENGE}
def check_answer_units(run, task: str, data_summary: str = "") -> 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: def check_graph(run, sense: str) -> list:
"""Graph und kürzesten Weg deterministisch nachrechnen (ohne LLM). """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) 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 = _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) run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec)
einheiten = check_answer_units(run, task, data_prompt)
attempt = 1 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)) fehler = run.error or (("Die Lösung hält das Modell nicht ein:\n- " + "\n- ".join(run.feasibility))
if run.feasibility else 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]}") log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}")
solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler, 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)
solver_code.code = clean_generated_code(solver_code.code) 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 = _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) run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec)
einheiten = check_answer_units(run, task, data_prompt)
attempt += 1 attempt += 1
# Bleibt die erfundene Einheit, ist das Ergebnis trotzdem richtig – Warnung, nicht unzuverlässig
run.warnings += einheiten
run.attempts = attempt run.attempts = attempt
return solver_code, run return solver_code, run

View file

@ -422,7 +422,7 @@ if __name__ == "__main__":
sub_questions=[SubQuestion(id="a", quote="Formulieren Sie das Modell!", kind="formulieren", needs_solver=False), 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)]) SubQuestion(id="b", quote="Berechnen Sie die Kosten!", kind="rechnen", needs_solver=True)])
session = FakeSession([GOOD], [OK], [SolverCode(code=code)], understandings=[verstanden]) 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)) markdown = to_markdown(build_document(result))
assert "Antworten auf die Aufgabe" in markdown assert "Antworten auf die Aufgabe" in markdown
assert markdown.index("Formulieren Sie das Modell!") < markdown.index("| Status |") assert markdown.index("Formulieren Sie das Modell!") < markdown.index("| Status |")

View file

@ -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]]) table = dict(DECOMPOSITION, rows=[["Rendite", 0.097557], ["Kosten", -0.05], ["Zielwert", 0.2]])
_, warnings = parse_result_tables([table], objective_value=0.097557) _, warnings = parse_result_tables([table], objective_value=0.097557)
assert any("weicht vom Zielwert" in w for w in warnings) 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.") == []