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
42 changed files with 315 additions and 279 deletions

View file

@ -77,7 +77,7 @@ echo "Ein Betrieb ..." | .venv/bin/python TextToModel.py --report ausgabe/modell
.venv/bin/python TextToModel.py aufgabe.txt --no-solve --model deepseek/deepseek-v4-pro-0813
# Report nachträglich aus gespeichertem Ergebnis erzeugen (keine LLM-Kosten)
.venv/bin/python report.py ausgabe/ergebnis.json ausgabe/modell.md
.venv/bin/python -m reporting.report ausgabe/ergebnis.json ausgabe/modell.md
# exportiertes Solver-Programm erneut ausführen
.venv/bin/python ausgabe/solve.py
@ -157,36 +157,36 @@ Trotzdem gilt: **Ergebnisse vor Verwendung prüfen** – der Report zeigt dafür
`benchmark/cases/` enthält 19 Aufgaben mit **unabhängig verifiziertem Optimum** (Brute Force bzw. zweiter Solver) – von kleinen LPs bis zu Aufgaben mit eingebauten Fallen (Rüstkosten/Mindestlos, Wenn-dann-Regel als Implikation, Volatilität statt Varianz, nichtkonvexes NLP mit globalem Optimum, beweisbar unlösbare Mischung, Losgrößen als kürzester Weg). Gemessen wird der **Zielwert** und, strenger, ob die **Aufgabe erfüllt** ist: jede Teilfrage beantwortet, jede Zahl der Antwort belegt. Dateien im Fallverzeichnis außer `task.txt`/`expected.json` werden als Datenanhang übergeben.
```bash
.venv/bin/python benchmark.py --models deepseek/deepseek-v4.1-flash deepseek/deepseek-v4-flash-0731 --runs 2 --jobs 3
.venv/bin/python benchmark.py --cases hard_schichtplan_woche --runs 3
.venv/bin/python -m benchmark.benchmark --models deepseek/deepseek-v4.1-flash deepseek/deepseek-v4-flash-0731 --runs 2 --jobs 3
.venv/bin/python -m benchmark.benchmark --cases hard_schichtplan_woche --runs 3
```
Letzter Stand (September 2026, DeepSeek V4.1-Flash über OpenRouter, je 2 Läufe): **38 von 38 Läufen** über alle 19 Fälle mit richtigem Zielwert und erfüllter Aufgabe, etwa 0,05–0,15 $ und 3–10 Minuten je Lauf. Die Fallmenge ist damit gesättigt; die Grenzen der Pipeline zeigen sich eher an schlecht gestellten Aufgaben (fehlende Daten, mehrdeutige Angaben) – Beispiele dafür liegen in `aufgaben/`. Hinweis: Drei Fälle wurden nach dieser Messung sprachlich neu gefasst (gleiche Zahlen und Optima).
Letzter Stand (September 2026, DeepSeek V4.1-Flash über OpenRouter, je 2 Läufe): **38 von 38 Läufen** über alle 19 Fälle mit richtigem Zielwert und erfüllter Aufgabe, etwa 0,05–0,15 $ und 3–10 Minuten je Lauf. Die Fallmenge ist damit gesättigt; die Grenzen der Pipeline zeigen sich eher an schlecht gestellten Aufgaben (fehlende Daten, mehrdeutige Angaben) – Beispiele dafür liegen in `tasks/`. Hinweis: Drei Fälle wurden nach dieser Messung sprachlich neu gefasst (gleiche Zahlen und Optima).
## Tests
```bash
.venv/bin/python -m pytest -q # offline, ohne API-Kosten
.venv/bin/python -m pytest -q test_solver_stage.py -k cvxpy # einzelner Test
.venv/bin/python -m pytest -q tests/test_solver_stage.py -k cvxpy # einzelner Test
```
## Projektstruktur
| Datei | Inhalt |
Im Wurzelverzeichnis liegt nur das Hauptprogramm; jede Pipeline-Stufe liegt mit ihrem Code und ihrem Prompt in einem eigenen Ordner.
| Ordner / Datei | Inhalt |
|---|---|
| `TextToModel.py` | CLI und Pipeline (`run_pipeline`) |
| `model_schema.py` | Pydantic-Schemas aller Zwischenergebnisse |
| `model_checks.py` | deterministische Konsistenz-Checks |
| `solver_stage.py` | Solver-Code erzeugen, prüfen, ausführen |
| `llm.py` | LLM-Zugriff (OpenRouter/Gemini), Wiederholungen, Kostenerfassung |
| `report.py` | Report als Markdown/HTML (mit Kopier-Symbol), auch als eigenständiges CLI |
| `script_export.py` | dokumentiertes, eigenständiges Solver-Programm (Kopf mit Aufgabe, Modell, Algorithmus) |
| `data_profile.py` | Profil beigelegter CSV-/Excel-Dateien, ohne LLM |
| `charts.py` | Diagramme und Graphenzeichnung als Inline-SVG |
| `benchmark.py`, `benchmark/cases/` | Benchmark und Referenzaufgaben |
| `aufgaben/` | weitere Aufgaben zum Ausprobieren (u. a. mit fehlenden Daten) |
| `*-Prompt.txt` | System-Prompts für Verstehen, Extraktion, Prüfung und Solver-Code |
| `test_*.py` | Offline-Tests |
| `TextToModel.py` | CLI und Ablauf der Pipeline (`run_pipeline`, Neustart, Gegenprobe, Fortsetzen, Neurechnen) |
| `core/` | `model_schema.py` (Pydantic-Schemas aller Zwischenergebnisse), `llm.py` (LLM-Zugriff über OpenRouter/Gemini, Wiederholungen, Kostenerfassung) |
| `understanding/` | Stufe Verstehen: Teilfragen und Zahlen des Aufgabentexts erfassen (`understand.py`, `Verstehen-Prompt.txt`) |
| `modeling/` | Stufen Extraktion und Korrektur: Modellspezifikation erzeugen und überarbeiten (`extraction.py`, `Routing-und-Extraktions-Prompt.txt`) |
| `checks/` | Prüfungen: deterministische Konsistenz-Checks (`model_checks.py`) und LLM-Prüfung von Zielgröße und Annahmen (`review.py`, `Pruef-Prompt.txt`) |
| `solving/` | Solver-Code erzeugen, prüfen, in der Sandbox ausführen (`solver_stage.py`, `Solver-Prompt.txt`); eigenständiges Solver-Programm (`script_export.py`) |
| `reporting/` | Report als Markdown/HTML, auch als CLI (`report.py`); Diagramme und Graphenzeichnung als Inline-SVG (`charts.py`) |
| `data/` | Profil beigelegter CSV-/Excel-Dateien, ohne LLM (`data_profile.py`) |
| `benchmark/` | Benchmark (`benchmark.py`) und Referenzaufgaben (`cases/`) |
| `tasks/` | weitere Aufgaben zum Ausprobieren (u. a. mit fehlenden Daten) |
| `tests/` | Offline-Tests (`pytest`, Konfiguration in `pyproject.toml`) |
## Sicherheitshinweis

View file

@ -5,10 +5,10 @@ import sys
from pathlib import Path
from typing import List
from llm import DEFAULT_MODEL, LLMSession
import data_profile
from model_checks import check_spec, check_understanding, has_errors
from model_schema import (
from checks.model_checks import check_spec, check_understanding, has_errors
from checks.review import needs_work, review_assumptions, select_best
from core.llm import DEFAULT_MODEL, LLMSession
from core.model_schema import (
AssumptionReview,
SolverStatus,
CheckIssue,
@ -17,14 +17,13 @@ from model_schema import (
PipelineResult,
TaskUnderstanding,
)
from report import write_report
from script_export import build_solver_script
from solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve
from data import data_profile
from modeling.extraction import revise_model, text_to_model
from reporting.report import write_report
from solving.script_export import build_solver_script
from solving.solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve
from understanding.understand import understand_task
PROMPT_DIR = Path(__file__).parent
ROUTING_PROMPT_FILE = PROMPT_DIR / "Routing-und-Extraktions-Prompt.txt"
REVIEW_PROMPT_FILE = PROMPT_DIR / "Pruef-Prompt.txt"
UNDERSTAND_PROMPT_FILE = PROMPT_DIR / "Verstehen-Prompt.txt"
MAX_REVISIONS = int(os.environ.get("MAX_REVISIONS", "2"))
SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2"))
MAX_RESTARTS = int(os.environ.get("MAX_RESTARTS", "1"))
@ -48,119 +47,6 @@ def log(msg: str) -> None:
print(msg, file=sys.stderr)
def understand_task(session: LLMSession, task: str, issues: List[CheckIssue] = (),
data_prompt: str = "") -> TaskUnderstanding:
"""Stufe 0: erfassen, was im Text steht – Teilfragen, Zahlen, Machbarkeit. Noch kein Modell."""
nachtrag = ""
if issues:
nachtrag = ("\n\nDEIN VORIGER VERSUCH WAR UNVOLLSTÄNDIG:\n"
+ "\n".join(f"- [{i.code}] {i.message}" for i in issues)
+ "\nErfasse die fehlenden Punkte, behalte den Rest bei.")
return session.create(
TaskUnderstanding,
[
{"role": "system", "content": UNDERSTAND_PROMPT_FILE.read_text(encoding="utf-8")},
{"role": "user", "content": f"AUFGABENTEXT:\n{task}\n\n{data_prompt}{nachtrag}".strip()},
],
step="Verstehen",
)
def text_to_model(session: LLMSession, task: str,
understanding: TaskUnderstanding | None = None,
data_prompt: str = "") -> OptimizationModelSpecification:
inhalt = task if not data_prompt else f"{task}\n\n{data_prompt}"
if understanding is not None:
# Die Verstehensstufe hat Teilfragen und Zahlen schon erfasst – die Extraktion muss den Text
# nicht noch einmal danach absuchen, sondern baut darauf auf.
inhalt += ("\n\nBEREITS ERFASSTES VERSTÄNDNIS (Teilfragen und Zahlen; übernimm die Werte aus facts "
"in extracted_values, statt sie neu zu suchen):\n" + understanding.model_dump_json(indent=2))
return session.create(
OptimizationModelSpecification,
[
{"role": "system", "content": ROUTING_PROMPT_FILE.read_text(encoding="utf-8")},
{"role": "user", "content": inhalt},
],
step="Extraktion",
)
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",
)
def revise_model(
session: LLMSession, task: str, spec: OptimizationModelSpecification, review: AssumptionReview,
issues: List[CheckIssue] = (),
) -> OptimizationModelSpecification:
errors = [i for i in issues if i.severity == CheckSeverity.ERROR]
check_text = "\n".join(f"- [{i.code}] {i.message}" for i in errors) or "keine"
# Warnungen lösen für sich keine Korrekturrunde aus; findet aber ohnehin eine statt,
# sollen sie mit behoben werden – sonst erreicht z. B. unerklärter Fachjargon das LLM nie.
hinweise = [i for i in issues if i.severity == CheckSeverity.WARNING]
hinweis_text = "\n".join(f"- [{i.code}] {i.message}" for i in hinweise) or "keine"
return session.create(
OptimizationModelSpecification,
[
{"role": "system", "content": ROUTING_PROMPT_FILE.read_text(encoding="utf-8")},
{
"role": "user",
"content": (
f"AUFGABENTEXT:\n{task}\n\n"
f"BISHERIGE MODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}\n\n"
f"PRÜFBERICHT:\n{review.model_dump_json(indent=2)}\n\n"
f"FEHLER DER AUTOMATISCHEN KONSISTENZPRÜFUNG:\n{check_text}\n\n"
f"HINWEISE DER AUTOMATISCHEN PRÜFUNG (kein Fehler, aber zu verbessern):\n{hinweis_text}\n\n"
"Überarbeite die Modellspezifikation: Setze alle Korrekturen aus dem Prüfbericht um, "
"behebe alle Fehler der Konsistenzprüfung, passe die betroffenen Restriktionen, Variablen "
"und Parameter an und weise fehlende Annahmen in open_assumptions aus. "
"Arbeite auch die Hinweise ab, soweit das Modell dadurch nicht schlechter wird – "
"unerklärte Fachbegriffe gehören in executive_summary, nicht weggelassen. "
"Behalte korrekte Teile unverändert bei."
),
},
],
step="Korrektur",
)
# 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)
def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple:
"""Verstehensstufe samt Prüfung; ein Nachfassen, wenn Zahlen oder Teilfragen fehlen."""
if UNDERSTAND_RETRIES < 0:

0
benchmark/__init__.py Normal file
View file

227
benchmark/benchmark.py Normal file
View file

@ -0,0 +1,227 @@
"""Benchmark: Referenzaufgaben mit bekanntem Optimum durch die Pipeline schicken und Modelle vergleichen.
Beispiel:
.venv/bin/python benchmark.py --models deepseek/deepseek-v4-flash-0731 deepseek/deepseek-v4-pro-0813 --runs 2
"""
import argparse
import hashlib
import json
import os
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache
from datetime import datetime
from pathlib import Path
from core.llm import DEFAULT_MODEL, is_fatal_error
from TextToModel import run_pipeline
BENCHMARK_DIR = Path(__file__).parent
ROOT = BENCHMARK_DIR.parent
CASES_DIR = BENCHMARK_DIR / "cases"
RESULTS_DIR = BENCHMARK_DIR / "results"
# Nach dem ersten fatalen API-Fehler (Kontingent/Guthaben/Key) keine weiteren Läufe starten
quota_exhausted = threading.Event()
# Dateien, die das LLM-Verhalten steuern: die drei System-Prompts und die Feldbeschreibungen
# (Field(description=...) geht als JSON-Schema mit und wirkt wie eine Prompt-Anweisung).
PROMPT_FILES = ("modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt", "solving/Solver-Prompt.txt",
"core/model_schema.py")
# Umgebungsvariablen, die das Verhalten ändern – ohne sie ließe sich ein A/B-Lauf nicht zuordnen
CONFIG_VARS = ("LLM_MODEL", "LLM_MODEL_EXTRACTION", "LLM_MODEL_REVIEW", "LLM_MODEL_REVISION", "LLM_MODEL_SOLVER",
"UNDERSTAND_RETRIES", "MAX_REVISIONS", "SOLVER_RETRIES", "MAX_RESTARTS", "INFEASIBLE_RECHECK",
"LLM_REASONING_EFFORT", "SOLVER_SANDBOX")
def prompt_digest(paths=None) -> str:
"""Kurzer Hash über alle Dateien, die das LLM-Verhalten steuern."""
digest = hashlib.sha256()
for path in paths if paths is not None else [ROOT / n for n in PROMPT_FILES]:
digest.update(Path(path).read_bytes())
return digest.hexdigest()[:12]
@lru_cache(maxsize=1)
def prompt_state() -> dict:
"""Womit wurde gemessen? Ohne diese Angaben lassen sich zwei Ergebnisdateien nicht vergleichen.
Beobachtet: die Messlatte 19/22 ließ sich keinem Prompt-Stand zuordnen, weil nur Fall, Modell und
Laufnummer gespeichert wurden – jede Prompt-Änderung entwertete die Vergleichbarkeit still.
"""
state = {"prompts": prompt_digest()}
gesetzt = {name: os.environ[name] for name in CONFIG_VARS if os.environ.get(name)}
if gesetzt:
state["config"] = gesetzt
def git(*args):
return subprocess.run(["git", *args], cwd=ROOT, capture_output=True,
text=True, timeout=10).stdout.strip()
try:
state["commit"] = git("rev-parse", "--short", "HEAD") or "unbekannt"
state["dirty"] = bool(git("status", "--porcelain"))
except (OSError, subprocess.SubprocessError):
state["commit"], state["dirty"] = "unbekannt", None
return state
def load_cases(names=None):
cases = []
for case_dir in sorted(p for p in CASES_DIR.iterdir() if p.is_dir()):
if names and case_dir.name not in names:
continue
cases.append({
"name": case_dir.name,
"task": (case_dir / "task.txt").read_text(encoding="utf-8"),
"expected": json.loads((case_dir / "expected.json").read_text(encoding="utf-8")),
# Alles außer task.txt/expected.json gilt als beigelegte Datendatei (--daten)
"data_files": sorted(str(f) for f in case_dir.iterdir()
if f.is_file() and f.name not in ("task.txt", "expected.json")),
})
return cases
def result_correct(solver_run, expected: dict) -> bool:
"""Status muss passen (Default optimal); bei optimal zusätzlich der Zielwert."""
expected_status = expected.get("status", "optimal")
if solver_run is None or solver_run.status.value != expected_status:
return False
return expected_status != "optimal" or objective_matches(solver_run.objective_value, expected)
def objective_matches(actual, expected: dict) -> bool:
if actual is None:
return False
# Toleranz relativ, mindestens absolut (für Zielwerte < 1)
return abs(actual - expected["objective_value"]) <= expected["tolerance"] * max(1.0, abs(expected["objective_value"]))
def run_case(case: dict, model: str, run_no: int) -> dict:
expected = case["expected"]
row = {"case": case["name"], "model": model, "run": run_no, **prompt_state()}
if quota_exhausted.is_set():
row.update(seconds=0, error="übersprungen: fataler API-Fehler in früherem Lauf")
return row
start = time.monotonic()
try:
result = run_pipeline(case["task"], model=model, data_files=case.get("data_files") or ())
except Exception as exc: # API-/Validierungsfehler sollen den Benchmark nicht abbrechen
message = f"{type(exc).__name__}: {exc}"
if is_fatal_error(message):
quota_exhausted.set()
row.update(seconds=round(time.monotonic() - start), error=message[:500])
return row
spec, solver_run = result.specification, result.solver_run
# "Aufgabe erfüllt" statt nur "Zahl richtig": alle Teilfragen beantwortet, keine unbelegte Zahl
fragen = {q.id for q in result.understanding.sub_questions} if result.understanding else set()
antworten = {a.question_id for a in solver_run.answers} if solver_run else set()
abnahme = [w for w in (solver_run.warnings if solver_run else []) if "Antwort zu" in w or "Teilfragen ohne" in w]
objective_ok = result.reliable and result_correct(solver_run, expected)
row.update(
seconds=round(time.monotonic() - start),
problem_class=spec.problem_class.value,
target_stack=spec.target_stack.value,
class_ok=spec.problem_class.value == expected.get("problem_class", spec.problem_class.value),
stack_ok=spec.target_stack.value == expected.get("target_stack", spec.target_stack.value),
check_errors=sum(1 for i in result.checks if i.severity.value == "error"),
check_warnings=sum(1 for i in result.checks if i.severity.value == "warning"),
revisions=result.revisions,
review_ok=not result.review.needs_revision,
data_assumptions=len(result.solver_code.data_assumptions) if result.solver_code else 0,
solver_status=solver_run.status.value if solver_run else "nicht gestartet",
solver_attempts=solver_run.attempts if solver_run else 0,
objective_value=solver_run.objective_value if solver_run else None,
objective_ok=objective_ok,
questions=len(fragen),
answered=len(fragen & antworten),
answer_findings=len(abnahme),
task_ok=bool(objective_ok and fragen and fragen <= antworten and not abnahme
and len(fragen) >= expected.get("min_sub_questions", 1)),
solver_error=solver_run.error if solver_run else "; ".join(result.reliability_issues),
reliable=result.reliable,
restarts=result.restarts,
providers=result.usage.providers,
llm_calls=result.usage.calls,
reasoning_tokens=result.usage.reasoning_tokens,
cost_usd=result.usage.cost_usd,
result=json.loads(result.model_dump_json()),
)
return row
def mark(value) -> str:
return {True: "ja", False: "NEIN", None: "-"}.get(value, str(value))
def print_table(rows):
header = ["Fall", "Modell", "#", "Klasse", "Stack", "ChkErr", "Rev", "Review", "Solver", "Zielwert", "Zuverl.", "Neust.", "Treffer", "Fragen", "Antw.", "Erfüllt", "Datenann.", "Aufrufe", "Reasoning", "USD", "s"]
lines = [header]
for r in rows:
if "error" in r:
lines.append([r["case"], r["model"], str(r["run"]), "-", "-", "-", "-", "-", "ABBRUCH", "-", "-", "-", "NEIN", "-", "-", "NEIN", "-", "-", "-", "-", str(r["seconds"])])
continue
objective = "-" if r["objective_value"] is None else f"{r['objective_value']:.6g}"
lines.append([
r["case"], r["model"], str(r["run"]), mark(r["class_ok"]), mark(r["stack_ok"]), str(r["check_errors"]),
str(r["revisions"]), mark(r["review_ok"]), r["solver_status"], objective, mark(r["reliable"]), str(r["restarts"]), mark(r["objective_ok"]),
str(r.get("questions", 0)), str(r.get("answered", 0)), mark(r.get("task_ok")),
str(r["data_assumptions"]), str(r["llm_calls"]), str(r["reasoning_tokens"]),
"-" if r["cost_usd"] is None else f"{r['cost_usd']:.4f}", str(r["seconds"]),
])
widths = [max(len(line[i]) for line in lines) for i in range(len(header))]
for n, line in enumerate(lines):
print(" ".join(cell.ljust(w) for cell, w in zip(line, widths)))
if n == 0:
print(" ".join("-" * w for w in widths))
stand = prompt_state()
geaendert = " + nicht eingecheckte Änderungen" if stand.get("dirty") else ""
print(f"\nGemessener Stand: Commit {stand['commit']}{geaendert}, Prompt-Kennung {stand['prompts']} "
f"(über {', '.join(PROMPT_FILES)})")
if stand.get("config"):
print("Abweichende Einstellungen: " + ", ".join(f"{k}={v}" for k, v in stand["config"].items()))
print("\nZusammenfassung je Modell:")
for model in dict.fromkeys(r["model"] for r in rows):
mine = [r for r in rows if r["model"] == model]
hits = sum(1 for r in mine if r.get("objective_ok"))
erfuellt = sum(1 for r in mine if r.get("task_ok"))
routing = sum(1 for r in mine if r.get("class_ok") and r.get("stack_ok"))
seconds = sum(r["seconds"] for r in mine) / len(mine)
cost = sum(r.get("cost_usd") or 0 for r in mine)
print(f" {model}: Ergebnis korrekt {hits}/{len(mine)}, Aufgabe erfüllt {erfuellt}/{len(mine)}, Routing korrekt {routing}/{len(mine)}, "
f"Ø {seconds:.0f} s pro Lauf, Kosten gesamt ${cost:.4f} (ohne abgebrochene Läufe)")
for r in rows:
if r.get("error") or r.get("solver_error"):
print(f"\n[{r['case']} | {r['model']} | #{r['run']}] {r.get('error') or r.get('solver_error')}")
if quota_exhausted.is_set():
print("\nABBRUCH: fataler API-Fehler (Kontingent, Guthaben oder Key) – restliche Läufe wurden übersprungen.")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--models", nargs="+", default=[DEFAULT_MODEL])
parser.add_argument("--runs", type=int, default=1, help="Läufe je Fall und Modell (Streuung sichtbar machen)")
parser.add_argument("--cases", nargs="+", help="nur diese Fälle (Verzeichnisnamen)")
parser.add_argument("--jobs", type=int, default=1, help="parallele Läufe (Gemini Free Tier: 1)")
args = parser.parse_args()
cases = load_cases(args.cases)
jobs = [(case, model, run_no) for model in args.models for case in cases for run_no in range(1, args.runs + 1)]
with ThreadPoolExecutor(max_workers=args.jobs) as pool:
rows = list(pool.map(lambda job: run_case(*job), jobs))
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
out_file = RESULTS_DIR / f"{datetime.now():%Y%m%d-%H%M%S}.json"
out_file.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
print()
print_table(rows)
print(f"\nDetails: {out_file}")
if __name__ == "__main__":
main()

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)

0
core/__init__.py Normal file
View file

162
core/llm.py Normal file
View file

@ -0,0 +1,162 @@
"""LLM-Zugriff: Client je Anbieter, Wiederholung bei vorübergehenden Fehlern, Nutzungs-/Kostenerfassung.
Anbieter wird am Modellnamen erkannt:
- mit "/" (z. B. "deepseek/deepseek-v4-flash-0731") -> OpenRouter, Key: OPENROUTER_API_KEY
- sonst (z. B. "gemini-2.5-flash") -> Google Gemini, Key: GEMINI_API_KEY
"""
import os
import sys
import threading
import time
import instructor
from core.model_schema import CallRecord, LLMUsage
DEFAULT_MODEL = os.environ.get("LLM_MODEL", "deepseek/deepseek-v4.1-flash")
# Modell je Pipeline-Stufe: ein starkes Modell für Extraktion/Prüfung, ein schnelles für den Solver-Code
# (oder umgekehrt, je nach Messung). Leer = das Modell der Session (LLM_MODEL bzw. --model).
STEP_MODEL_ENV = {
"Extraktion": "LLM_MODEL_EXTRACTION",
"Prüfung": "LLM_MODEL_REVIEW",
"Korrektur": "LLM_MODEL_REVISION",
"Solver-Code": "LLM_MODEL_SOLVER",
"Solver-Reparatur": "LLM_MODEL_SOLVER",
}
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# Ohne Vorgabe wählt OpenRouter den billigsten Anbieter – bei DeepSeek V4 teils nur ~20 Tokens/s,
# bei zehntausenden Reasoning-Tokens also Minuten pro Aufruf. Weiterhin preisorientiert, aber mit Mindestdurchsatz.
# Gemessen am 16.09.: Fireworks ~178 Tokens/s gegen Parasail ~60 Tokens/s beim selben Modell – Faktor 3.
# Es ist eine Präferenz, kein harter Filter: gibt es keinen so schnellen Anbieter mit JSON-Schema,
# leitet OpenRouter trotzdem weiter (geprüft mit qwen3-coder -> SiliconFlow, ~25 Tokens/s).
OPENROUTER_MIN_THROUGHPUT = int(os.environ.get("OPENROUTER_MIN_THROUGHPUT", "75")) # Tokens/s (p50)
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "420")) # Sekunden je Aufruf
# Ohne Vorgabe schneiden manche OpenRouter-Anbieter die Antwort ab (beobachtet: abgebrochene
# Modellspezifikation in einer Korrekturrunde). Großzügig setzen; bezahlt werden nur erzeugte Tokens.
LLM_MAX_TOKENS = int(os.environ.get("LLM_MAX_TOKENS", "32000"))
# Denkaufwand (OpenRouter): "low" | "medium" | "high"; leer = Modellvorgabe.
# Reasoning-Tokens machen den Großteil der Laufzeit aus (gemessen: 45.000 von 73.000 Output-Tokens).
REASONING_EFFORT = os.environ.get("LLM_REASONING_EFFORT", "").strip().lower()
# Wartezeiten in Sekunden bei vorübergehenden Fehlern (Überlastung, Minutenlimit, Verbindung)
RETRY_WAITS = (10, 30, 60)
# Fehler, bei denen Wiederholen nichts bringt, sondern nur Kontingent/Geld kostet
FATAL_MARKERS = ("RESOURCE_EXHAUSTED", "Error code: 401", "Error code: 402", "Error code: 403")
TRANSIENT_MARKERS = (
"UNAVAILABLE", "Error code: 429", "Error code: 500", "Error code: 502", "Error code: 503",
"Error code: 504", "Connection error", "timed out", "Request timed out",
"incomplete due to a max_tokens", "IncompleteOutputException", # anderer Anbieter kann mehr liefern
)
def is_fatal_error(message: str) -> bool:
"""Kontingent erschöpft, Guthaben leer oder Key ungültig."""
return any(marker in message for marker in FATAL_MARKERS)
def is_transient_error(message: str) -> bool:
return not is_fatal_error(message) and any(marker in message for marker in TRANSIENT_MARKERS)
def provider_for(model: str) -> str:
return "openrouter" if "/" in model else "gemini"
def model_for_step(step: str, default: str) -> str:
"""Modell dieser Pipeline-Stufe: Umgebungsvariable aus STEP_MODEL_ENV, sonst das Session-Modell."""
return os.environ.get(STEP_MODEL_ENV.get(step, ""), "").strip() or default
def _make_client(provider: str):
if provider == "openrouter":
from openai import OpenAI
return instructor.from_openai(
# max_retries=0: Wiederholungen übernimmt LLMSession.create (mit Fehlerklassifizierung)
OpenAI(base_url=OPENROUTER_BASE_URL, api_key=os.environ["OPENROUTER_API_KEY"],
timeout=LLM_TIMEOUT, max_retries=0),
mode=instructor.Mode.JSON_SCHEMA,
)
from google import genai
return instructor.from_genai(
client=genai.Client(), # liest GEMINI_API_KEY bzw. GOOGLE_API_KEY
mode=instructor.Mode.GENAI_STRUCTURED_OUTPUTS,
)
class LLMSession:
"""Ein Modell + Client; summiert Tokens und Kosten aller Aufrufe (thread-sicher)."""
def __init__(self, model: str = DEFAULT_MODEL):
self.model = model
self.provider = provider_for(model)
self.step_models = {step: model_for_step(step, model) for step in STEP_MODEL_ENV}
# Clients hier anlegen, damit create() thread-sicher bleibt; ohne Stufen-Modell ist es genau einer
self._clients = {provider_for(m): _make_client(provider_for(m)) for m in {model, *self.step_models.values()}}
self.client = self._clients[self.provider]
self.usage = LLMUsage(model=model, provider=self.provider)
self._lock = threading.Lock()
def overrides(self) -> dict:
"""Stufen mit abweichendem Modell – für die Fortschrittsausgabe."""
return {step: m for step, m in self.step_models.items() if m != self.model}
def create(self, response_model, messages, step: str = "unbenannt"):
model = self.step_models.get(step, self.model)
provider = provider_for(model)
kwargs = {"model": model, "response_model": response_model, "messages": messages}
if provider == "openrouter":
kwargs["max_tokens"] = LLM_MAX_TOKENS
# nur Anbieter wählen, die strukturierte Ausgabe (response_format) wirklich unterstützen
kwargs["extra_body"] = {"provider": {
"require_parameters": True,
"preferred_min_throughput": {"p50": OPENROUTER_MIN_THROUGHPUT},
}}
if REASONING_EFFORT:
kwargs["extra_body"]["reasoning"] = {"effort": REASONING_EFFORT}
for wait in (*RETRY_WAITS, None):
started = time.monotonic()
try:
result, completion = self._clients[provider].chat.completions.create_with_completion(**kwargs)
self._record(completion, step, time.monotonic() - started, model)
return result
except Exception as exc:
if wait is None or not is_transient_error(str(exc)):
raise
print(f"Vorübergehender LLM-Fehler – neuer Versuch in {wait} s: {str(exc)[:120]}", file=sys.stderr)
time.sleep(wait)
def _record(self, completion, step: str = "unbenannt", seconds: float = 0.0, model: str = None) -> None:
before = (self.usage.completion_tokens, self.usage.reasoning_tokens)
with self._lock:
self.usage.calls += 1
provider = getattr(completion, "provider", None) # OpenRouter: tatsächlich antwortender Anbieter
if provider:
self.usage.providers[provider] = self.usage.providers.get(provider, 0) + 1
usage = getattr(completion, "usage", None)
if usage is not None: # OpenAI-Format (OpenRouter)
self.usage.prompt_tokens += usage.prompt_tokens or 0
self.usage.completion_tokens += usage.completion_tokens or 0
details = getattr(usage, "completion_tokens_details", None)
self.usage.reasoning_tokens += getattr(details, "reasoning_tokens", 0) or 0
cost = getattr(usage, "cost", None)
if cost is not None:
self.usage.cost_usd = (self.usage.cost_usd or 0.0) + cost
self._log_call(step, seconds, before, getattr(completion, "provider", None), model)
return
meta = getattr(completion, "usage_metadata", None)
if meta is not None: # Gemini-Format, keine Kostenangabe
self.usage.prompt_tokens += meta.prompt_token_count or 0
self.usage.completion_tokens += meta.candidates_token_count or 0
self.usage.reasoning_tokens += meta.thoughts_token_count or 0
self._log_call(step, seconds, before, getattr(completion, "provider", None), model)
def _log_call(self, step, seconds, before, provider, model=None) -> None:
self.usage.call_records.append(CallRecord(
step=step, seconds=round(seconds, 1), provider=provider, model=model,
completion_tokens=self.usage.completion_tokens - before[0],
reasoning_tokens=self.usage.reasoning_tokens - before[1],
))

439
core/model_schema.py Normal file
View file

@ -0,0 +1,439 @@
from enum import Enum
from typing import Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field
class ProblemClass(str, Enum):
LP = "Linear Programming (LP)"
MILP = "Mixed-Integer Linear Programming (MILP)"
CP = "Constraint Programming (CP)"
QP = "Quadratic Programming (QP)"
SOCP = "Second-Order Cone Programming (SOCP)"
MIQP = "Mixed-Integer Quadratic Programming (MIQP)"
NLP = "Nonlinear Programming (NLP)"
SHORTEST_PATH = "Kürzeste Wege im Graphen (Shortest Path)"
class TargetStack(str, Enum):
HIGHS = "HiGHS"
OR_TOOLS_CPSAT = "Google OR-Tools (CP-SAT)"
CVXPY = "CVXPY"
SCIPY = "SciPy (scipy.optimize.linprog)"
SCIP = "SCIP (pyscipopt)"
IPOPT = "Ipopt (cyipopt)"
NETWORKX = "NetworkX (Dijkstra)"
class ObjectiveSense(str, Enum):
MINIMIZE = "minimize"
MAXIMIZE = "maximize"
class VariableType(str, Enum):
CONTINUOUS = "continuous"
INTEGER = "integer"
BINARY = "binary"
class VariableDefinition(BaseModel):
name: str = Field(description="Mathematischer Variablenname, z. B. x_{i,j}")
var_type: VariableType
domain: str = Field(description="Definitionsbereich, z. B. >= 0, [0, 1] oder {0, 1}")
description: str = Field(description="Semantische Bedeutung für Entscheider")
class ParameterDefinition(BaseModel):
symbol: str = Field(description="Mathematisches Symbol, z. B. c_j oder \\Sigma")
dimension: str = Field(description="Dimension, z. B. Skalar, Vektor (n,), Matrix (n x n)")
unit: str = Field(
description="Physikalische Einheit des Werts, z. B. '€', '€/t', 'h', 'Stück', '%' oder 'dimensionslos'. "
"Wird maschinell geprüft: Terme der Zielfunktion dürfen Geldbeträge nicht mit "
"dimensionslosen Größen mischen"
)
description: str = Field(description="Bedeutung (z. B. Kosten pro Tonne, Renditen)")
extracted_values: Optional[str] = Field(
default=None, description="Explizite Werte aus der Aufgabe, falls vorhanden"
)
class ConstraintDefinition(BaseModel):
identifier: str = Field(description="Kurzname, z. B. 'Kapazitaetsgrenze_Lager'")
latex_formula: str = Field(description="Formel in LaTeX-Syntax, z. B. '\\sum_{j} a_j x_j \\le C'")
description: str = Field(description="Erklärung der Restriktion in Geschäftssprache")
is_hard_constraint: bool = Field(default=True)
class AssumptionDefinition(BaseModel):
unclear_point: str = Field(description="Unklare oder fehlende Stelle in der Aufgabenstellung")
assumption: str = Field(description="Getroffene Annahme, auf der das Modell beruht")
alternative: str = Field(
description="Die zweite plausible Lesart derselben Stelle, ausformuliert, und der Grund, warum das Modell "
"ihr nicht folgt. Nur wenn die Stelle wirklich eindeutig ist: 'keine zweite Lesart'"
)
impact: str = Field(description="Auswirkung der Annahme auf Variablen, Parameter oder Restriktionen")
class ColumnProfile(BaseModel):
"""Eine Spalte einer beigelegten Datei – gerechnet, nicht geschätzt."""
name: str
dtype: str
missing: int = 0
minimum: Optional[float] = None
maximum: Optional[float] = None
mean: Optional[float] = None
samples: List[str] = Field(default_factory=list)
class DataFileProfile(BaseModel):
"""Profil einer beigelegten Datendatei (siehe data_profile.py)."""
name: str
rows: int
columns: List[ColumnProfile] = Field(default_factory=list)
truncated_columns: int = 0
error: Optional[str] = None
class SubQuestion(BaseModel):
"""Eine Teilfrage der Aufgabe. Jede muss am Ende beantwortet werden."""
id: str = Field(description="Kennung wie im Text: 'a', 'b', 'Teil 2', '1'")
quote: str = Field(description="Die Frage wörtlich aus dem Aufgabentext")
kind: Literal["formulieren", "rechnen", "vergleichen", "erklaeren", "zeichnen"] = Field(
description="Was verlangt wird: ein Modell aufstellen, eine Zahl berechnen, Varianten vergleichen, "
"etwas begründen oder eine Skizze/einen Graphen")
needs_solver: bool = Field(description="True, wenn die Antwort eine berechnete Zahl braucht")
class Fact(BaseModel):
"""Ein Zahlenwert aus dem Aufgabentext, mit seiner Fundstelle."""
value: str = Field(description="Der Wert genau wie im Text, z. B. '8', '0,05', '12 %'")
unit: str = Field(description="Einheit, z. B. '€', 'h', 'Stück', '%', 'dimensionslos'")
meaning: str = Field(description="Wofür die Zahl steht, in der Sprache der Aufgabe")
quote: str = Field(description="Die Textstelle, in der die Zahl steht (Zeile oder Tabellenzeile)")
class IgnoredNumber(BaseModel):
"""Eine Zahl im Text, die keine Modelldaten sind – mit Begründung."""
value: str
reason: str = Field(description="Warum das keine Eingabegröße ist, z. B. 'Aufgabennummer', "
"'Teil der Einheit (in 1000 Stunden)', 'Seitenzahl'")
class TaskUnderstanding(BaseModel):
"""Was in der Aufgabe steht – vor jeder Modellierung: Fragen, Zahlen, Machbarkeit."""
is_optimization_task: bool = Field(
description="False, wenn der Text gar keine Optimierungs- oder Entscheidungsaufgabe stellt "
"(dann wird nicht modelliert)")
reason: str = Field(description="Begründung in einem Satz, worum es geht bzw. warum es keine Aufgabe ist")
sub_questions: List[SubQuestion] = Field(
description="Alle Teilfragen in der Reihenfolge des Textes; bei einer einzigen Frage genau ein Eintrag")
facts: List[Fact] = Field(description="Alle Zahlenwerte des Textes, die Eingabegrößen des Modells sind")
ignored_numbers: List[IgnoredNumber] = Field(
default_factory=list, description="Zahlen im Text, die keine Eingabegrößen sind, mit Begründung")
class ObjectiveDefinition(BaseModel):
sense: ObjectiveSense
latex_formula: str = Field(description="Zielfunktion in LaTeX, z. B. '\\min \\sum_i c_i x_i' oder '\\min w^T \\Sigma w'")
description: str = Field(description="Ökonomisches Ziel (z. B. Gesamtkosten senken)")
class OptimizationModelSpecification(BaseModel):
"""Vollständige Zerlegung und Routing-Spezifikation einer Optimierungsaufgabe."""
# 1. Routing & Taxonomie
problem_class: ProblemClass
target_stack: TargetStack
routing_rationale: str = Field(
description="Präzise Begründung, warum dieser Stack für das Problem optimal ist."
)
# 2. Didaktische Zusammenfassung
executive_summary: str = Field(
description="Verständliche Zusammenfassung des Entscheidungsproblems für Manager."
)
# 3. Mathematische Bausteine
indices: List[str] = Field(
description="Mengen und Indizes, z. B. ['i in I (Mitarbeiter)', 't in T (Zeitschritte)']"
)
parameters: List[ParameterDefinition]
variables: List[VariableDefinition]
objective: ObjectiveDefinition
constraints: List[ConstraintDefinition]
# 4. Offene Annahmen
open_assumptions: List[AssumptionDefinition] = Field(
default_factory=list,
description="Annahmen zu unklaren oder fehlenden Angaben; leer, wenn die Aufgabe eindeutig ist",
)
# 5. Technische Schnittstelle
suggested_export_format: str = Field(
description="Zieldateityp: '.lp', '.mps', 'Python (CP-SAT)', oder 'Matrix-Dict'"
)
# --- Deterministische Checks (ohne LLM, siehe model_checks.py) ---
class CheckSeverity(str, Enum):
ERROR = "error" # löst Korrekturrunde aus
WARNING = "warning" # nur Report
class CheckIssue(BaseModel):
severity: CheckSeverity
code: str
message: str
# --- Solver-Stufe (siehe solver_stage.py) ---
class SolverStatus(str, Enum):
OPTIMAL = "optimal"
FEASIBLE = "feasible"
INFEASIBLE = "infeasible"
UNBOUNDED = "unbounded"
ERROR = "error" # Laufzeitfehler, Timeout, fehlendes RESULT_JSON, Import-Verstoß
class SolverCode(BaseModel):
code: str = Field(description="Vollständiges, eigenständig ausführbares Python-Skript ohne Markdown-Codeblock")
data_assumptions: List[str] = Field(
default_factory=list,
description="Alle Datenwerte, die nicht im Aufgabentext stehen und für die Lösung angenommen wurden",
)
class ResultTable(BaseModel):
"""Verständliche Ergebnistabelle, vom Solver-Programm aus der tatsächlichen Lösung berechnet."""
title: str
columns: List[str]
rows: List[List[Union[float, int, str, None]]]
objective_column: Optional[str] = Field(
default=None, description="Spalte mit dem Beitrag je Zeile zur Zielfunktion (im Maßstab von objective_value); Summe muss dem Zielwert entsprechen")
unit: Optional[str] = Field(default=None, description="Einheit der objective_column, z. B. '€' oder '%'")
note: Optional[str] = None
class ConstraintValue(BaseModel):
"""Eine Restriktion, ausgewertet an der gefundenen Lösung (vom Solver-Programm gemeldet)."""
name: str = Field(description="Name der Restriktion, beginnt mit dem identifier aus der Spezifikation")
lhs: float = Field(description="Wert der linken Seite an der Lösung")
sense: Literal["<=", ">=", "=="]
rhs: float
class VariableValue(BaseModel):
"""Eine Variable mit Wert, Typ und Schranken – Grundlage für Ganzzahligkeits- und Schrankenprüfung."""
name: str
value: float
var_type: VariableType = VariableType.CONTINUOUS
lb: Optional[float] = None
ub: Optional[float] = None
class Sensitivity(BaseModel):
"""Wirkung einer Lockerung/Verschärfung einer bindenden Restriktion, durch Neulösen ermittelt."""
name: str = Field(description="Name der Restriktion wie in constraints")
delta: float = Field(description="Verschiebung der rechten Seite, > 0")
objective_plus: Optional[float] = Field(default=None, description="Zielwert bei rechter Seite + delta; None wenn dann unlösbar")
objective_minus: Optional[float] = Field(default=None, description="Zielwert bei rechter Seite - delta")
unit: Optional[str] = Field(default=None, description="Einheit des Zielwerts je Einheit der rechten Seite")
class Answer(BaseModel):
"""Die Antwort auf eine Teilfrage – in der Sprache der Frage, nicht im Modelljargon."""
question_id: str = Field(description="Kennung der Teilfrage wie im Verständnis ('a', 'b', 'Teil 2')")
text: str = Field(description="Antwortsatz, der die Frage beantwortet; nur tatsächlich berechnete Zahlen")
class Scenario(BaseModel):
"""Eine Konfiguration im Vergleich – als Abweichung der Eingabedaten, nicht als eigenes Modell."""
name: str = Field(description="Kurzer Name, z. B. 'Reine Varianzminimierung'")
description: str = Field(description="Was an dieser Konfiguration anders ist, in einem Satz")
data_override: dict = Field(
description="Die abweichenden Einträge von DATA, z. B. {'lambda_1': 1, 'lambda_2': 0}")
objective_value: Optional[float] = Field(
default=None, description="Zielwert dieser Konfiguration, vom Programm berechnet")
verified_value: Optional[float] = Field(
default=None, description="Vom Runner durch erneutes Ausführen bestätigter Zielwert (ohne LLM)")
class Multistart(BaseModel):
"""Streuung über mehrere Startpunkte – bei nichtkonvexen Problemen der einzige Hinweis darauf,
ob das gefundene lokale Optimum stabil ist (Ipopt, siehe Solver-Prompt)."""
starts: int = Field(description="Anzahl versuchter Startpunkte")
converged: int = Field(description="Wie viele davon eine zulässige Lösung erreicht haben")
best: float = Field(description="Bester Zielwert (der gemeldete)")
worst: Optional[float] = Field(default=None, description="Schlechtester Zielwert der erfolgreichen Läufe")
mean: Optional[float] = Field(default=None, description="Mittelwert der erfolgreichen Zielwerte")
stddev: Optional[float] = Field(default=None, description="Standardabweichung der erfolgreichen Zielwerte")
class GraphArc(BaseModel):
"""Ein Bogen des zugrunde liegenden Graphen, mit seiner fachlichen Bedeutung."""
source: str = Field(description="Startknoten")
target: str = Field(description="Zielknoten")
weight: float = Field(description="Bogengewicht (Kosten, Dauer, Entfernung)")
class DijkstraStep(BaseModel):
"""Ein Schritt des Markierungsverfahrens: welcher Knoten dauerhaft wurde und mit welcher Marke."""
node: str
label: float = Field(description="Endgültige Entfernung dieses Knotens von der Quelle")
class GraphSolution(BaseModel):
"""Der Graph, sein optimaler Weg und der Markierungsverlauf – Grundlage für Zeichnung und Prüfung."""
nodes: List[str]
arcs: List[GraphArc]
path: List[str] = Field(default_factory=list, description="Knotenfolge des kürzesten Weges, von der Quelle zur Senke")
steps: List[DijkstraStep] = Field(
default_factory=list, description="Reihenfolge, in der Dijkstra die Knoten dauerhaft markiert hat")
node_meaning: str = Field(description="Was ein Knoten in der Aufgabe bedeutet, in einem Satz ohne Fachjargon")
arc_meaning: str = Field(description="Was ein Bogen in der Aufgabe bedeutet, in einem Satz ohne Fachjargon")
class SolverRun(BaseModel):
status: SolverStatus
objective_value: Optional[float] = None
headline: Optional[str] = Field(default=None, description="Ein Satz mit dem Ergebnis, nur berechnete Zahlen")
solution: dict = Field(default_factory=dict)
tables: List[ResultTable] = Field(default_factory=list)
constraints: List[ConstraintValue] = Field(default_factory=list)
variables: List[VariableValue] = Field(default_factory=list)
sensitivity: List[Sensitivity] = Field(default_factory=list)
data: dict = Field(default_factory=dict, description="Eingabedaten (DATA) des Programms – Grundlage für --resolve")
relaxation_bound: Optional[float] = Field(
default=None, description="Zielwert der stetigen Relaxation (nur bei ganzzahligen Modellen)")
multistart: Optional[Multistart] = Field(
default=None, description="Streuung über mehrere Startpunkte (nur bei lokalen Verfahren wie Ipopt)")
graph: Optional[GraphSolution] = Field(
default=None, description="Graph, kürzester Weg und Markierungsverlauf (nur bei Graphenproblemen)")
answers: List[Answer] = Field(
default_factory=list, description="Eine Antwort je Teilfrage der Aufgabe (Rückprojektion des Ergebnisses)")
scenarios: List[Scenario] = Field(
default_factory=list, description="Verglichene Konfigurationen (Datenvarianten desselben Modells)")
feasibility: List[str] = Field(
default_factory=list, description="Befunde der deterministischen Zulässigkeitsprüfung; leer = Lösung hält alles ein")
undeclared_data: List[str] = Field(
default_factory=list, description="Selbst erzeugte, nicht ausgewiesene Eingabedaten; leer = alles ausgewiesen")
warnings: List[str] = Field(default_factory=list, description="Deterministische Prüfungen der Ergebnistabellen")
error: Optional[str] = None
attempts: int = 1
# --- Prüfschritt: Abgleich der Annahmen mit dem Aufgabentext ---
class AssumptionVerdict(str, Enum):
JUSTIFIED = "justified" # Text ist unklar, Annahme plausibel und textkonform
UNNECESSARY = "unnecessary" # Text ist eindeutig, Annahme überflüssig (aber nicht falsch)
CONTRADICTS_TASK = "contradicts_task" # Annahme steht im Widerspruch zum Text
STRICTER_THAN_TASK = "stricter_than_task" # Modell schränkt stärker ein als der Text verlangt
WEAKER_THAN_TASK = "weaker_than_task" # Modell lässt mehr zu als der Text erlaubt
class AssumptionCheck(BaseModel):
unclear_point: str = Field(description="Die geprüfte Stelle (aus open_assumptions übernommen)")
verdict: AssumptionVerdict
task_evidence: str = Field(description="Wörtliches Zitat aus dem Aufgabentext, auf das sich das Urteil stützt")
explanation: str = Field(description="Begründung des Urteils, inkl. logischer Analyse (z. B. Implikation vs. Äquivalenz)")
correction: Optional[str] = Field(
default=None,
description="Korrigierte Annahme bzw. Formel in LaTeX; nur bei contradicts_task, stricter_than_task oder weaker_than_task",
)
class ObjectiveCheck(BaseModel):
task_evidence: str = Field(description="Wörtliches Zitat aus dem Aufgabentext, das die zu optimierende bzw. anzugebende Größe benennt")
asked_quantity: str = Field(description="Gefragte Größe mit Einheit/Maßstab, z. B. 'Standardabweichung der Portfoliorendite' oder 'Gesamtkosten in €'")
modeled_quantity: str = Field(description="Größe, die objective.latex_formula tatsächlich berechnet")
matches: bool = Field(
description="True nur, wenn die Zielfunktion genau die gefragte Größe (inkl. Richtung min/max und Maßstab) liefert; "
"eine monoton transformierte Größe (z. B. Varianz statt Standardabweichung) ist False"
)
units_consistent: bool = Field(
description="True nur, wenn alle Terme der Zielfunktion dieselbe Einheit haben (z. B. alle in Euro oder alle relativ); "
"ein dimensionsloser Term neben Euro-Beträgen ist False")
unit_explanation: str = Field(description="Einheit je Term der Zielfunktion, z. B. 'Rendite €, Risiko dimensionslos, Kosten €'")
explanation: str
correction: Optional[str] = Field(default=None, description="Korrigierte Zielfunktion in LaTeX; nur wenn matches oder units_consistent False ist")
class AssumptionReview(BaseModel):
"""Prüfbericht: Zielgröße und offene Annahmen einer Modellspezifikation gegen den Aufgabentext."""
objective_check: ObjectiveCheck
checks: List[AssumptionCheck]
missing_assumptions: List[AssumptionDefinition] = Field(
default_factory=list,
description="Unklare Stellen im Text, die das Modell stillschweigend entschieden hat, ohne sie auszuweisen",
)
@property
def needs_revision(self) -> bool:
faulty = {
AssumptionVerdict.CONTRADICTS_TASK,
AssumptionVerdict.STRICTER_THAN_TASK,
AssumptionVerdict.WEAKER_THAN_TASK,
}
return (
not self.objective_check.matches
or not self.objective_check.units_consistent
or bool(self.missing_assumptions)
or any(c.verdict in faulty for c in self.checks)
)
class CallRecord(BaseModel):
"""Ein LLM-Aufruf – für die Frage, welche Pipeline-Stufe wie lange dauert."""
step: str
seconds: float
completion_tokens: int = 0
reasoning_tokens: int = 0
provider: Optional[str] = None
model: Optional[str] = Field(default=None, description="Tatsächlich benutztes Modell (kann je Stufe abweichen)")
class LLMUsage(BaseModel):
model: str
provider: str
calls: int = 0
prompt_tokens: int = 0
completion_tokens: int = Field(default=0, description="inkl. Reasoning-Tokens")
reasoning_tokens: int = 0
cost_usd: Optional[float] = Field(default=None, description="nur OpenRouter liefert Kosten")
providers: Dict[str, int] = Field(default_factory=dict, description="Antwortender Anbieter -> Anzahl Aufrufe (OpenRouter)")
call_records: List[CallRecord] = Field(default_factory=list)
def by_step(self) -> Dict[str, dict]:
"""Sekunden und Tokens je Pipeline-Stufe."""
summary: Dict[str, dict] = {}
for record in self.call_records:
entry = summary.setdefault(record.step, {"calls": 0, "seconds": 0.0, "completion_tokens": 0, "reasoning_tokens": 0})
entry["calls"] += 1
entry["seconds"] += record.seconds
entry["completion_tokens"] += record.completion_tokens
entry["reasoning_tokens"] += record.reasoning_tokens
return summary
class PipelineResult(BaseModel):
task: str = ""
data_files: List[DataFileProfile] = Field(
default_factory=list, description="Profile der beigelegten Datendateien (--daten)")
understanding: Optional[TaskUnderstanding] = Field(
default=None, description="Ergebnis der Verstehensstufe (Teilfragen, Fakten); None bei alten Ergebnissen")
specification: OptimizationModelSpecification
review: AssumptionReview
revisions: int = Field(description="Anzahl durchgeführter Korrekturrunden")
selected_revision: int = Field(default=0, description="Runde, deren Spezifikation verwendet wurde (0 = Erstentwurf)")
restarts: int = Field(default=0, description="Anzahl kompletter Neustarts der Modellierung")
reliable: bool = Field(default=True, description="False, wenn nach allen Runden/Neustarts Zielgröße oder Konsistenz nicht stimmen")
infeasible_confirmed: bool = Field(
default=False, description="True, wenn 'unlösbar' durch eine zweite, unabhängige Modellierung bestätigt wurde")
reliability_issues: List[str] = Field(default_factory=list)
checks: List[CheckIssue] = Field(default_factory=list)
solver_code: Optional[SolverCode] = None
solver_run: Optional[SolverRun] = None
usage: Optional[LLMUsage] = None

0
data/__init__.py Normal file
View file

89
data/data_profile.py Normal file
View file

@ -0,0 +1,89 @@
"""Profil beigelegter Datendateien – Zeilen, Spalten, Typen, Spannweiten, Lücken.
Bewusst **ohne LLM**: Das Fremdprojekt MM-Agent lässt eine Datenbeschreibung vom Modell schreiben
(`DataDescription.summary`). Gerechnet ist billiger, reproduzierbar und nicht halluzinierbar – und
das Profil ist damit selbst eine geprüfte Tatsache, auf die sich die späteren Stufen berufen dürfen.
"""
from pathlib import Path
import pandas as pd
from core.model_schema import ColumnProfile, DataFileProfile
SAMPLE_ROWS = 3 # so viele Beispielwerte je Spalte in den Prompt
MAX_COLUMNS = 40 # breitere Tabellen werden abgeschnitten, damit der Prompt nicht platzt
def _read(path: Path) -> pd.DataFrame:
"""CSV (mit Trennzeichen-Erkennung) oder Excel einlesen."""
if path.suffix.lower() in (".xlsx", ".xls"):
return pd.read_excel(path)
# sep=None + engine="python" erkennt Komma, Semikolon und Tabulator selbst
return pd.read_csv(path, sep=None, engine="python")
def _as_numbers(spalte: pd.Series) -> pd.Series:
"""Zahlen einer Spalte, auch bei deutscher Schreibweise ('1.234,56' als Text eingelesen)."""
zahlen = pd.to_numeric(spalte, errors="coerce")
vorhanden = spalte.notna().sum()
if vorhanden and zahlen.notna().sum() < vorhanden and pd.api.types.is_string_dtype(spalte):
deutsch = pd.to_numeric(
spalte.astype(str).str.replace(".", "", regex=False).str.replace(",", ".", regex=False),
errors="coerce")
if deutsch.notna().sum() > zahlen.notna().sum():
return deutsch
return zahlen
def _column(name: str, spalte: pd.Series) -> ColumnProfile:
zahlen = _as_numbers(spalte)
# Eine Spalte gilt als Zahlenspalte, wenn die große Mehrheit der belegten Werte Zahlen sind
vorhanden = max(int(spalte.notna().sum()), 1)
numerisch = zahlen.notna().sum() >= 0.8 * vorhanden and not pd.api.types.is_bool_dtype(spalte)
beispiele = [str(v) for v in spalte.dropna().unique()[:SAMPLE_ROWS]]
return ColumnProfile(
name=str(name),
dtype="Zahl" if numerisch else "Text",
missing=int(spalte.isna().sum()),
minimum=float(zahlen.min()) if numerisch and zahlen.notna().any() else None,
maximum=float(zahlen.max()) if numerisch and zahlen.notna().any() else None,
mean=float(zahlen.mean()) if numerisch and zahlen.notna().any() else None,
samples=beispiele,
)
def profile_file(path) -> DataFileProfile:
"""Eine Datei einlesen und ihr Profil bestimmen; unlesbare Dateien melden den Fehler."""
path = Path(path)
try:
tabelle = _read(path)
except Exception as exc: # kaputte Datei darf den Lauf nicht abbrechen
return DataFileProfile(name=path.name, rows=0, columns=[], error=f"{type(exc).__name__}: {exc}")
spalten = [_column(name, tabelle[name]) for name in list(tabelle.columns)[:MAX_COLUMNS]]
return DataFileProfile(name=path.name, rows=int(len(tabelle)), columns=spalten,
truncated_columns=max(0, len(tabelle.columns) - MAX_COLUMNS))
def profile_files(paths) -> list:
return [profile_file(p) for p in paths or []]
def to_prompt(profiles) -> str:
"""Das Profil als knapper Text für die Prompts – Spaltennamen sind das Wichtigste."""
if not profiles:
return ""
teile = ["BEIGELEGTE DATEN (deterministisch ermittelt, nicht geschätzt):"]
for p in profiles:
if p.error:
teile.append(f"- {p.name}: NICHT LESBAR ({p.error})")
continue
teile.append(f"- {p.name}: {p.rows} Zeilen, {len(p.columns)} Spalten"
+ (f" (weitere {p.truncated_columns} Spalten nicht gezeigt)" if p.truncated_columns else ""))
for s in p.columns:
spanne = (f", von {s.minimum:g} bis {s.maximum:g}, Mittel {s.mean:g}"
if s.minimum is not None else "")
luecken = f", {s.missing} fehlend" if s.missing else ""
teile.append(f" {s.name} ({s.dtype}{spanne}{luecken}): {', '.join(s.samples)}")
teile.append("Lies diese Dateien im Programm über ihren Dateinamen (sie liegen im Arbeitsverzeichnis). "
"Jede abgeleitete Kennzahl wird aus den Daten berechnet, nie geschätzt.")
return "\n".join(teile)

View file

@ -0,0 +1,69 @@
Du bist ein Experte für mathematische Optimierung und OR-Architektur.
Deine Aufgabe ist es, eine natürlichsprachige Optimierungsaufgabe systematisch zu dekonstruieren, in ein formales mathematisches Modell zu überführen und der optimalen Python-Bibliothek zuzuweisen.
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.
Entscheide streng nach folgenden Routing-Regeln (die Zuordnung Klasse → Stack wird nachgerechnet) [→ STACK_MISMATCH]:
1. ZIEL: Google OR-Tools (CP-SAT)
- Typ: Constraint Programming (CP)
- Indikatoren: Schicht- und Personalplanung, Disjunktive Zeitfenster (Interval-Variablen), Reihenfolgeentscheidungen (No-Overlap), logische Verknüpfungen (IF-THEN, XOR), diskrete Packungs- oder Zuweisungsprobleme ohne Konvexität.
2. ZIEL: HiGHS (highspy) / SciPy
- Typ: Lineare Programmierung (LP) oder Mixed-Integer LP (MILP)
- Indikatoren: Transport- und Netzwerkflüsse, klassische Produktionsplanung, Mischungsprobleme, Rucksackprobleme mit linearen Kosten.
- Wähle HiGHS bei Ganzzahligkeit (MILP), SciPy bei reinen kontinuierlichen LPs.
3. ZIEL: CVXPY
- Typ: Quadratische (QP) oder konvexe Optimierung (SOCP) – nur mit kontinuierlichen Variablen
- Indikatoren: Portfoliooptimierung (Varianz-/Kovarianzmatrizen, Markowitz), Risiko-Minimierung, quadratische Abweichungen, Entropie- oder geometrische Programmierung.
4. ZIEL: SCIP (pyscipopt)
- Typ: Mixed-Integer Quadratic Programming (MIQP) – quadratische bzw. nichtlineare Zielfunktion oder Restriktionen UND ganzzahlige/binäre Variablen
- Indikatoren: Portfolio mit Kardinalitätsbedingung (mindestens/höchstens k Titel), Mindest-/Höchstgewicht nur für ausgewählte Titel, fixe Transaktions- oder Auswahlkosten zusammen mit Varianz/Risiko, Standortwahl mit quadratischen Kosten, Losgrößen mit quadratischen Kosten.
- Sobald ein quadratischer Term und eine Ganzzahligkeit im selben Modell vorkommen, ist es MIQP/SCIP – nicht CVXPY (keine Ganzzahligkeit) und nicht CP-SAT (keine kontinuierlichen Variablen). [→ CVXPY_WITH_DISCRETE_VARS, CPSAT_WITH_CONTINUOUS_VARS]
5. ZIEL: Ipopt (cyipopt)
- Typ: Nonlinear Programming (NLP) – glatte nichtlineare Zielfunktion oder Restriktionen, ausschließlich stetige Variablen
- Indikatoren: Produktions- oder Mischungsprobleme mit nichtlinearen Ausbeute-, Verlust- oder Kostenfunktionen, Kurvenanpassung/Parameterschätzung (kleinste Quadrate mit nichtlinearem Modell), physikalische oder verfahrenstechnische Auslegung, Gleichgewichtsbedingungen.
- Ipopt liefert nur ein LOKALES Optimum. Verlangt die Aufgabe nachweislich das globale Optimum und ist das Problem nicht konvex, wähle stattdessen SCIP (globale Optimierung); mit ganzzahligen Variablen ohnehin SCIP. [→ IPOPT_WITH_DISCRETE_VARS]
6. ZIEL: NetworkX (Dijkstra)
- Typ: Kürzeste Wege im Graphen (Shortest Path)
- Indikatoren: kürzeste/billigste/schnellste Verbindung in einem Netz; Ersatz- und Erneuerungsprobleme („wann Maschine ersetzen?“ – Knoten sind Zeitpunkte, ein Bogen i→j heißt „Kauf am Ende von i, Nutzung bis Ende j“); Wegewahl in Verkehrs- oder Leitungsnetzen; Aufgabenstellungen, die ausdrücklich Dijkstra, Bellman-Ford oder „Kürzeste-Wege-Problem“ nennen.
- Verlangt die Aufgabe ausdrücklich ein bestimmtes Graphverfahren (typisch: „Lösen Sie mit dem Dijkstra-Algorithmus“), ist dieser Stack zu wählen – ein gleichwertiges Flussmodell erfüllt die Aufgabenstellung nicht. [→ REQUESTED_METHOD_IGNORED]
- VORAUSSETZUNG: alle Bogengewichte sind nicht negativ. Gibt es negative Gewichte (Erlöse, Gutschriften), ist Dijkstra NICHT anwendbar – dann Flussmodell als MILP über HiGHS wählen und das in routing_rationale begründen. [→ check_graph]
- Die Entscheidungsvariablen sind die Bögen des Weges; deklariere sie als binäre \(x_{ij}\), auch wenn der Algorithmus selbst ohne sie auskommt.
GRÖSSENORDNUNG: Schätze aus dem Produkt der Indexmengen, wie viele Variablen das Modell bekommt, und nenne die Zahl in routing_rationale. Praktische Grenzen: LP ~10.000 Variablen, MILP/CP ~100–1.000 diskrete (NP-schwer), QP/SOCP ~1.000 stetige, MIQP/NLP ~100; kürzeste Wege sind auch bei \(10^5\) Knoten unkritisch. Über der Grenze bleibt der fachlich richtige Stack – die Folge („Optimalität im Zeitlimit voraussichtlich nicht beweisbar“) gehört als Annahme ausgewiesen, nicht das Modell vereinfacht.
Anforderungen an die Ausgabe:
- Formelfelder (latex_formula, symbol, name, domain) enthalten reines LaTeX ohne Begrenzer und ohne Markdown. In name steht nur das Symbol, das auch in den Formeln vorkommt („a“, „x_{i,j}“), nie eine Bezeichnung wie „Asymptotischer Verschleiß“ – die gehört in description. Jedes Symbol aus Zielfunktion und Restriktionen muss als Variable, Parameter oder Index auffindbar sein. [→ UNDECLARED_SYMBOL]
- In Textfeldern steht jede Formel in \( … \), z. B. „Anteil \(w_i\) je Fonds \(i \in I\)“; keine $-Zeichen, % in Formeln als \%.
- Liegt ein bereits erfasstes Verständnis bei, übernimm dessen Zahlen: JEDER Wert aus facts gehört in das extracted_values des zugehörigen Parameters (bei Matrizen und Vektoren die vollständige Werteliste, nicht nur ein Beispiel). Ein Modell ohne Zahlen lässt sich nicht nachrechnen. [→ UNUSED_FACTS]
- Jeder Parameter nennt im Feld unit seine Einheit („€“, „€/t“, „h“, „Stück“, „%“, „dimensionslos“), und alle Terme der Zielfunktion müssen dieselbe haben. Mischt die Aufgabe relative mit absoluten Größen, rechne um – Risiko \(\sigma^2 w^2\) mit dem Kapital \(B\) multiplizieren, wenn Kosten in Euro stehen – und weise die gewählte Einheit als Annahme aus. [→ MIXED_OBJECTIVE_UNITS]
- Die Zielfunktion muss genau die im Text gefragte Größe in deren Maßstab liefern, nicht nur eine Größe mit derselben optimalen Lösung (z. B. Standardabweichung/Volatilität ≠ Varianz, Gewinn nach Fixkosten ≠ Deckungsbeitrag).
MODELLGRENZEN (eine Lösung muss in der Wirklichkeit möglich sein):
- Jede Variable bekommt einen Definitionsbereich, den die Wirklichkeit zulässt: Mengen, Zeiten, Stückzahlen und Kapital nicht negativ, Anteile in \([0, 1]\), Zeiten durch die verfügbare Zeit begrenzt.
- Prüfe vor der Ausgabe jede Variable, die die Zielfunktion verbessert: Was begrenzt sie nach oben? Fehlt die Grenze im Text, leite sie aus den Daten ab (Budget/Preis, Kapazität, Bedarf, Horizont) und weise sie als Annahme aus – eine Lösung darf nicht nur deshalb beliebig groß werden, weil eine Selbstverständlichkeit fehlt.
ERKLÄREN (ebenso wichtig wie die Richtigkeit des Modells – der Report soll jemanden überzeugen, der die Aufgabe nicht kennt):
- Adressat ist ein Entscheider ohne OR-Ausbildung. Schreibe wie in einem Praxishandbuch: kurze Sätze, Fachbegriff erst erklären, dann verwenden.
- JEDER Fachbegriff, der später in Restriktionsnamen, Variablennamen oder Ergebnistabellen auftaucht (Bogen, Knoten, Fluss, Relaxation, Schlupf, Kardinalität, Epigraph …), wird in executive_summary in der Sprache der Aufgabe eingeführt – z. B. „ein Bogen \(i \to j\) bedeutet: Kauf am Ende von Jahr \(i\) und Nutzung bis Ende Jahr \(j\)“. Ein Begriff, der nirgends erklärt wird, gilt als Fehler. [→ UNEXPLAINED_JARGON]
- executive_summary beantwortet in dieser Reihenfolge: (1) Welche Entscheidung ist zu treffen? (2) Wie bildet das Modell sie ab, in Alltagssprache? (3) Woran erkennt man eine gute Lösung? Keine Wiederholung des Aufgabentexts, keine Formelwüste.
- Verlangt die Aufgabe eine Zeichnung, eine Skizze oder einen Graphen, beschreibe die Struktur so, dass sie sich zeichnen lässt (Knoten, Bögen, Richtung, Beschriftung) – der Report zeichnet sie daraus.
- description bei Variablen, Parametern und Restriktionen sagt, WAS die Größe fachlich bedeutet, nicht was die Formel syntaktisch tut. Schlecht: „Summe über alle j“. Gut: „Die Maschine wird in genau einem Jahr ersetzt“.
- Nennt der Text keine Bedeutung, erfinde keine – erkläre mathematisch (Mindest-/Höchstbedingungen) und sage, dass die Bedeutung fehlt.
Umgang mit Unklarheiten:
- Erfinde keine Informationen stillschweigend. Ist die Aufgabe unvollständig oder mehrdeutig (z. B. fehlende Kostenwerte, unklarer Planungshorizont, mehrdeutige Begriffe wie „aufeinanderfolgend“), triff eine plausible Annahme und weise sie im Feld open_assumptions aus.
- Jede Annahme nennt: die unklare Textstelle, die getroffene Annahme, die verworfene zweite Lesart (alternative) und ihre Auswirkung auf das Modell.
- ZWEI LESARTEN: Formuliere bei jeder Unklarheit erst beide Deutungen aus, entscheide dich dann. In alternative steht die Deutung, der du NICHT folgst, als vollständiger Satz mit dem Grund der Ablehnung – z. B. „‚aufeinanderfolgend‘ könnte auch nur Kalendertage ohne Wochenenden meinen; verworfen, weil der Text von durchgehendem Betrieb spricht“. Ist eine Stelle wirklich eindeutig, gehört sie nicht in open_assumptions; nur wenn du sie trotzdem ausweist, schreibe „keine zweite Lesart“.
- Suche Mehrdeutigkeiten aktiv, statt die erste Deutung zu übernehmen: Bezugsgröße (pro Stück, pro Charge, gesamt), Zeitbezug (je Tag, je Periode, kumuliert), Einschluss der Grenze (mindestens/höchstens vs. mehr/weniger als), Brutto oder Netto, ganze Einheiten oder teilbare Mengen.
- Modelliere jede Restriktion der Aufgabe auf Basis der getroffenen Annahme vollständig. Lasse keine Restriktion weg und verweise nicht nur in einer Beschreibung darauf, dass sie „nicht modelliert“ sei.
- Ist die Aufgabe eindeutig, bleibt open_assumptions leer.

0
modeling/__init__.py Normal file
View file

68
modeling/extraction.py Normal file
View file

@ -0,0 +1,68 @@
"""Stufen 2 und 5 der Pipeline: Modellspezifikation aus dem Text extrahieren und nach der Prüfung korrigieren."""
from pathlib import Path
from typing import List
from core.llm import LLMSession
from core.model_schema import (
AssumptionReview,
CheckIssue,
CheckSeverity,
OptimizationModelSpecification,
TaskUnderstanding,
)
ROUTING_PROMPT_FILE = Path(__file__).parent / "Routing-und-Extraktions-Prompt.txt"
def text_to_model(session: LLMSession, task: str,
understanding: TaskUnderstanding | None = None,
data_prompt: str = "") -> OptimizationModelSpecification:
inhalt = task if not data_prompt else f"{task}\n\n{data_prompt}"
if understanding is not None:
# Die Verstehensstufe hat Teilfragen und Zahlen schon erfasst – die Extraktion muss den Text
# nicht noch einmal danach absuchen, sondern baut darauf auf.
inhalt += ("\n\nBEREITS ERFASSTES VERSTÄNDNIS (Teilfragen und Zahlen; übernimm die Werte aus facts "
"in extracted_values, statt sie neu zu suchen):\n" + understanding.model_dump_json(indent=2))
return session.create(
OptimizationModelSpecification,
[
{"role": "system", "content": ROUTING_PROMPT_FILE.read_text(encoding="utf-8")},
{"role": "user", "content": inhalt},
],
step="Extraktion",
)
def revise_model(
session: LLMSession, task: str, spec: OptimizationModelSpecification, review: AssumptionReview,
issues: List[CheckIssue] = (),
) -> OptimizationModelSpecification:
errors = [i for i in issues if i.severity == CheckSeverity.ERROR]
check_text = "\n".join(f"- [{i.code}] {i.message}" for i in errors) or "keine"
# Warnungen lösen für sich keine Korrekturrunde aus; findet aber ohnehin eine statt,
# sollen sie mit behoben werden – sonst erreicht z. B. unerklärter Fachjargon das LLM nie.
hinweise = [i for i in issues if i.severity == CheckSeverity.WARNING]
hinweis_text = "\n".join(f"- [{i.code}] {i.message}" for i in hinweise) or "keine"
return session.create(
OptimizationModelSpecification,
[
{"role": "system", "content": ROUTING_PROMPT_FILE.read_text(encoding="utf-8")},
{
"role": "user",
"content": (
f"AUFGABENTEXT:\n{task}\n\n"
f"BISHERIGE MODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}\n\n"
f"PRÜFBERICHT:\n{review.model_dump_json(indent=2)}\n\n"
f"FEHLER DER AUTOMATISCHEN KONSISTENZPRÜFUNG:\n{check_text}\n\n"
f"HINWEISE DER AUTOMATISCHEN PRÜFUNG (kein Fehler, aber zu verbessern):\n{hinweis_text}\n\n"
"Überarbeite die Modellspezifikation: Setze alle Korrekturen aus dem Prüfbericht um, "
"behebe alle Fehler der Konsistenzprüfung, passe die betroffenen Restriktionen, Variablen "
"und Parameter an und weise fehlende Annahmen in open_assumptions aus. "
"Arbeite auch die Hinweise ab, soweit das Modell dadurch nicht schlechter wird – "
"unerklärte Fachbegriffe gehören in executive_summary, nicht weggelassen. "
"Behalte korrekte Teile unverändert bei."
),
},
],
step="Korrektur",
)

3
pyproject.toml Normal file
View file

@ -0,0 +1,3 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = [".", "tests"]

0
reporting/__init__.py Normal file
View file

188
reporting/charts.py Normal file
View file

@ -0,0 +1,188 @@
"""Inline-SVG-Diagramme für den HTML-Report – ohne externe Bibliotheken, aus den geprüften Zahlen der Ergebnistabellen.
Formen (siehe dataviz-Richtlinie): Magnitudenvergleich -> waagerechte Balken; Zerlegung eines Zielwerts
in Zu-/Abschläge -> Wasserfall. Farben über CSS-Rollen (--viz-*), damit Hell/Dunkel in einer Stelle umschaltet:
divergierendes Paar Blau/Rot (validiert), Gesamtwert in neutraler Textfarbe.
"""
import html
BAR_HEIGHT = 22 # <= 24px, Balken füllt das Band nicht ganz
BAR_GAP = 10 # Luft zwischen Balken (>= 2px Surface-Gap)
LABEL_WIDTH = 150
VALUE_WIDTH = 92
PADDING = 12
RADIUS = 4 # abgerundetes Datenende
def _fmt(value: float, unit: str = "") -> str:
text = f"{value:,.6g}".replace(",", " ")
return f"{text}{unit}"
def _text(x, y, content, role="ink", anchor="start", size=12, weight="normal"):
return (f'<text x="{x:.1f}" y="{y:.1f}" text-anchor="{anchor}" font-size="{size}" '
f'font-weight="{weight}" fill="var(--viz-{role})">{html.escape(str(content))}</text>')
def _bar(x, y, width, height, color_role, negative=False):
"""Balken mit abgerundetem Datenende und eckigem Ende an der Nulllinie."""
width = max(width, 1.0)
r = min(RADIUS, width / 2)
if negative: # wächst nach links -> linkes Ende gerundet
path = (f"M{x + width:.1f},{y:.1f} H{x + r:.1f} Q{x:.1f},{y:.1f} {x:.1f},{y + r:.1f} "
f"V{y + height - r:.1f} Q{x:.1f},{y + height:.1f} {x + r:.1f},{y + height:.1f} "
f"H{x + width:.1f} Z")
else:
path = (f"M{x:.1f},{y:.1f} H{x + width - r:.1f} Q{x + width:.1f},{y:.1f} {x + width:.1f},{y + r:.1f} "
f"V{y + height - r:.1f} Q{x + width:.1f},{y + height:.1f} {x + width - r:.1f},{y + height:.1f} "
f"H{x:.1f} Z")
return f'<path d="{path}" fill="var(--viz-{color_role})"/>'
def _frame(width, height, title, body) -> str:
return (f'<svg class="viz" viewBox="0 0 {width} {height:.0f}" width="100%" height="{height:.0f}" '
f'role="img" aria-label="{html.escape(title)}" xmlns="http://www.w3.org/2000/svg">'
f"{body}</svg>")
def bar_chart(labels, values, title, unit="") -> str:
"""Waagerechte Balken für Beiträge je Objekt; negative Werte links der Nulllinie (Blau/Rot)."""
if not labels:
return ""
width = 720
plot_x = LABEL_WIDTH + PADDING
plot_width = width - plot_x - VALUE_WIDTH - PADDING
height = PADDING * 2 + len(labels) * (BAR_HEIGHT + BAR_GAP)
low, high = min(0.0, min(values)), max(0.0, max(values))
span = (high - low) or 1.0
zero_x = plot_x + plot_width * (0 - low) / span
parts = [f'<line x1="{zero_x:.1f}" y1="{PADDING - 4}" x2="{zero_x:.1f}" y2="{height - PADDING + 4}" '
f'stroke="var(--viz-grid)" stroke-width="1"/>']
for n, (label, value) in enumerate(zip(labels, values)):
y = PADDING + n * (BAR_HEIGHT + BAR_GAP)
length = plot_width * abs(value) / span
negative = value < 0
x = zero_x - length if negative else zero_x
parts.append(f"<g><title>{html.escape(f'{label}: {_fmt(value, unit)}')}</title>")
parts.append(_bar(x, y, length, BAR_HEIGHT, "neg" if negative else "pos", negative))
parts.append("</g>")
parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, label, "muted", anchor="end"))
value_x = x - 6 if negative else x + length + 6
parts.append(_text(value_x, y + BAR_HEIGHT * 0.72, _fmt(value, unit), "ink",
anchor="end" if negative else "start"))
return _frame(width, height, title, "".join(parts))
def waterfall_chart(labels, deltas, total_label, total_value, title, unit="") -> str:
"""Zielwert-Zerlegung: Zu- und Abschläge als Stufen, Gesamtwert als neutraler Balken ab Null."""
if not labels:
return ""
width = 720
plot_x = LABEL_WIDTH + PADDING
plot_width = width - plot_x - VALUE_WIDTH - PADDING
rows = len(labels) + 1
height = PADDING * 2 + rows * (BAR_HEIGHT + BAR_GAP)
running, levels = 0.0, []
for delta in deltas:
levels.append((running, running + delta))
running += delta
bounds = [v for pair in levels for v in pair] + [0.0, total_value]
low, high = min(bounds), max(bounds)
span = (high - low) or 1.0
scale = lambda v: plot_x + plot_width * (v - low) / span
parts = [f'<line x1="{scale(0):.1f}" y1="{PADDING - 4}" x2="{scale(0):.1f}" y2="{height - PADDING + 4}" '
f'stroke="var(--viz-grid)" stroke-width="1"/>']
for n, (label, delta, (start, end)) in enumerate(zip(labels, deltas, levels)):
y = PADDING + n * (BAR_HEIGHT + BAR_GAP)
x0, x1 = sorted((scale(start), scale(end)))
negative = delta < 0
parts.append(f"<g><title>{html.escape(f'{label}: {_fmt(delta, unit)}')}</title>")
parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "neg" if negative else "pos", negative))
parts.append("</g>")
parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, label, "muted", anchor="end"))
parts.append(_text(x1 + 6, y + BAR_HEIGHT * 0.72, _fmt(delta, unit), "ink"))
if n + 1 < len(labels): # Verbindungslinie zur nächsten Stufe
next_y = y + BAR_HEIGHT + BAR_GAP
parts.append(f'<line x1="{scale(end):.1f}" y1="{y + BAR_HEIGHT:.1f}" x2="{scale(end):.1f}" '
f'y2="{next_y:.1f}" stroke="var(--viz-grid)" stroke-width="1" stroke-dasharray="2 2"/>')
y = PADDING + len(labels) * (BAR_HEIGHT + BAR_GAP)
x0, x1 = sorted((scale(0), scale(total_value)))
parts.append(f"<g><title>{html.escape(f'{total_label}: {_fmt(total_value, unit)}')}</title>")
parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "total", total_value < 0))
parts.append("</g>")
parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, total_label, "ink", anchor="end", weight="600"))
parts.append(_text(x1 + 6, y + BAR_HEIGHT * 0.72, _fmt(total_value, unit), "ink", weight="600"))
return _frame(width, height, title, "".join(parts))
# --- Graphenzeichnung (Knoten und Bögen) ---
NODE_RADIUS = 17
ARC_BOW = 34 # Wölbung je Spannweite, damit parallele Bögen sich nicht überdecken
GRAPH_PADDING = 26
def _arrow_marker(name: str, role: str) -> str:
return (f'<marker id="{name}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" '
f'orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--viz-{role})"/></marker>')
def graph_chart(nodes, arcs, path=(), title="Graph", unit="") -> str:
"""Knoten-Bogen-Diagramm mit hervorgehobenem Weg.
nodes: Knotennamen in Reihenfolge, arcs: (von, nach, gewicht), path: Knotenfolge des Weges.
Knoten liegen auf einer Waagerechten, Bögen wölben sich darüber (Rückwärtsbögen darunter);
die Wölbung wächst mit der Spannweite, damit sich parallele Bögen nicht überdecken.
"""
if not nodes or not arcs or len(nodes) > 14:
return ""
index = {name: i for i, name in enumerate(nodes)}
if any(a[0] not in index or a[1] not in index for a in arcs):
return ""
weg = {(a, b) for a, b in zip(path, path[1:])}
step = max(96, min(150, 820 // max(len(nodes) - 1, 1)))
breite = 2 * GRAPH_PADDING + step * (len(nodes) - 1) + 2 * NODE_RADIUS
spannen_oben = [abs(index[b] - index[a]) for a, b, _ in arcs if index[b] > index[a]]
spannen_unten = [abs(index[b] - index[a]) for a, b, _ in arcs if index[b] < index[a]]
oben = ARC_BOW * max(spannen_oben, default=0) / 2 + 30
unten = ARC_BOW * max(spannen_unten, default=0) / 2 + 30
mitte = GRAPH_PADDING + oben
hoehe = mitte + unten + GRAPH_PADDING
def x(name):
return GRAPH_PADDING + NODE_RADIUS + step * index[name]
teile = [f"<defs>{_arrow_marker('pfeil', 'grid')}{_arrow_marker('pfeil-weg', 'pos')}</defs>"]
for start, ziel, gewicht in arcs:
auf_weg = (start, ziel) in weg
rolle, marker = ("pos", "pfeil-weg") if auf_weg else ("grid", "pfeil")
x1, x2 = x(start), x(ziel)
rueckwaerts = index[ziel] < index[start]
spanne = abs(index[ziel] - index[start])
bogen = ARC_BOW * spanne * (1 if rueckwaerts else -1)
# Kreisrand statt Mittelpunkt als Anfang/Ende, damit die Pfeilspitze sichtbar bleibt
richtung = 1 if x2 > x1 else -1
x1 += richtung * NODE_RADIUS
x2 -= richtung * NODE_RADIUS
steuer_y = mitte + bogen
teile.append(f'<path d="M{x1:.1f},{mitte:.1f} Q{(x1 + x2) / 2:.1f},{steuer_y:.1f} {x2:.1f},{mitte:.1f}" '
f'fill="none" stroke="var(--viz-{rolle})" stroke-width="{3 if auf_weg else 1.5}" '
f'marker-end="url(#{marker})"/>')
teile.append(_text((x1 + x2) / 2, mitte + bogen / 2 + (20 if rueckwaerts else -10), _fmt(gewicht, unit),
role=rolle if auf_weg else "ink", anchor="middle", size=12,
weight="bold" if auf_weg else "normal"))
for name in nodes:
auf_weg = name in set(path)
teile.append(f'<circle cx="{x(name):.1f}" cy="{mitte:.1f}" r="{NODE_RADIUS}" '
f'fill="var(--viz-surface)" stroke="var(--viz-{"pos" if auf_weg else "grid"})" '
f'stroke-width="{3 if auf_weg else 1.5}"/>')
teile.append(_text(x(name), mitte + 4, name, anchor="middle", size=13,
weight="bold" if auf_weg else "normal"))
return _frame(breite, hoehe, title, "".join(teile))

635
reporting/report.py Normal file
View file

@ -0,0 +1,635 @@
"""Report eines PipelineResult als Markdown (.md) oder HTML (.html) mit gerenderten Formeln und Solver-Code.
Aufbau: build_document() erzeugt ein formatneutrales Dokument (Liste von Blöcken),
to_markdown() / to_html() geben es aus. Formeln: Markdown mit $…$ / $$…$$ (z. B. VS Code-Vorschau),
HTML mit MathJax (lädt aus dem Netz).
"""
import html
import re
from datetime import datetime
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
class Math(str):
"""LaTeX-Formel (inline in Tabellen, sonst abgesetzt)."""
class Code(str):
"""Monospace-Text."""
def _clean_latex(latex: str) -> str:
# LLM liefert Formeln teils mit eigenen Begrenzern ($…$, \(…\), \[…\])
latex = latex.strip()
for left, right in (("$$", "$$"), ("$", "$"), (r"\[", r"\]"), (r"\(", r"\)")):
if latex.startswith(left) and latex.endswith(right) and len(latex) > len(left) + len(right):
latex = latex[len(left):-len(right)].strip()
return latex
# --- Formeln im Fließtext erkennen ---
# LLM-Texte enthalten Formeln mit \(…\), $…$, \begin{…}…\end{…} oder ganz ohne Begrenzer (häufigster Fall).
_EXPLICIT_MATH = re.compile(
r"\\\((?P<paren>.+?)\\\)"
r"|\\\[(?P<bracket>.+?)\\\]"
r"|\$\$(?P<ddollar>.+?)\$\$"
r"|\$(?P<dollar>[^$\n]+?)\$"
r"|(?P<env>\\begin\{(?P<envname>[A-Za-z*]+)\}.*?\\end\{(?P=envname)\})",
re.S,
)
_MATHY = re.compile(r"[\\_^{}=<>]")
# Wort ist Formel: LaTeX-Befehl, \{ \}, x_{..}/x^{..}, kurzes Symbol mit Index (r_A, x_ij, U_i)
_MATH_WORD = re.compile(r"\\[A-Za-z{}|]|[_^]\{|^[(\[]?[A-Za-z]{1,3}[_^][A-Za-z0-9]{1,4}\b")
# Füllwort innerhalb einer Formel: Operator, Zahl, einzelner Buchstabe
_GLUE_WORD = re.compile(r"[=<>≤≥+\-−*/·,:|()\[\]]+|[<>]=|[-+]?\d+(?:[.,]\d+)?%?|[A-Za-z]")
def _brace_code(word: str) -> str:
# r_min -> r_{min}: in LaTeX würde sonst nur der erste Buchstabe tiefgestellt; % leitet in TeX einen Kommentar ein
word = re.sub(r"(?<!\\)([_^])([A-Za-z0-9]{2,})", r"\1{\2}", word)
return re.sub(r"(?<!\\)%", r"\\%", word)
def _split_bare(text: str) -> list:
words = re.split(r"(\s+)", text)
tokens = words[0::2]
is_math = [bool(_MATH_WORD.search(t)) for t in tokens]
is_glue = [bool(_GLUE_WORD.fullmatch(t.strip(".,;:"))) for t in tokens]
parts, i = [], 0
while i < len(tokens):
if not is_math[i]:
parts.append((False, tokens[i]))
i += 1
continue
start = i
while start > 0 and is_glue[start - 1] and not is_math[start - 1] and parts and not parts[-1][0]:
start -= 1 # vorangehende Füllwörter (z. B. "i" in "i \in I") gehören zur Formel
parts.pop()
j = i + 1
while j < len(tokens) and (is_math[j] or is_glue[j]):
j += 1 # Formelwörter und Füllwörter (Operatoren, Zahlen: "U_i = 0.40") gehören dazu
end = j - 1
run = " ".join(tokens[start:end + 1])
while run.count("{") > run.count("}") and end + 1 < len(tokens):
end += 1
run += " " + tokens[end]
stripped = run.rstrip(".,;:")
tail = run[len(stripped):]
parts.append((True, _brace_code(stripped)))
if tail:
parts.append((False, tail))
i = end + 1
# Wörter wieder mit einfachen Leerzeichen verbinden
merged = []
for math, value in parts:
if merged and not (not math and value[:1] in ".,;:" and merged[-1][0]):
merged.append((False, " "))
merged.append((math, value))
return _join_text(merged)
def _join_text(parts: list) -> list:
result = []
for math, value in parts:
if result and not math and not result[-1][0]:
result[-1] = (False, result[-1][1] + value)
else:
result.append((math, value))
return result
def split_math(text: str) -> list:
"""Text in [(is_math, str), …] zerlegen; Formelteile ohne Begrenzer."""
parts, pos = [], 0
for match in _EXPLICIT_MATH.finditer(text):
if match.group("dollar") is not None and not _MATHY.search(match.group("dollar")) \
and not re.fullmatch(r"\s*[A-Za-z]\s*", match.group("dollar")):
continue # z. B. Geldbetrag "$5 und $7"
if match.start() > pos:
parts += _split_bare(text[pos:match.start()])
latex = next(match.group(g) for g in ("paren", "bracket", "ddollar", "dollar", "env") if match.group(g) is not None)
parts.append((True, latex.strip()))
pos = match.end()
if pos < len(text):
parts += _split_bare(text[pos:])
return _join_text(parts)
def _graph_section(run) -> list:
"""Zeichnung des Graphen mit hervorgehobenem Weg, darüber die Bedeutung von Knoten und Bogen."""
g = run.graph
if g is None:
return []
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")
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)
+ f" mit der Länge {summanden} = {_fmt_number(run.objective_value)}."))
if g.steps:
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.")]
return doc
def _result_section(result) -> list:
"""Ergebnis zuerst: Antwortsatz, Kennzahlen, verständliche Tabellen mit Diagramm, dann Rohwerte."""
run, spec = result.solver_run, result.specification
if run is None:
return []
doc = [("h2", "Ergebnis")]
if run.headline:
doc.append(("lead", run.headline))
# Rückprojektion auf die gestellte Aufgabe: die Antworten stehen vor allen Kennzahlen
fragen = {q.id: q for q in (result.understanding.sub_questions if result.understanding else [])}
if run.answers:
doc.append(("h3", "Antworten auf die Aufgabe"))
for antwort in run.answers:
frage = fragen.get(antwort.question_id)
doc.append(("h4", f"{antwort.question_id}) {frage.quote}" if frage else str(antwort.question_id)))
doc.append(("p", antwort.text))
doc.append(("table", ["Status", "Zielwert", "Versuche"],
[[run.status.value, "–" if run.objective_value is None else _fmt_number(run.objective_value), str(run.attempts)]]))
if run.error:
doc.append(("pre", run.error))
if run.status == SolverStatus.INFEASIBLE and result.infeasible_confirmed:
doc.append(("p", "Die Unlösbarkeit wurde durch eine zweite, unabhängige Modellierung bestätigt."))
for problem in run.feasibility:
doc.append(("warn", f"Zulässigkeitsprüfung: {problem}"))
for warning in run.warnings:
doc.append(("warn", warning))
if run.constraints and not run.feasibility:
doc.append(("p", f"Zulässigkeit deterministisch geprüft: {len(run.constraints)} Restriktionen und "
f"{len(run.variables)} Variablen wurden aus der Lösung nachgerechnet und eingehalten."))
# Erst erklären, dann Zahlen: die Fachbegriffe der Tabellen stehen sonst vor ihrer Erklärung
doc += [("h3", "Wie das Modell die Aufgabe sieht"), ("p", spec.executive_summary)]
doc += _graph_section(run)
for table in run.tables:
doc += [("h3", table.title),
("table", table.columns, [[_fmt_cell(c) for c in row] for row in table.rows])]
chart = _chart_for_table(table)
if chart:
doc.append(("svg", chart))
if table.note:
doc.append(("p", table.note))
if run.scenarios:
rows = [[s.name, s.description, ", ".join(f"{k} = {v}" for k, v in s.data_override.items()),
_fmt_number(s.objective_value) if s.objective_value is not None else "–",
_fmt_number(s.verified_value) if s.verified_value is not None else "–"]
for s in run.scenarios]
doc += [("h3", "Verglichene Konfigurationen"),
("table", ["Konfiguration", "Unterschied", "Geänderte Daten", "Zielwert", "Nachgerechnet"], rows),
("p", "Jede Konfiguration ist dieselbe Rechnung mit geänderten Eingabedaten. Die Spalte "
"„Nachgerechnet“ entsteht durch erneutes Ausführen des Programms – ohne LLM.")]
doc += _sensitivity_section(run)
if result.solver_code and result.solver_code.data_assumptions:
doc += [("h3", "Angenommene Daten"), ("list", result.solver_code.data_assumptions)]
if run.solution:
doc.append(("h3", "Alle Variablenwerte (≠ 0)"))
for base, description, entries in _group_solution(run.solution, spec):
total = sum(v for _, v in entries if isinstance(v, (int, float)) and not isinstance(v, bool))
rows = [[Code(k), _fmt_number(v)] for k, v in entries] + [["Summe", _fmt_number(total)]]
doc += [("h4", f"{base} – {description}" if description else base), ("table", ["Variable", "Wert"], rows)]
return doc
def _sensitivity_section(run) -> list:
"""Was bringt es, eine bindende Grenze zu verschieben? (aus Neulösungen des Programms)"""
doc = []
if run.sensitivity:
by_name = {c.name: c for c in run.constraints}
rows = []
for s in run.sensitivity:
constraint = by_name.get(s.name)
sense = constraint.sense if constraint else ""
gelockert = s.objective_plus if sense != ">=" else s.objective_minus
wirkung = ("–" if gelockert is None or run.objective_value is None
else _fmt_number((gelockert - run.objective_value) / s.delta))
rows.append([s.name, _fmt_number(s.delta),
"–" if s.objective_minus is None else _fmt_number(s.objective_minus),
"–" if s.objective_plus is None else _fmt_number(s.objective_plus),
wirkung + (f" {s.unit}" if s.unit else "")])
doc += [("h3", "Sensitivität der bindenden Restriktionen"),
("table", ["Restriktion", "Verschiebung δ", "Zielwert bei rhs−δ", "Zielwert bei rhs+δ",
"Wert einer Lockerung je Einheit"], rows),
("p", "Ermittelt durch Neulösen mit verschobener rechter Seite, nicht aus Solver-Attributen "
"abgelesen. „–“ bedeutet: in dieser Richtung unlösbar.")]
if run.relaxation_bound is not None and run.objective_value is not None:
luecke = abs(run.relaxation_bound - run.objective_value)
doc += [("h3", "Preis der Ganzzahligkeit"),
("p", f"Ohne Ganzzahligkeitsforderung wäre der Zielwert {_fmt_number(run.relaxation_bound)}; "
f"die ganzzahlige Lösung liegt um {_fmt_number(luecke)} daneben.")]
if run.multistart is not None:
m = run.multistart
stabil = m.stddev is not None and m.stddev <= 1e-6 * max(1.0, abs(m.best))
rows = [["Startpunkte", f"{m.starts}"], ["davon erfolgreich", f"{m.converged}"],
["bester Zielwert", _fmt_number(m.best)]]
rows += [[bezeichnung, _fmt_number(wert)] for bezeichnung, wert in
(("schlechtester Zielwert", m.worst), ("Mittelwert", m.mean), ("Standardabweichung", m.stddev))
if wert is not None]
doc += [("h3", "Stabilität über mehrere Startpunkte"),
("table", ["", ""], rows),
("p", "Das Verfahren findet nur lokale Optima. "
+ ("Alle erfolgreichen Startpunkte führten zum selben Wert – ein Hinweis (kein Beweis) "
"darauf, dass das Optimum global ist."
if stabil else
"Verschiedene Startpunkte führten zu verschiedenen Werten: das Ergebnis ist die beste "
"gefundene lokale Lösung, nicht nachweislich das globale Optimum."))]
return doc
def _chart_for_table(table):
"""Balken für Beiträge je Objekt, Wasserfall für die Zielwert-Zerlegung (nur bei numerischer Beitragsspalte)."""
if not table.objective_column or table.objective_column not in table.columns:
return ""
col = table.columns.index(table.objective_column)
labels, values, total = [], [], None
for row in table.rows:
value = row[col] if col < len(row) else None
if not isinstance(value, (int, float)) or isinstance(value, bool):
continue
label = str(row[0]) if row else ""
if _is_sum_row(row):
total = (label, value)
else:
labels.append(label)
values.append(float(value))
if len(values) < 2:
return ""
unit = table.unit or ""
if "zerlegung" in table.title.lower() or "bestandteil" in table.title.lower():
label, value = total or ("Zielwert", sum(values))
return charts.waterfall_chart(labels, values, label, float(value), table.title, unit)
return charts.bar_chart(labels, values, table.title, unit)
def _fmt_cell(value):
# Zahlen formatieren, Texte (evtl. mit Formeln) für split_math durchreichen
return _fmt_number(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else ("" if value is None else str(value))
def _variable_base(name: str) -> str:
"""'w[A02]' -> 'w', 'x_{p,d}' -> 'x', '\\alpha_i' -> 'alpha'"""
match = re.match(r"\\?([A-Za-z]+)", name.strip())
return match.group(1) if match else name
def _group_solution(solution: dict, spec) -> list:
"""Variablenwerte nach Basisname gruppieren, Bedeutung aus der Modellspezifikation."""
descriptions = {_variable_base(v.name): v.description for v in spec.variables}
groups: dict = {}
for name, value in solution.items():
groups.setdefault(_variable_base(name), []).append((name, value))
return [(base, descriptions.get(base, ""), entries) for base, entries in groups.items()]
def _fmt_number(value) -> str:
if isinstance(value, float):
return f"{value:.6g}"
return str(value)
def build_document(result: PipelineResult, model: str = "") -> list:
spec, review = result.specification, result.review
doc = [("h1", "Optimierungsmodell")]
if not result.reliable:
doc.append(("warn", "Ergebnis unzuverlässig – bitte nicht verwenden. "
+ " ".join(f"({n}) {p}" for n, p in enumerate(result.reliability_issues, 1))
+ (" Der Solver wurde nicht gestartet." if result.solver_run is None else "")))
meta = [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}"]
if result.usage:
meta.append(f"Modell: {result.usage.model}")
cost = f", Kosten USD {result.usage.cost_usd:.4f}" if result.usage.cost_usd is not None else ""
meta.append(f"LLM: {result.usage.calls} Aufrufe, {result.usage.completion_tokens} Output-Tokens "
f"(davon {result.usage.reasoning_tokens} Reasoning){cost}")
elif model:
meta.append(f"Modell: {model}")
meta.append(f"Korrekturrunden: {result.revisions}, verwendet: Runde {result.selected_revision}"
+ (f", Neustarts: {result.restarts}" if result.restarts else ""))
if result.usage and result.usage.providers:
meta.append("Anbieter: " + ", ".join(f"{name} ({n}×)" for name, n in result.usage.providers.items()))
doc.append(("list", meta))
doc += _result_section(result)
if result.task.strip():
doc += [("h2", "Aufgabe"), ("pre", result.task.strip())]
if result.data_files:
doc += [("h2", "Beigelegte Daten")]
for datei in result.data_files:
if datei.error:
doc += [("h3", datei.name), ("warn", f"Nicht lesbar: {datei.error}")]
continue
doc += [("h3", f"{datei.name} ({datei.rows} Zeilen)"),
("table", ["Spalte", "Typ", "Kleinster", "Größter", "Mittel", "Fehlend", "Beispiele"],
[[s.name, s.dtype,
_fmt_number(s.minimum) if s.minimum is not None else "–",
_fmt_number(s.maximum) if s.maximum is not None else "–",
_fmt_number(s.mean) if s.mean is not None else "–",
str(s.missing), ", ".join(s.samples)] for s in datei.columns])]
doc += [("p", "Gerechnet, nicht geschätzt: Zeilen, Spannweiten und Lücken stammen aus der Datei selbst.")]
if result.understanding is not None:
u = result.understanding
doc += [("h2", "Was in der Aufgabe steht")]
if u.sub_questions:
doc += [("h3", "Gestellte Fragen"),
("table", ["Nr.", "Frage", "Verlangt", "Rechnung nötig"],
[[q.id, q.quote, q.kind, "ja" if q.needs_solver else "nein"] for q in u.sub_questions])]
if u.facts:
doc += [("h3", "Gegebene Zahlen"),
("table", ["Wert", "Einheit", "Bedeutung", "Fundstelle"],
[[f.value, f.unit, f.meaning, f.quote] for f in u.facts]),
("p", "Aus dem Aufgabentext übernommen, nicht gerechnet. Jede Zahl des Textes muss hier "
"oder als bewusst übergangene Zahl auftauchen – das wird maschinell geprüft.")]
if u.ignored_numbers:
doc += [("h3", "Übergangene Zahlen"),
("table", ["Wert", "Grund"], [[n.value, n.reason] for n in u.ignored_numbers])]
doc += [
("h2", "Einordnung"),
("table", ["Problemklasse", "Solver-Stack", "Exportformat"],
[[spec.problem_class.value, spec.target_stack.value, spec.suggested_export_format]]),
("p", spec.routing_rationale),
("h2", "Mathematisches Modell"),
("h3", "Mengen und Indizes"),
("list", list(spec.indices)), # Formeln erkennt split_math (mit/ohne Begrenzer)
("h3", "Parameter"),
("table", ["Symbol", "Dimension", "Einheit", "Bedeutung", "Werte"],
[[Math(_clean_latex(p.symbol)), p.dimension, p.unit, p.description, p.extracted_values or "–"]
for p in spec.parameters]),
("h3", "Entscheidungsvariablen"),
("table", ["Variable", "Typ", "Bereich", "Bedeutung"],
[[Math(_clean_latex(v.name)), v.var_type.value, Math(_clean_latex(v.domain)), v.description] for v in spec.variables]),
("h3", f"Zielfunktion ({spec.objective.sense.value})"),
("math", _clean_latex(spec.objective.latex_formula)),
("p", spec.objective.description),
("h3", "Restriktionen"),
]
for c in spec.constraints:
label = c.identifier + ("" if c.is_hard_constraint else " (weich)")
doc += [("h4", label), ("math", _clean_latex(c.latex_formula)), ("p", c.description)]
if spec.open_assumptions:
doc += [("h3", "Offene Annahmen"),
("table", ["Unklare Stelle", "Annahme", "Verworfene Lesart", "Auswirkung"],
[[a.unclear_point, a.assumption, a.alternative, a.impact] for a in spec.open_assumptions])]
oc = review.objective_check
doc += [
("h2", "Prüfbericht"),
("h3", "Zielgröße"),
("table", ["", ""], [
["Gefragt (Zitat)", oc.task_evidence],
["Gefragte Größe", oc.asked_quantity],
["Modellierte Größe", oc.modeled_quantity],
["Übereinstimmung", "ja" if oc.matches else "NEIN"],
["Begründung", oc.explanation],
] + ([["Korrektur", Math(_clean_latex(oc.correction))]] if oc.correction else [])),
]
if review.checks:
doc += [("h3", "Annahmen"),
("table", ["Stelle", "Urteil", "Beleg", "Begründung"],
[[c.unclear_point, c.verdict.value, c.task_evidence, c.explanation + (f" Korrektur: {c.correction}" if c.correction else "")]
for c in review.checks])]
if review.missing_assumptions:
doc += [("h3", "Nicht ausgewiesene Annahmen"),
("list", [f"{a.unclear_point}: {a.assumption}" for a in review.missing_assumptions])]
doc += [("h3", "Automatische Konsistenzprüfung"),
("list", [f"{i.severity.value.upper()} [{i.code}] {i.message}" for i in result.checks] or ["keine Auffälligkeiten"])]
if result.solver_code:
doc += [("h2", "Solver-Programm (Python)"),
("p", "Eigenständig lauffähig (python3 solve.py): Kopf mit Aufgabe, Modell und Algorithmus, "
"kommentierter Code mit main()."),
("code", "python", build_solver_script(result))]
return doc
# --- Markdown ---
def _md_inline(value, in_table: bool = False) -> str:
if isinstance(value, list):
return "".join(_md_inline(v, in_table) for v in value)
text = str(value).replace("\n", " ")
if isinstance(value, Math):
# | trennt Markdown-Tabellenspalten: Norm \| -> \Vert, Betrag | -> \vert
return f"${text.replace(chr(92) + '|', r'\Vert ').replace('|', r'\vert ')}$" if in_table else f"${text}$"
if isinstance(value, Code):
return f"`{text}`"
# Fließtext: erkannte Formeln als $…$, übriger Text unverändert
return "".join(
_md_inline(Math(part), in_table) if is_math else (part.replace("|", r"\|") if in_table else part)
for is_math, part in split_math(text)
)
def to_markdown(doc: list) -> str:
out = []
for block in doc:
kind = block[0]
if kind in ("h1", "h2", "h3", "h4"):
out.append("#" * int(kind[1]) + " " + block[1])
elif kind == "p":
out.append(_md_inline(block[1]))
elif kind == "warn":
out.append(f"> **⚠ {_md_inline(block[1])}**")
elif kind == "lead":
out.append(f"**{_md_inline(block[1])}**")
elif kind == "svg":
pass # Diagramme nur im HTML-Report
elif kind == "list":
out.append("\n".join(f"- {_md_inline(item)}" for item in block[1]))
elif kind == "math":
out.append(f"$$\n{block[1]}\n$$")
elif kind == "pre":
out.append(f"```text\n{block[1]}\n```")
elif kind == "code":
out.append(f"```{block[1]}\n{block[2]}\n```")
elif kind == "table":
headers, rows = block[1], block[2]
lines = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)]
lines += ["| " + " | ".join(_md_inline(cell, in_table=True) for cell in row) + " |" for row in rows]
out.append("\n".join(lines))
return "\n\n".join(out) + "\n"
# --- HTML ---
HTML_HEAD = """<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Optimierungsmodell</title>
<script>window.MathJax = {tex: {inlineMath: [['\\\\(', '\\\\)']]}};</script> <!-- kein $ als Begrenzer: Beträge wie $0.01 -->
<script defer src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>
<style>
:root { color-scheme: light; --surface: #fcfcfb; --ink: #0b0b0b; --muted: #52514e; --rule: #ddd; --head: #f4f4f6; --code-bg: #f6f8fa;
--viz-pos: #2a78d6; --viz-neg: #e34948; --viz-total: #52514e; --viz-ink: #0b0b0b; --viz-muted: #52514e; --viz-grid: #c9c8c4; --viz-surface: #ffffff; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
color-scheme: dark; --surface: #1a1a19; --ink: #ffffff; --muted: #c3c2b7; --rule: #3a3a38; --head: #262624; --code-bg: #222220;
--viz-pos: #3987e5; --viz-neg: #e66767; --viz-total: #c3c2b7; --viz-ink: #ffffff; --viz-muted: #c3c2b7; --viz-grid: #4a4a47; --viz-surface: #1b1b19; } }
body { font-family: system-ui, sans-serif; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5;
color: var(--ink); background: var(--surface); }
.lead { font-size: 1.15rem; font-weight: 600; margin: .6rem 0 1rem; }
figure.viz-root { margin: .2rem 0 1.4rem; }
h1, h2 { border-bottom: 1px solid var(--rule); padding-bottom: .2rem; }
h4 { margin-bottom: .2rem; }
table { border-collapse: collapse; margin: .5rem 0 1rem; width: 100%; }
th, td { border: 1px solid var(--rule); padding: .35rem .5rem; text-align: left; vertical-align: top; }
th { background: var(--head); }
pre { background: var(--code-bg); padding: .8rem; overflow-x: auto; border-radius: 4px; }
pre.task { white-space: pre-wrap; } /* Aufgabentext umbrechen, Code nicht */
code { font-family: ui-monospace, monospace; font-size: .9em; }
.math { overflow-x: auto; }
.warn { background: #fdecea; border: 2px solid #c62828; color: #7f1d1d; padding: .8rem 1rem; border-radius: 4px; font-weight: 600; margin: 1rem 0; }
.codeblock { position: relative; }
.codeblock pre { padding-top: 2.4rem; }
.copy-btn { position: absolute; top: .4rem; right: .4rem; display: inline-flex; align-items: center; gap: .3rem;
font: inherit; font-size: .8rem; padding: .25rem .5rem; border: 1px solid #ccc; border-radius: 4px;
background: var(--surface); color: var(--ink); cursor: pointer; }
.copy-btn:hover { background: #eef; }
.copy-btn svg { width: 1em; height: 1em; }
</style>
</head>
<body>
"""
COPY_ICON = (
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">'
'<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
)
# Kopieren per Clipboard-API; Rückfall über execCommand, falls die API (z. B. bei file://) nicht verfügbar ist
COPY_SCRIPT = """<script>
document.querySelectorAll('.copy-btn').forEach(function (button) {
button.addEventListener('click', function () {
var text = button.parentElement.querySelector('code').innerText;
var label = button.querySelector('span');
function done(ok) {
label.textContent = ok ? 'Kopiert \u2713' : 'Fehler';
setTimeout(function () { label.textContent = 'Kopieren'; }, 2000);
}
function fallback() {
var area = document.createElement('textarea');
area.value = text;
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(area);
done(ok);
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(function () { done(true); }, fallback);
} else {
fallback();
}
});
});
</script>"""
def _html_inline(value) -> str:
if isinstance(value, list):
return "".join(_html_inline(v) for v in value)
if isinstance(value, Math):
return f"\\({html.escape(str(value))}\\)"
if isinstance(value, Code):
return f"<code>{html.escape(str(value))}</code>"
# Fließtext: erkannte Formeln als \(…\) für MathJax, übriger Text escaped
return "".join(
f"\\({html.escape(part)}\\)" if is_math else html.escape(part) for is_math, part in split_math(str(value))
)
def to_html(doc: list) -> str:
out = [HTML_HEAD]
for block in doc:
kind = block[0]
if kind in ("h1", "h2", "h3", "h4"):
out.append(f"<{kind}>{html.escape(block[1])}</{kind}>")
elif kind == "p":
out.append(f"<p>{_html_inline(block[1])}</p>")
elif kind == "warn":
out.append(f'<div class="warn">⚠ {_html_inline(block[1])}</div>')
elif kind == "lead":
out.append(f'<p class="lead">{_html_inline(block[1])}</p>')
elif kind == "svg":
out.append(f'<figure class="viz-root">{block[1]}</figure>')
elif kind == "list":
out.append("<ul>" + "".join(f"<li>{_html_inline(item)}</li>" for item in block[1]) + "</ul>")
elif kind == "math":
out.append(f'<div class="math">\\[{html.escape(block[1])}\\]</div>')
elif kind == "pre":
out.append(f'<pre class="task">{html.escape(block[1])}</pre>')
elif kind == "code":
out.append(
'<div class="codeblock">'
f'<button class="copy-btn" type="button" title="In die Zwischenablage kopieren">{COPY_ICON}<span>Kopieren</span></button>'
f'<pre><code class="language-{block[1]}">{html.escape(block[2])}</code></pre></div>'
)
elif kind == "table":
headers, rows = block[1], block[2]
head = "".join(f"<th>{html.escape(h)}</th>" for h in headers) if any(headers) else ""
body = "".join("<tr>" + "".join(f"<td>{_html_inline(c)}</td>" for c in row) + "</tr>" for row in rows)
out.append(f"<table>{'<tr>' + head + '</tr>' if head else ''}{body}</table>")
out.append(COPY_SCRIPT)
out.append("</body>\n</html>\n")
return "\n".join(out)
def write_report(result: PipelineResult, path: str) -> None:
target = Path(path)
doc = build_document(result)
if target.suffix.lower() in (".html", ".htm"):
content = to_html(doc)
elif target.suffix.lower() == ".md":
content = to_markdown(doc)
else:
raise ValueError(f"Unbekanntes Report-Format '{target.suffix}' (erlaubt: .md, .html)")
target.write_text(content, encoding="utf-8")
if __name__ == "__main__":
import argparse
import json
parser = argparse.ArgumentParser(description="Report aus gespeichertem Ergebnis erzeugen (ohne LLM-Aufrufe)")
parser.add_argument("result_json", help="Ausgabe von TextToModel.py oder Datei aus benchmark/results/")
parser.add_argument("output", help="Zieldatei .md oder .html")
parser.add_argument("--case", help="Benchmark-Datei: Fallname")
parser.add_argument("--model", help="Benchmark-Datei: Modellname (Teilstring genügt)")
parser.add_argument("--run", type=int, default=1, help="Benchmark-Datei: Laufnummer (Default 1)")
args = parser.parse_args()
data = json.loads(Path(args.result_json).read_text(encoding="utf-8"))
if isinstance(data, list): # Benchmark-Ergebnis
rows = [r for r in data if "result" in r and (not args.case or r["case"] == args.case)
and (not args.model or args.model in r["model"]) and r["run"] == args.run]
if len(rows) != 1:
available = sorted({(r["case"], r["model"], r["run"]) for r in data if "result" in r})
parser.error(f"{len(rows)} passende Läufe – bitte mit --case/--model/--run eindeutig wählen. Verfügbar: {available}")
data = rows[0]["result"]
write_report(PipelineResult.model_validate(data), args.output)
print(f"Report geschrieben: {args.output}")

Some files were not shown because too many files have changed in this diff Show more