refactor: Pipeline-Stufen in Ordner mit ihren Prompts

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

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

0
reporting/__init__.py Normal file
View file

188
reporting/charts.py Normal file
View file

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

635
reporting/report.py Normal file
View file

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