text_to_solver_program/reporting/report.py
dschlueter 8697400dc2 feat: Nicht-Optimierungsaufgaben ablehnen statt modellieren
Bisher erzeugte is_optimization_task=False nur eine Log-Warnung; eine
reine Gleichung wurde trotzdem in ein Optimierungsmodell gepresst.
Jetzt bricht run_pipeline direkt nach dem Verstehen ab: kein
Nachfassen, keine Extraktion, kein Solver. PipelineResult.rejected,
reliable=False, specification/review optional; Report "Aufgabe
abgelehnt", CLI-Exit-Code 3, Benchmark-Status "abgelehnt" (auch die
Ergebnistabelle verkraftet die Zeile), --von verweigert.

Der Verstehens-Prompt nennt reine Gleichungen/Rechenaufgaben als
Nicht-Aufgaben und Zulaessigkeitsfragen sowie moeglicherweise
unloesbare Planungen ausdruecklich als Aufgaben. Unvermessen;
Prompt-Kennung jetzt 0333bfd5f9fe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:54:54 +02:00

651 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 _rejected_document(result: PipelineResult) -> list:
"""Abgelehnte Aufgabe: Grund und Aufgabentext – kein Modell, keine Zahlen."""
grund = result.understanding.reason if result.understanding else "; ".join(result.reliability_issues)
doc = [("h1", "Aufgabe abgelehnt"),
("warn", f"Keine Optimierungsaufgabe – es wurde weder modelliert noch gerechnet. {grund}")]
if result.usage:
cost = f", Kosten USD {result.usage.cost_usd:.4f}" if result.usage.cost_usd is not None else ""
doc.append(("list", [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}", f"Modell: {result.usage.model}",
f"LLM: {result.usage.calls} Aufrufe{cost}"]))
if result.task.strip():
doc += [("h2", "Aufgabe"), ("pre", result.task.strip())]
return doc
def build_document(result: PipelineResult, model: str = "") -> list:
spec, review = result.specification, result.review
if result.rejected or spec is None:
return _rejected_document(result)
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}")