"""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.+?)\\\)" r"|\\\[(?P.+?)\\\]" r"|\$\$(?P.+?)\$\$" r"|\$(?P[^$\n]+?)\$" r"|(?P\\begin\{(?P[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"(? 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 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 = """ Optimierungsmodell """ COPY_ICON = ( '' ) # Kopieren per Clipboard-API; Rückfall über execCommand, falls die API (z. B. bei file://) nicht verfügbar ist COPY_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"{html.escape(str(value))}" # 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])}") elif kind == "p": out.append(f"

{_html_inline(block[1])}

") elif kind == "warn": out.append(f'
⚠ {_html_inline(block[1])}
') elif kind == "lead": out.append(f'

{_html_inline(block[1])}

') elif kind == "svg": out.append(f'
{block[1]}
') elif kind == "list": out.append("
    " + "".join(f"
  • {_html_inline(item)}
  • " for item in block[1]) + "
") elif kind == "math": out.append(f'
\\[{html.escape(block[1])}\\]
') elif kind == "pre": out.append(f'
{html.escape(block[1])}
') elif kind == "code": out.append( '
' f'' f'
{html.escape(block[2])}
' ) elif kind == "table": headers, rows = block[1], block[2] head = "".join(f"{html.escape(h)}" for h in headers) if any(headers) else "" body = "".join("" + "".join(f"{_html_inline(c)}" for c in row) + "" for row in rows) out.append(f"{'' + head + '' if head else ''}{body}
") out.append(COPY_SCRIPT) out.append("\n\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}")