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:
parent
b74445b8d6
commit
7459690bf5
42 changed files with 315 additions and 279 deletions
38
README.md
38
README.md
|
|
@ -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
|
.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)
|
# 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
|
# exportiertes Solver-Programm erneut ausführen
|
||||||
.venv/bin/python ausgabe/solve.py
|
.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.
|
`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
|
```bash
|
||||||
.venv/bin/python benchmark.py --models deepseek/deepseek-v4.1-flash deepseek/deepseek-v4-flash-0731 --runs 2 --jobs 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 benchmark.py --cases hard_schichtplan_woche --runs 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
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python -m pytest -q # offline, ohne API-Kosten
|
.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
|
## 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`) |
|
| `TextToModel.py` | CLI und Ablauf der Pipeline (`run_pipeline`, Neustart, Gegenprobe, Fortsetzen, Neurechnen) |
|
||||||
| `model_schema.py` | Pydantic-Schemas aller Zwischenergebnisse |
|
| `core/` | `model_schema.py` (Pydantic-Schemas aller Zwischenergebnisse), `llm.py` (LLM-Zugriff über OpenRouter/Gemini, Wiederholungen, Kostenerfassung) |
|
||||||
| `model_checks.py` | deterministische Konsistenz-Checks |
|
| `understanding/` | Stufe Verstehen: Teilfragen und Zahlen des Aufgabentexts erfassen (`understand.py`, `Verstehen-Prompt.txt`) |
|
||||||
| `solver_stage.py` | Solver-Code erzeugen, prüfen, ausführen |
|
| `modeling/` | Stufen Extraktion und Korrektur: Modellspezifikation erzeugen und überarbeiten (`extraction.py`, `Routing-und-Extraktions-Prompt.txt`) |
|
||||||
| `llm.py` | LLM-Zugriff (OpenRouter/Gemini), Wiederholungen, Kostenerfassung |
|
| `checks/` | Prüfungen: deterministische Konsistenz-Checks (`model_checks.py`) und LLM-Prüfung von Zielgröße und Annahmen (`review.py`, `Pruef-Prompt.txt`) |
|
||||||
| `report.py` | Report als Markdown/HTML (mit Kopier-Symbol), auch als eigenständiges CLI |
|
| `solving/` | Solver-Code erzeugen, prüfen, in der Sandbox ausführen (`solver_stage.py`, `Solver-Prompt.txt`); eigenständiges Solver-Programm (`script_export.py`) |
|
||||||
| `script_export.py` | dokumentiertes, eigenständiges Solver-Programm (Kopf mit Aufgabe, Modell, Algorithmus) |
|
| `reporting/` | Report als Markdown/HTML, auch als CLI (`report.py`); Diagramme und Graphenzeichnung als Inline-SVG (`charts.py`) |
|
||||||
| `data_profile.py` | Profil beigelegter CSV-/Excel-Dateien, ohne LLM |
|
| `data/` | Profil beigelegter CSV-/Excel-Dateien, ohne LLM (`data_profile.py`) |
|
||||||
| `charts.py` | Diagramme und Graphenzeichnung als Inline-SVG |
|
| `benchmark/` | Benchmark (`benchmark.py`) und Referenzaufgaben (`cases/`) |
|
||||||
| `benchmark.py`, `benchmark/cases/` | Benchmark und Referenzaufgaben |
|
| `tasks/` | weitere Aufgaben zum Ausprobieren (u. a. mit fehlenden Daten) |
|
||||||
| `aufgaben/` | weitere Aufgaben zum Ausprobieren (u. a. mit fehlenden Daten) |
|
| `tests/` | Offline-Tests (`pytest`, Konfiguration in `pyproject.toml`) |
|
||||||
| `*-Prompt.txt` | System-Prompts für Verstehen, Extraktion, Prüfung und Solver-Code |
|
|
||||||
| `test_*.py` | Offline-Tests |
|
|
||||||
|
|
||||||
## Sicherheitshinweis
|
## Sicherheitshinweis
|
||||||
|
|
||||||
|
|
|
||||||
134
TextToModel.py
134
TextToModel.py
|
|
@ -5,10 +5,10 @@ import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from llm import DEFAULT_MODEL, LLMSession
|
from checks.model_checks import check_spec, check_understanding, has_errors
|
||||||
import data_profile
|
from checks.review import needs_work, review_assumptions, select_best
|
||||||
from model_checks import check_spec, check_understanding, has_errors
|
from core.llm import DEFAULT_MODEL, LLMSession
|
||||||
from model_schema import (
|
from core.model_schema import (
|
||||||
AssumptionReview,
|
AssumptionReview,
|
||||||
SolverStatus,
|
SolverStatus,
|
||||||
CheckIssue,
|
CheckIssue,
|
||||||
|
|
@ -17,14 +17,13 @@ from model_schema import (
|
||||||
PipelineResult,
|
PipelineResult,
|
||||||
TaskUnderstanding,
|
TaskUnderstanding,
|
||||||
)
|
)
|
||||||
from report import write_report
|
from data import data_profile
|
||||||
from script_export import build_solver_script
|
from modeling.extraction import revise_model, text_to_model
|
||||||
from solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve
|
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"))
|
MAX_REVISIONS = int(os.environ.get("MAX_REVISIONS", "2"))
|
||||||
SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2"))
|
SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2"))
|
||||||
MAX_RESTARTS = int(os.environ.get("MAX_RESTARTS", "1"))
|
MAX_RESTARTS = int(os.environ.get("MAX_RESTARTS", "1"))
|
||||||
|
|
@ -48,119 +47,6 @@ def log(msg: str) -> None:
|
||||||
print(msg, file=sys.stderr)
|
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:
|
def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple:
|
||||||
"""Verstehensstufe samt Prüfung; ein Nachfassen, wenn Zahlen oder Teilfragen fehlen."""
|
"""Verstehensstufe samt Prüfung; ein Nachfassen, wenn Zahlen oder Teilfragen fehlen."""
|
||||||
if UNDERSTAND_RETRIES < 0:
|
if UNDERSTAND_RETRIES < 0:
|
||||||
|
|
|
||||||
0
benchmark/__init__.py
Normal file
0
benchmark/__init__.py
Normal file
227
benchmark/benchmark.py
Normal file
227
benchmark/benchmark.py
Normal 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
49
checks/Pruef-Prompt.txt
Normal 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
0
checks/__init__.py
Normal file
503
checks/model_checks.py
Normal file
503
checks/model_checks.py
Normal 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
50
checks/review.py
Normal 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
0
core/__init__.py
Normal file
162
core/llm.py
Normal file
162
core/llm.py
Normal 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
439
core/model_schema.py
Normal 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
0
data/__init__.py
Normal file
89
data/data_profile.py
Normal file
89
data/data_profile.py
Normal 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)
|
||||||
69
modeling/Routing-und-Extraktions-Prompt.txt
Normal file
69
modeling/Routing-und-Extraktions-Prompt.txt
Normal 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
0
modeling/__init__.py
Normal file
68
modeling/extraction.py
Normal file
68
modeling/extraction.py
Normal 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
3
pyproject.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = [".", "tests"]
|
||||||
0
reporting/__init__.py
Normal file
0
reporting/__init__.py
Normal file
188
reporting/charts.py
Normal file
188
reporting/charts.py
Normal 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
635
reporting/report.py
Normal 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
Loading…
Reference in a new issue