Eingebettete Base64-Bilder und Bilddateien werden aus dem Aufgabentext gelöst und nur der Verstehensstufe als Bild übergeben; sie überträgt jede Abbildung in Text. Eine zweite, unabhängige Lesung wird Zahl für Zahl verglichen (bei Abweichung entscheidet eine dritte, sonst reliable=False). Alle weiteren Stufen rechnen mit der Übertragung, der Report zeigt Original und Übertragung nebeneinander. LLM_MODEL_UNDERSTANDING wählt das Bildmodell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
675 lines
33 KiB
Python
675 lines
33 KiB
Python
"""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 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 _figure_blocks(result: PipelineResult) -> list:
|
||
"""Abbildungen der Aufgabe: Original neben der Übertragung, mit der die Pipeline gerechnet hat."""
|
||
if not result.figures:
|
||
return []
|
||
uebertragen = {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()))
|
||
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}")
|