text_to_solver_program/reporting/report.py

678 lines
33 KiB
Python
Raw Normal View History

"""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 DIJKSTRA_EXPLANATION, _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_EXPLANATION)]
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 n, row in enumerate(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, last=n == len(table.rows) - 1):
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 _figure_blocks(result: PipelineResult) -> list:
"""Abbildungen der Aufgabe: Original neben der Übertragung, mit der die Pipeline gerechnet hat."""
if not result.figures:
return []
from understanding.figures import canonical_figure_id
uebertragen = {canonical_figure_id(f.id): f for f in (result.understanding.figures if result.understanding else [])}
doc = [("h3", "Abbildungen"),
("p", "Die Pipeline rechnet nicht mit dem Bild, sondern mit seiner Übertragung in Text. "
"Bitte Original und Übertragung vergleichen – ein verlesener Wert ändert das ganze Ergebnis.")]
for figur in result.figures:
doc.append(("h4", figur.id + (f" – {figur.alt}" if figur.alt and figur.alt.lower() != "image" else "")))
doc.append(("img", f"data:{figur.mime};base64,{figur.data}", figur.id))
t = uebertragen.get(figur.id)
if t is None:
doc.append(("warn", f"{figur.id} wurde nicht übertragen – ihre Inhalte fehlen im Modell."))
continue
doc += [("p", f"Übertragung: {t.shows}"), ("pre", t.transcription.strip())]
if t.uncertain:
doc.append(("warn", f"{figur.id} – unsicher gelesen: " + "; ".join(t.uncertain)))
return doc
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())] + _figure_blocks(result)
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()))
if result.usage and result.usage.responded_models:
meta.append("Modell laut Antwort: " + ", ".join(f"{name} ({n}×)" for name, n in result.usage.responded_models.items()))
doc.append(("list", meta))
doc += _result_section(result)
if result.task.strip():
doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result)
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 == "img":
out.append(f"![{block[2]}]({block[1]})")
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 == "img":
out.append(f'<figure><img src="{html.escape(block[1], quote=True)}" alt="{html.escape(block[2])}" '
f'style="max-width:100%"><figcaption>{html.escape(block[2])}</figcaption></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}")