refactor: Pipeline-Stufen in Ordner mit ihren Prompts

Im Wurzelverzeichnis liegt nur noch TextToModel.py (Ablauf und CLI).
Jede Stufe liegt mit Code und Prompt in einem eigenen Ordner: core,
understanding, modeling, checks, solving, reporting, data, benchmark;
Tests unter tests/, Aufgaben unter tasks/. understand_task,
text_to_model/revise_model und review_assumptions/needs_work/select_best
sind aus TextToModel.py zu ihren Prompts gewandert, inhaltlich
unveraendert. Prompt-Kennung des Benchmarks bleibt 93744636095a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-17 12:11:51 +02:00
commit 7459690bf5
4 changed files with 315 additions and 279 deletions

49
checks/Pruef-Prompt.txt Normal file
View file

@ -0,0 +1,49 @@
Du bist ein kritischer Gutachter für mathematische Optimierungsmodelle.
Du erhältst einen Aufgabentext und eine daraus erzeugte Modellspezifikation (JSON). Deine Aufgabe ist es, die Zielfunktion und die offenen Annahmen (open_assumptions) streng gegen den Aufgabentext abzugleichen. Du erstellst selbst kein neues Modell.
GEMEINSAMER RAHMEN (gilt für alle vier Stufen dieser Pipeline: Verstehen, Modellieren, Prüfen, Lösen)
- Diese Pipeline beantwortet eine Textaufgabe mit einer nachprüfbaren Zahl UND einer Erklärung, die sie trägt. Empfänger ist ein Entscheider ohne OR-Ausbildung.
- Rangfolge bei Zielkonflikten, von oben nach unten: (1) Richtigkeit – lieber „unlösbar“ als eine erfundene Zahl. (2) Nachprüfbarkeit – melde alles, was nachgerechnet werden kann. (3) Verständlichkeit – eine richtige Zahl, die niemand einordnen kann, ist halb wertlos. (4) Sparsamkeit – kurz, aber nie auf Kosten von (1) bis (3).
- Arbeitsteilung: Deine Angaben werden anschließend OHNE LLM nachgerechnet – Zulässigkeit jeder Restriktion, Einheiten der Zielfunktion, Summen der Ergebnistabellen, Länge des Weges im Graphen, Streuung der Startpunkte, deklarierte Symbole, unerklärte Fachbegriffe. Beschönigen wird also gefunden. Melde stattdessen offen, was unsicher ist. Regeln, die mit [→ …] enden, nennen die Prüfung, die sie durchsetzt; die übrigen korrigiert niemand für dich.
- Keine Angabe raten, um ein Feld zu füllen: Was nicht im Aufgabentext steht, gehört als Annahme ausgewiesen, nicht stillschweigend erfunden.
Schritt 1 – Zielgröße (objective_check), immer zuerst:
1. Zitiere die Stelle im Aufgabentext, die sagt, was optimiert bzw. angegeben werden soll (task_evidence).
2. Benenne die gefragte Größe genau, mit Einheit und Maßstab (asked_quantity).
3. Benenne, was objective.latex_formula tatsächlich berechnet (modeled_quantity).
4. matches ist nur True, wenn beide Größen identisch sind: gleiche Größe, gleiche Richtung (min/max), gleicher Maßstab.
Eine andere, nur monoton verwandte Größe ist False – auch wenn sie dieselbe optimale Lösung hätte, denn der ausgegebene Zielwert wäre falsch. Beispiele:
- gefragt Standardabweichung/Volatilität, modelliert Varianz w^T Σ w → False (Korrektur: \min \sqrt{w^T Σ w} bzw. Norm-Formulierung)
- gefragt Varianz, modelliert Varianz → True (nicht in Standardabweichung „korrigieren“)
- gefragt Gewinn nach Abzug der Fixkosten, modelliert nur Deckungsbeitrag → False
- gefragt Kosten in Tsd. €, modelliert Kosten in € → False
5. Prüfe die Einheiten: Benenne in unit_explanation die Einheit jedes Terms der Zielfunktion. units_consistent ist nur True, wenn alle Terme dieselbe Einheit haben. Beispiele für False:
- Rendite und Kosten in Euro, Risiko als \(\sum w_i^2 \sigma_i^2\) über Anteile (dimensionslos) – das Risiko wäre um den Faktor Kapital zu klein und praktisch wirkungslos.
- 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).
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.
Schritt 2 – Vorgehen für jede Annahme:
1. Suche die zugehörige Stelle im Aufgabentext und zitiere sie wörtlich (task_evidence).
2. Prüfe, ob der Text an dieser Stelle wirklich unklar ist oder eine eindeutige Aussage trifft.
3. Prüfe die Annahme und die daraus abgeleiteten Restriktionen logisch gegen den Text:
- Richtung von Bedingungen: „Wenn A, dann B“ ist eine Implikation (A ⇒ B), keine Äquivalenz (A ⇔ B).
- „mindestens“ / „höchstens“ / „genau“ müssen als ≥ / ≤ / = abgebildet sein.
- Das Modell darf nichts verbieten, was der Text erlaubt (stricter_than_task), und nichts erlauben, was der Text verbietet (weaker_than_task).
- Eine Annahme darf dem Text nicht widersprechen (contradicts_task).
4. Prüfe die zweite Lesart im Feld alternative: Stützt der Aufgabentext sie besser als die gewählte Annahme, ist die Annahme falsch – vergib dann contradicts_task, stricter_than_task oder weaker_than_task und gib in correction die Lesart an, der das Modell folgen muss. Steht dort „keine zweite Lesart“, obwohl der Text die Stelle offen lässt, gehört das in die Begründung.
5. Vergib genau ein Urteil (verdict):
- justified: Text ist unklar, Annahme ist plausibel und textkonform.
- unnecessary: Text ist eindeutig, die Annahme ist überflüssig, aber nicht falsch.
- contradicts_task, stricter_than_task, weaker_than_task: wie oben beschrieben; gib dann in correction die korrigierte Annahme bzw. Formel (LaTeX) an.
Was du NICHT prüfst (das rechnet die Pipeline ohne LLM nach – verschwende dafür keine Zeichen):
- Einheit je Parameter, Summen der Ergebnistabellen, Zulässigkeit der Lösung, Länge des Weges im Graphen, Streuung der Startpunkte, deklarierte Symbole, unerklärte Fachbegriffe, ob ein im Text verlangtes Verfahren (z. B. Dijkstra) verwendet wurde.
- Ob Problemklasse und Stack zueinander passen: dafür gibt es eine feste Tabelle. Deine Aufgabe ist die Zielgröße und die Annahmen – dort sitzen die Fehler, die keine Tabelle findet.
Zusätzlich:
- Prüfe, ob das Modell unklare Stellen des Textes stillschweigend entschieden hat, ohne sie als Annahme auszuweisen. Führe diese in missing_assumptions auf – mit denselben Feldern, also auch mit der zweiten Lesart (alternative), die an dieser Stelle möglich wäre.
- Beurteile ausschließlich auf Basis des Aufgabentextes, nicht auf Basis von Allgemeinwissen über typische Problemstellungen.
- Sei präzise und knapp. Wenn alles korrekt ist, vergib justified bzw. unnecessary und lasse missing_assumptions leer.

0
checks/__init__.py Normal file
View file

503
checks/model_checks.py Normal file
View file

@ -0,0 +1,503 @@
"""Deterministische Plausibilitätsprüfungen einer Modellspezifikation (ohne LLM).
error -> löst in run_pipeline eine Korrekturrunde aus
warning -> wird nur berichtet (Heuristiken mit möglichen Fehlalarmen)
"""
import re
from typing import Iterable, List, Set
from core.model_schema import (
CheckIssue,
CheckSeverity,
OptimizationModelSpecification,
ProblemClass,
TargetStack,
VariableType,
)
# Zulässige Ziel-Stacks je Problemklasse (muss zu den Routing-Regeln im Prompt passen)
ALLOWED_STACKS = {
ProblemClass.LP: {TargetStack.SCIPY, TargetStack.HIGHS},
ProblemClass.MILP: {TargetStack.HIGHS},
ProblemClass.CP: {TargetStack.OR_TOOLS_CPSAT},
ProblemClass.QP: {TargetStack.CVXPY},
ProblemClass.SOCP: {TargetStack.CVXPY},
ProblemClass.MIQP: {TargetStack.SCIP},
ProblemClass.NLP: {TargetStack.IPOPT, TargetStack.SCIP},
# Kürzeste Wege: spezialisierter Graphalgorithmus, alternativ als Flussmodell (MILP)
ProblemClass.SHORTEST_PATH: {TargetStack.NETWORKX, TargetStack.HIGHS},
}
LINEAR_CLASSES = {ProblemClass.LP, ProblemClass.MILP, ProblemClass.CP, ProblemClass.SHORTEST_PATH}
# Quadrat, Transponierte (w^T \Sigma w) oder Norm
NONLINEAR_PATTERN = re.compile(r"\^\s*\{?\s*(2|T)\b|\\\||\\lVert|\|\|")
# Grenzen von Summen/Produkten/Integralen: \sum_{i=0}^{2} ist keine Potenz, sondern ein Laufbereich
LIMIT_PATTERN = re.compile(r"\\(sum|prod|int|iint|oint|bigcup|bigcap|max|min|argmax|argmin)"
r"(\s*_\s*(\{[^{}]*\}|\\?\w))?(\s*\^\s*(\{[^{}]*\}|\\?\w))?")
GREEK = {
"alpha", "beta", "gamma", "delta", "epsilon", "varepsilon", "zeta", "eta", "theta", "vartheta",
"iota", "kappa", "lambda", "mu", "nu", "xi", "pi", "rho", "sigma", "tau", "upsilon", "phi",
"varphi", "chi", "psi", "omega", "Gamma", "Delta", "Theta", "Lambda", "Xi", "Pi", "Sigma",
"Upsilon", "Phi", "Psi", "Omega",
}
# Wörter, die in Formeln vorkommen dürfen, ohne deklariert zu sein
IGNORED_TOKENS = {"sum", "min", "max", "abs", "log", "exp", "sqrt", "for", "all", "if", "then", "and", "or", "not"}
# Einheiten je Parameter (ParameterDefinition.unit) – nur zwei Klassen werden gegeneinander geprüft:
# Geldbeträge und dimensionslose Größen. Beobachtet: Risiko als \sum w_i^2 \sigma_i^2 (dimensionslos)
# neben Kosten in Euro – das Risiko war um den Faktor Kapital zu klein und damit wirkungslos.
CURRENCY_UNIT = re.compile(r"€|\bEUR\b|Euro|\$|\bUSD\b|Geldeinheit|Kosteneinheit|\bKE\b|\bGE\b", re.IGNORECASE)
RELATIVE_UNIT = re.compile(r"^\s*(-|–|1)?\s*$|%|Prozent|Anteil|Quote|Faktor|Verhältnis|dimensionslos|"
r"dimensionless|ohne Einheit|einheitenlos", re.IGNORECASE)
# Fachbegriffe, die ein Leser ohne OR-Ausbildung nicht mitbringt. Tauchen sie dort auf, wo er sie sieht
# (Restriktionsnamen, Tabellenspalten, Antwortsatz), müssen sie in executive_summary erklärt sein.
# Je Begriff mehrere Wortstämme, damit Beugungen ("Bogen"/"Bögen") als derselbe Begriff zählen.
JARGON = {
"Bogen": ("bogen", "bög"),
"Knoten": ("knoten",),
"Kante": ("kante",),
"Fluss": ("fluss", "flüss"),
"Quelle/Senke": ("senke",),
"Relaxation": ("relaxation", "relaxier"),
"Schlupf": ("schlupf", "slack"),
"Kardinalität": ("kardinal",),
"Epigraph": ("epigraph",),
"Schattenpreis": ("schattenpreis", "dualwert", "dualvariable"),
"Konvexität": ("konvex",),
"Markierung": ("marke", "markier"),
"Inzidenz": ("inzidenz",),
"Big-M": ("big-m", "big m"),
"Subtour": ("subtour",),
"Lagrange": ("lagrange",),
}
def unexplained_jargon(sichtbar: str, erklaerung: str) -> List[str]:
"""Fachbegriffe, die im sichtbaren Text vorkommen, in der Erklärung aber nicht."""
sichtbar, erklaerung = sichtbar.lower(), erklaerung.lower()
return sorted(name for name, staemme in JARGON.items()
if any(s in sichtbar for s in staemme) and not any(s in erklaerung for s in staemme))
def check_jargon(spec: OptimizationModelSpecification) -> List[CheckIssue]:
"""Fachbegriffe in Namen des Modells, die die Zusammenfassung nicht einführt (siehe Block ERKLÄREN)."""
sichtbar = " ".join([*(c.identifier for c in spec.constraints), *(v.name for v in spec.variables),
*spec.indices])
offen = unexplained_jargon(sichtbar, spec.executive_summary)
if not offen:
return []
return [CheckIssue(
severity=CheckSeverity.WARNING, code="UNEXPLAINED_JARGON",
message=f"Fachbegriffe im Modell, die executive_summary nicht erklärt: {', '.join(offen)}. "
f"Der Leser trifft sie in Restriktions- und Variablennamen, ohne zu wissen, was sie bedeuten.",
)]
# --- Verstehensstufe: Teilfragen und Zahlen des Aufgabentexts (ohne LLM) ---
# Zahl im Fließtext: 1.234,56 / 0,05 / 12 / 3.8 – ohne Zahlen, die Teil eines Wortes sind (A02, x1)
NUMBER_PATTERN = re.compile(r"(?<![A-Za-z0-9_])\d+(?:[.,]\d+)*(?![A-Za-z0-9_])")
# Teilfragen-Marker am Zeilenanfang: "a)", "- b)", "**Teil 2:**", "1." – bewusst eng, um
# Aufzählungen im Fließtext nicht als Frage zu zählen.
QUESTION_PATTERN = re.compile(r"^[\s\-*>]*\**\s*(?:\(?([a-h])\)|(?:Teil|Aufgabe|Frage)\s+(\d+)|([1-9])\.)\s*[:.)]?\s+\S",
re.MULTILINE)
def normalize_number(roh: str) -> str:
"""'1.234,56' -> '1234.56', '3.8' -> '3.8', '1.234' -> '1234'.
Der Punkt ist mehrdeutig: Tausendertrenner (deutsch) oder Dezimalpunkt (englisch, Nummerierung).
Entschieden wird am Komma (dann ist der Punkt Tausendertrenner) und sonst an der Gruppenlänge:
genau drei Ziffern hinter dem letzten Punkt heißt Tausender, alles andere Dezimalpunkt.
"""
if "," in roh:
roh = roh.replace(".", "").replace(",", ".")
elif "." in roh:
teile = roh.split(".")
if all(len(t) == 3 for t in teile[1:]) and len(teile[0]) <= 3:
roh = "".join(teile) # 1.234 bzw. 12.345.678
wert = float(roh)
return str(int(wert)) if wert.is_integer() else f"{wert:g}" # große Zahlen exakt, nicht 1.23457e+06
def numbers_in(text: str) -> Set[str]:
"""Zahlen eines Textes in einheitlicher Schreibweise."""
gefunden = set()
for roh in NUMBER_PATTERN.findall(text or ""):
try:
gefunden.add(normalize_number(roh))
except ValueError:
continue
return gefunden
def question_ids_in(text: str) -> Set[str]:
"""Kennungen der Teilfragen, die im Aufgabentext erkennbar sind."""
return {(a or b or c).lower() for a, b, c in QUESTION_PATTERN.findall(text or "")}
def _covers(genannt: str, erkannt: str) -> bool:
"""'2a', '2.1', 'a1' decken die Teilfrage '2' bzw. 'a' ab – '21' deckt '2' nicht ab.
Beobachtet an „Portfolio Nr. 2": Teil 2 wurde in 2a und 2b zerlegt, gemeldet wurde „es fehlt: 2".
"""
if genannt == erkannt:
return True
if not genannt.startswith(erkannt):
return False
naechstes = genannt[len(erkannt)]
return naechstes.isdigit() != erkannt[-1].isdigit() or not naechstes.isalnum()
def check_understanding(understanding, task: str) -> List[CheckIssue]:
"""Prüft das Verständnis gegen den Aufgabentext: sind alle Zahlen und alle Teilfragen erfasst?
Zahlen gelten als erfasst, wenn sie irgendwo im Verständnis auftauchen (Wert, Zitat, Bedeutung)
oder ausdrücklich als irrelevant erklärt sind – ein Zitat der Tabellenzeile deckt so eine ganze
Datenzeile ab, ohne dass jede Zahl einzeln aufgeführt werden muss.
"""
issues = []
erfasst = numbers_in(understanding.model_dump_json())
fehlend = sorted(numbers_in(task) - erfasst, key=float)
if fehlend:
issues.append(CheckIssue(
severity=CheckSeverity.WARNING, code="UNCAPTURED_NUMBERS",
message=f"Zahlen aus dem Aufgabentext, die weder als Fakt noch als irrelevant erfasst sind: "
f"{', '.join(fehlend[:12])}{' …' if len(fehlend) > 12 else ''}. "
f"Sie fehlen damit in der Spezifikation und können nicht geprüft werden.",
))
erkannt = question_ids_in(task)
genannt = {q.id.lower().removeprefix("teil ").removeprefix("aufgabe ").strip(" ().") for q in understanding.sub_questions}
offen = sorted(e for e in erkannt if not any(_covers(g, e) for g in genannt))
if offen:
issues.append(CheckIssue(
severity=CheckSeverity.WARNING, code="MISSING_SUB_QUESTIONS",
message=f"Der Aufgabentext enthält die Teilfragen {', '.join(sorted(erkannt))}, "
f"das Verständnis nennt {', '.join(sorted(genannt)) or 'keine'} – es fehlen: {', '.join(offen)}.",
))
if not understanding.sub_questions:
issues.append(CheckIssue(
severity=CheckSeverity.WARNING, code="NO_SUB_QUESTIONS",
message="Keine einzige Teilfrage erfasst – auch eine Aufgabe mit nur einer Frage braucht einen Eintrag.",
))
return issues
def _strip_scripts(text: str) -> str:
"""Entfernt Indizes/Exponenten: x_{i,j} -> x, C^{prod}_{p} -> C, x_1 -> x."""
text = text.replace(r"\_", "_")
# Schriftbefehle samt Inhalt: \mathbb{Z} ist die Menge der ganzen Zahlen, kein Symbol namens Z
text = re.sub(r"\\(text|mathrm|operatorname|mbox|mathbb|mathcal|mathbf|mathsf|boldsymbol)\s*\{[^{}]*\}", " ", text)
previous = None
while previous != text: # verschachtelte Klammern von innen nach außen
previous = text
text = re.sub(r"[_^]\s*\{[^{}]*\}", "", text)
# einzelnes Zeichen als Index, aber nicht innerhalb von Namen wie min_staff
text = re.sub(r"[_^]\s*\\?[A-Za-z0-9](?![A-Za-z0-9])", "", text)
return text
def _symbols(text: str) -> Set[str]:
"""Basis-Symbole einer LaTeX-Formel (Greek als Symbol, andere Befehle ignoriert)."""
text = _strip_scripts(text)
tokens = set()
for match in re.finditer(r"\\([A-Za-z]+)|([A-Za-z][A-Za-z0-9_]*)", text):
command, name = match.groups()
if command:
if command in GREEK:
tokens.add("\\" + command)
else:
tokens.add(name.rstrip("_"))
return tokens
def _declared_symbols(spec: OptimizationModelSpecification) -> Set[str]:
declared: Set[str] = set()
for text in _texts_declaring(spec):
declared |= _symbols(text)
return declared
def _texts_declaring(spec: OptimizationModelSpecification) -> Iterable[str]:
yield from (v.name for v in spec.variables)
yield from (p.symbol for p in spec.parameters)
for index in spec.indices:
# Formel-Begrenzer entfernen ("Tage: \(d \in D\)"), dann erläuternde Klammer am Ende
# ("i in I (Mitarbeiter)"); früher wurde am ersten "(" geschnitten – das traf auch "\("
text = re.sub(r"\\[()\[\]]|\$", " ", index)
yield re.sub(r"\s\([^()]*\)\s*$", "", text)
def _objective_without_label(formula: str) -> str:
"""'Z = 12 x': der Name links vom Gleichheitszeichen ist eine Beschriftung, kein Symbol des Modells.
Nur für die Zielfunktion – bei Restriktionen ist die linke Seite Teil des Modells.
"""
# Nur ein Gleichheitszeichen auf oberster Klammerebene zählt: \sum_{i=0}^{2} enthält ebenfalls eines,
# und daran zerschnitten blieb von der Formel „0}^{2}\sum…“ übrig (führte zu NONLINEAR_TERM).
tiefe = 0
for pos, zeichen in enumerate(formula):
if zeichen in "{([":
tiefe += 1
elif zeichen in "})]":
tiefe -= 1
elif zeichen == "=" and tiefe == 0:
left, right = formula[:pos], formula[pos + 1:]
return right if len(_symbols(left) - IGNORED_TOKENS) <= 1 else formula
return formula
def _formulas(spec: OptimizationModelSpecification) -> Iterable[tuple]:
yield "Zielfunktion", _objective_without_label(spec.objective.latex_formula)
for c in spec.constraints:
yield c.identifier, c.latex_formula
def check_routing(spec: OptimizationModelSpecification) -> List[CheckIssue]:
issues = []
var_types = {v.var_type for v in spec.variables}
has_discrete = bool(var_types & {VariableType.INTEGER, VariableType.BINARY})
has_continuous = VariableType.CONTINUOUS in var_types
allowed = ALLOWED_STACKS[spec.problem_class]
if spec.target_stack not in allowed:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="STACK_MISMATCH",
message=f"Problemklasse '{spec.problem_class.value}' passt nicht zu Stack '{spec.target_stack.value}' "
f"(erlaubt: {', '.join(s.value for s in allowed)}).",
))
if has_discrete and spec.problem_class == ProblemClass.LP:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="LP_WITH_DISCRETE_VARS",
message="Problemklasse LP, aber ganzzahlige/binäre Variablen vorhanden (→ MILP oder CP).",
))
if has_discrete and spec.target_stack == TargetStack.CVXPY:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="CVXPY_WITH_DISCRETE_VARS",
message="CVXPY ist hier nur für kontinuierliche QP/SOCP vorgesehen; quadratisch + ganzzahlig/binär "
"ist MIQP (Stack SCIP).",
))
if spec.problem_class == ProblemClass.MIQP and not has_discrete:
issues.append(CheckIssue(
severity=CheckSeverity.WARNING, code="MIQP_WITHOUT_DISCRETE_VARS",
message="Problemklasse MIQP, aber keine ganzzahligen/binären Variablen (→ eigentlich QP/SOCP mit CVXPY).",
))
if has_discrete and spec.target_stack == TargetStack.IPOPT:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="IPOPT_WITH_DISCRETE_VARS",
message="Ipopt löst nur stetige Probleme; mit ganzzahligen/binären Variablen ist SCIP zuständig.",
))
if has_discrete and spec.target_stack == TargetStack.SCIPY:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="SCIPY_WITH_DISCRETE_VARS",
message="scipy.optimize.linprog unterstützt keine ganzzahligen/binären Variablen.",
))
if has_continuous and spec.target_stack == TargetStack.OR_TOOLS_CPSAT:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="CPSAT_WITH_CONTINUOUS_VARS",
message="CP-SAT unterstützt nur ganzzahlige/binäre Variablen, es gibt aber kontinuierliche.",
))
if spec.problem_class == ProblemClass.MILP and not has_discrete:
issues.append(CheckIssue(
severity=CheckSeverity.WARNING, code="MILP_WITHOUT_DISCRETE_VARS",
message="Problemklasse MILP, aber keine ganzzahligen/binären Variablen (→ eigentlich LP).",
))
return issues
def check_nonlinearity(spec: OptimizationModelSpecification) -> List[CheckIssue]:
if spec.problem_class not in LINEAR_CLASSES:
return []
return [
CheckIssue(
severity=CheckSeverity.WARNING, code="NONLINEAR_TERM",
message=f"'{name}' enthält einen möglicherweise nichtlinearen Term, "
f"Problemklasse ist aber '{spec.problem_class.value}': {formula}",
)
for name, formula in _formulas(spec)
if NONLINEAR_PATTERN.search(LIMIT_PATTERN.sub(" ", formula))
]
def _is_implicit_product(symbol: str, declared: Set[str]) -> bool:
"""'cx' in \\sin(cx) ist das Produkt c·x, kein eigener Name – wenn c und x deklariert sind."""
return len(symbol) > 1 and all(zeichen in declared for zeichen in symbol)
def check_undeclared_symbols(spec: OptimizationModelSpecification) -> List[CheckIssue]:
declared = _declared_symbols(spec)
undeclared: dict = {}
for name, formula in _formulas(spec):
for symbol in _symbols(formula) - declared - IGNORED_TOKENS:
if _is_implicit_product(symbol, declared):
continue
undeclared.setdefault(symbol, []).append(name)
return [
CheckIssue(
severity=CheckSeverity.WARNING, code="UNDECLARED_SYMBOL",
message=f"Symbol '{symbol}' wird in {', '.join(places)} verwendet, ist aber weder Variable, Parameter noch Index.",
)
for symbol, places in sorted(undeclared.items())
]
def _unit_class(unit: str) -> str:
"""'currency', 'relative' oder 'unbekannt'; nur die ersten beiden werden gegeneinander geprüft."""
text = (unit or "").strip()
if CURRENCY_UNIT.search(text):
return "currency"
if RELATIVE_UNIT.search(text):
return "relative"
return "unbekannt"
def _additive_terms(formula: str) -> List[str]:
"""Summanden einer Formel: Trennung an + und - auf oberster Klammerebene.
Indizes stehen in Klammern (\\sum_{i \\in I}) und werden dadurch nicht zerschnitten.
"""
body = re.sub(r"\\operatorname\*?\s*\{[^{}]*\}|\\(?:min|max|left|right|quad|qquad)\b|\\[,;!]", " ", formula)
if "=" in body: # "\min z = \sum ..." -> nur die rechte Seite ist die Zielfunktion
body = body.rsplit("=", 1)[1]
terms, current, depth = [], "", 0
for char in body:
if char in "{([":
depth += 1
elif char in "})]":
depth -= 1
if depth == 0 and char in "+-" and current.strip():
terms.append(current)
current = ""
continue
current += char
return [t for t in (*terms, current) if t.strip()]
def _term_unit_class(term: str, parameter_units: dict) -> str:
"""Einheit eines Summanden: ein Geldbetrag mal dimensionslosem Faktor bleibt ein Geldbetrag."""
classes = {c for symbol in _symbols(term) for c in parameter_units.get(symbol, ())}
if "currency" in classes:
return "currency"
if classes == {"relative"}:
return "relative"
return "unbekannt"
def check_objective_units(spec: OptimizationModelSpecification) -> List[CheckIssue]:
"""Mischt die Zielfunktion Geldbeträge mit dimensionslosen Größen? (aus ParameterDefinition.unit)"""
parameter_units: dict = {}
for parameter in spec.parameters:
for symbol in _symbols(parameter.symbol):
parameter_units.setdefault(symbol, set()).add(_unit_class(parameter.unit))
by_class: dict = {}
for term in _additive_terms(spec.objective.latex_formula):
unit_class = _term_unit_class(term, parameter_units)
if unit_class != "unbekannt":
by_class.setdefault(unit_class, []).append(term.strip())
if "currency" not in by_class or "relative" not in by_class:
return []
return [CheckIssue(
severity=CheckSeverity.ERROR, code="MIXED_OBJECTIVE_UNITS",
message=f"Die Zielfunktion mischt Geldbeträge ({'; '.join(by_class['currency'])}) mit dimensionslosen "
f"Termen ({'; '.join(by_class['relative'])}). Der dimensionslose Term ist gegenüber den "
f"Geldbeträgen praktisch wirkungslos – mit dem Kapital bzw. Mengengerüst multiplizieren.",
)]
# Nennt der Aufgabentext ein Verfahren beim Namen, ist es Teil der Aufgabenstellung – ein gleichwertiges
# Modell erfüllt sie dann nicht (beobachtet: „Lösen Sie mit dem Dijkstra-Algorithmus“ wurde als
# MILP-Flussmodell gelöst, mathematisch richtig, als Aufgabe aber verfehlt).
REQUESTED_METHOD = {
"dijkstra": {TargetStack.NETWORKX},
"bellman-ford": {TargetStack.NETWORKX, TargetStack.HIGHS},
"kürzeste-wege-problem": {TargetStack.NETWORKX, TargetStack.HIGHS},
"simplex": {TargetStack.SCIPY, TargetStack.HIGHS},
"branch-and-bound": {TargetStack.HIGHS, TargetStack.SCIP},
"branch-and-cut": {TargetStack.HIGHS, TargetStack.SCIP},
"constraint programming": {TargetStack.OR_TOOLS_CPSAT},
}
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:
issues.append(CheckIssue(
severity=CheckSeverity.ERROR, code="REQUESTED_METHOD_IGNORED",
message=f"Die Aufgabe verlangt ausdrücklich '{name}', das Modell nutzt aber "
f"'{spec.target_stack.value}' (passend wäre: {', '.join(sorted(s.value for s in stacks))}). "
f"Ein gleichwertiges Modell erfüllt die Aufgabenstellung nicht.",
))
return issues
def check_data_transfer(spec: OptimizationModelSpecification, understanding) -> List[CheckIssue]:
"""Sind die erfassten Zahlen auch im Modell angekommen?
Beobachtet: Die Verstehensstufe erfasste die vollständige Kostenmatrix, die Spezifikation führte
`extracted_values: None` – die Werte betraten die Pipeline erst im Solver-Code, also hinter allen
Prüfungen. Ohne Werte in der Spezifikation kann nichts Numerisches geprüft werden.
"""
if understanding is None or not understanding.facts:
return []
# Nur die erklärten Werte, nicht die Zitate: eine zitierte Tabellenzeile enthält auch
# Zeilenbeschriftungen, die keine Parameterwerte sind.
erfasst = numbers_in(" ".join(f.value for f in understanding.facts))
uebernommen = numbers_in(" ".join(p.extracted_values or "" for p in spec.parameters))
if not erfasst or (erfasst & uebernommen):
return [] # bewusst tolerant: gemeldet wird nur die Spezifikation ganz ohne Zahlen
return [CheckIssue(
severity=CheckSeverity.WARNING, code="UNUSED_FACTS",
message=f"Die Verstehensstufe hat {len(erfasst)} Zahlenwerte erfasst "
f"({', '.join(sorted(erfasst, key=float)[:8])}), in extracted_values steht keiner davon. "
f"Ohne Werte ist die Spezifikation nicht nachrechenbar – die Zahlen erreichen die Pipeline "
f"sonst erst im Solver-Code, hinter allen Prüfungen.",
)]
def check_missing_data(spec: OptimizationModelSpecification) -> List[CheckIssue]:
missing = [p.symbol for p in spec.parameters if not (p.extracted_values or "").strip()]
if not missing:
return []
return [CheckIssue(
severity=CheckSeverity.WARNING, code="MISSING_DATA",
message=f"Keine Werte im Aufgabentext für: {', '.join(missing)}. Lösung nur mit angenommenen Daten möglich.",
)]
def check_structure(spec: OptimizationModelSpecification) -> List[CheckIssue]:
issues = []
if not spec.variables:
issues.append(CheckIssue(severity=CheckSeverity.ERROR, code="NO_VARIABLES", message="Keine Entscheidungsvariablen definiert."))
if not spec.constraints:
issues.append(CheckIssue(severity=CheckSeverity.ERROR, code="NO_CONSTRAINTS", message="Keine Restriktionen definiert."))
return issues
def check_spec(spec: OptimizationModelSpecification, task: str = "", understanding=None) -> List[CheckIssue]:
"""Alle deterministischen Prüfungen; `task` und `understanding` nur für Prüfungen, die sie brauchen."""
return (
check_structure(spec)
+ check_routing(spec)
+ check_nonlinearity(spec)
+ check_undeclared_symbols(spec)
+ check_objective_units(spec)
+ check_jargon(spec)
+ check_requested_method(spec, task)
+ check_data_transfer(spec, understanding)
+ check_missing_data(spec)
)
def has_errors(issues: List[CheckIssue]) -> bool:
return any(i.severity == CheckSeverity.ERROR for i in issues)

50
checks/review.py Normal file
View file

@ -0,0 +1,50 @@
"""Stufe 4 der Pipeline: LLM-Prüfung von Zielgröße und Annahmen; Entscheidung über Korrekturrunden."""
from pathlib import Path
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
REVIEW_PROMPT_FILE = Path(__file__).parent / "Pruef-Prompt.txt"
def review_assumptions(session: LLMSession, task: str, spec: OptimizationModelSpecification) -> AssumptionReview:
return session.create(
AssumptionReview,
[
{"role": "system", "content": REVIEW_PROMPT_FILE.read_text(encoding="utf-8")},
{
"role": "user",
"content": f"AUFGABENTEXT:\n{task}\n\nMODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}",
},
],
step="Prüfung",
)
# Warnungen, die eine Korrekturrunde wert sind: keine Fehler (ein richtiges Ergebnis bleibt zuverlässig),
# aber in einer Runde billig zu beheben. Unerklärte Fachbegriffe kosten den Leser sonst das Verständnis.
REVISION_WORTHY_WARNINGS = {"UNEXPLAINED_JARGON", "UNUSED_FACTS"}
def needs_work(review: AssumptionReview, issues: List[CheckIssue]) -> bool:
"""Rechtfertigt der Stand eine Korrekturrunde?"""
return (review.needs_revision or has_errors(issues)
or any(i.code in REVISION_WORTHY_WARNINGS for i in issues))
def select_best(candidates: List[tuple]) -> int:
"""Index der besten (spec, issues, review)-Runde.
Rangfolge: passende Zielgröße vor unpassender (sonst womöglich Fremdproblem), keine Check-Errors vor Errors,
Prüfung ohne Beanstandung vor Beanstandung, weniger Errors;
bei Gleichstand gewinnt die spätere Runde. Verhindert, dass eine Korrekturrunde, die das Modell
verschlechtert (z. B. halluziniertes Fremdproblem), die bessere frühere Fassung ersetzt.
"""
def score(index):
_, issues, review = candidates[index]
errors = sum(1 for i in issues if i.severity == CheckSeverity.ERROR)
return (not review.objective_check.matches, errors > 0, review.needs_revision, errors, -index)
return min(range(len(candidates)), key=score)