feat: Report-Aufbau neu – Inhaltsverzeichnis, Aufgabe gerendert, Ergebnis nach dem Modell

Reihenfolge (Nutzerentscheidung): Titel → Laufdaten → Warnkasten → klickbares
Inhaltsverzeichnis (HTML, H2+H3) → Aufgabe → beigelegte Daten → Was in der Aufgabe
steht → Einordnung → „Wie das Modell die Aufgabe sieht“ (eigene H2) → Mathematisches
Modell → Ergebnis → Prüfbericht → Solver-Programm.

- Aufgabe als Markdown mit gerenderten Formeln (markdown-it-py, html=False,
  breaks=True; Formeln vorher durch Platzhalter geschützt); Formelsatz in reinem Text
  (nur_formeln.md) bleibt <pre>.
- „$0$“ gilt als Formel, „$5 und $7“ weiter als Geldbetrag (_is_money).
- markdown-it-py ausdrücklich in requirements.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-18 17:44:26 +02:00
commit 29d058d8f1

View file

@ -9,6 +9,8 @@ import re
from datetime import datetime
from pathlib import Path
from markdown_it import MarkdownIt
from reporting import charts
from core.model_schema import PipelineResult, SolverStatus
from solving.script_export import build_solver_script
@ -104,12 +106,17 @@ def _join_text(parts: list) -> list:
return result
def _is_money(dollar: str | None) -> bool:
"""„$5 und $7“: Geldbeträge, keine Formel. Eine Zahl direkt zwischen zwei $ („$0$ sonst“) ist dagegen eine Formel."""
return (dollar is not None and not _MATHY.search(dollar) and not re.fullmatch(r"\s*[A-Za-z]\s*", dollar)
and not re.fullmatch(r"\d+(?:[.,]\d+)?", dollar))
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")):
if _is_money(match.group("dollar")):
continue # z. B. Geldbetrag "$5 und $7"
if match.start() > pos:
parts += _split_bare(text[pos:match.start()])
@ -173,8 +180,8 @@ def _result_section(result) -> list:
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)]
# Die Erklärung („Wie das Modell die Aufgabe sieht“) steht als eigener Abschnitt vor dem Modell, also schon
# vor diesem Ergebnis – die Fachbegriffe der Tabellen sind damit eingeführt, bevor sie auftauchen.
doc += _graph_section(run)
for table in run.tables:
doc += [("h3", table.title),
@ -333,7 +340,7 @@ def _rejected_document(result: PipelineResult) -> list:
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)
doc += [("h2", "Aufgabe"), ("task", result.task.strip())] + _figure_blocks(result)
return doc
@ -341,11 +348,10 @@ 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)
# Aufbau (Nutzerentscheidung 18.09.2026): Titel → Laufdaten → Warnkasten → Inhaltsverzeichnis → Aufgabe →
# Was in der Aufgabe steht → Einordnung → Erklärung → Modell → Ergebnis → Prüfbericht → Solver-Programm.
# Vorher stand das Ergebnis vor Aufgabe und Modell und sammelte auch Erklärung und Rohwerte – das wirkte verwirrend.
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:
@ -362,11 +368,14 @@ def build_document(result: PipelineResult, model: str = "") -> list:
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 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 "")))
doc.append(("toc",))
if result.task.strip():
doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + _figure_blocks(result)
doc += [("h2", "Aufgabe"), ("task", result.task.strip())] + _figure_blocks(result)
if result.data_files:
doc += [("h2", "Beigelegte Daten")]
@ -405,6 +414,8 @@ def build_document(result: PipelineResult, model: str = "") -> list:
("table", ["Problemklasse", "Solver-Stack", "Exportformat"],
[[spec.problem_class.value, spec.target_stack.value, spec.suggested_export_format]]),
("p", spec.routing_rationale),
("h2", "Wie das Modell die Aufgabe sieht"),
("p", spec.executive_summary),
("h2", "Mathematisches Modell"),
("h3", "Mengen und Indizes"),
("list", list(spec.indices)), # Formeln erkennt split_math (mit/ohne Begrenzer)
@ -429,6 +440,8 @@ def build_document(result: PipelineResult, model: str = "") -> list:
("table", ["Unklare Stelle", "Annahme", "Verworfene Lesart", "Auswirkung"],
[[a.unclear_point, a.assumption, a.alternative, a.impact] for a in spec.open_assumptions])]
doc += _result_section(result)
oc = review.objective_check
doc += [
("h2", "Prüfbericht"),
@ -460,6 +473,49 @@ def build_document(result: PipelineResult, model: str = "") -> list:
return doc
# --- Aufgabentext: Markdown mit Formeln ---
# Aufgaben kommen meist als Markdown (docling) mit LaTeX. Roh-HTML aus dem Aufgabentext wird nicht übernommen
# (html=False), Zeilenumbrüche bleiben (breaks=True: Datenzeilen und übertragene Abbildungen sind zeilenweise).
_TASK_MARKDOWN = MarkdownIt("commonmark", {"html": False, "breaks": True}).enable("table")
_MARKDOWN_FEATURES = re.compile(r"^\s{0,3}(#{1,6}\s|[-*+]\s|\d+[.)]\s|\|)|\*\*|\$|\\\(|\\\[", re.MULTILINE)
# Formelsatz in reinem Text („17*x1 + 23*x2“, eingerückte Zeilen): Markdown machte aus *x1 + 23* Kursivschrift
_PLAIN_FORMULA = re.compile(r"\w\*\w|^(?: {2,}|\t)\S", re.MULTILINE)
_PLACEHOLDER = "ZZFORMELZZ{}ZZ"
def _is_plain_formula_text(text: str) -> bool:
"""Formelsatz ohne Markdown-Merkmale (z. B. tasks/nur_formeln.md) – bleibt Rohtext."""
return bool(_PLAIN_FORMULA.search(text)) and not _MARKDOWN_FEATURES.search(text)
def task_html(text: str) -> str:
"""Aufgabentext als HTML: Markdown gerendert, Formeln für MathJax, Formelsatz in reinem Text als <pre>.
Formeln werden vor dem Markdown-Rendern durch Platzhalter ersetzt – sonst machte Markdown aus x_{i}_{t}
Kursivschrift und schluckte Backslashes. Erkannt werden nur ausdrückliche Begrenzer (wie in split_math, mit
derselben Geldbetrag-Regel „$5 und $7“); Formeln ohne Begrenzer bleiben im Aufgabentext, wie sie sind.
"""
if _is_plain_formula_text(text):
return f'<pre class="task">{html.escape(text)}</pre>'
formeln = []
def ersetze(match):
if _is_money(match.group("dollar")):
return match.group(0)
if match.group("env") is not None:
formeln.append(html.escape(match.group("env"))) # MathJax erkennt \begin…\end selbst
else:
abgesetzt = match.group("bracket") is not None or match.group("ddollar") is not None
inhalt = next(match.group(g) for g in ("paren", "bracket", "ddollar", "dollar") if match.group(g) is not None)
formeln.append((r"\[" if abgesetzt else r"\(") + html.escape(inhalt.strip()) + (r"\]" if abgesetzt else r"\)"))
return _PLACEHOLDER.format(len(formeln) - 1)
gerendert = _TASK_MARKDOWN.render(_EXPLICIT_MATH.sub(ersetze, text))
gerendert = re.sub(r"ZZFORMELZZ(\d+)ZZ", lambda m: formeln[int(m.group(1))], gerendert)
return f'<div class="task">{gerendert}</div>'
# --- Markdown ---
def _md_inline(value, in_table: bool = False) -> str:
@ -500,6 +556,11 @@ def to_markdown(doc: list) -> str:
out.append(f"$$\n{block[1]}\n$$")
elif kind == "pre":
out.append(f"```text\n{block[1]}\n```")
elif kind == "task":
# Aufgaben sind meist schon Markdown; Formelsatz in reinem Text bleibt Codeblock
out.append(f"```text\n{block[1]}\n```" if _is_plain_formula_text(block[1]) else block[1])
elif kind == "toc":
pass # Inhaltsverzeichnis nur im HTML-Report (Markdown-Betrachter zeigen eine eigene Gliederung)
elif kind == "code":
out.append(f"```{block[1]}\n{block[2]}\n```")
elif kind == "table":
@ -537,6 +598,15 @@ HTML_HEAD = """<!doctype html>
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 */
div.task { background: var(--code-bg); padding: .4rem 1rem; border-radius: 4px; }
div.task h1, div.task h2, div.task h3, div.task h4, div.task h5, div.task h6 {
font-size: 1.05rem; border: none; margin: .8rem 0 .3rem; } /* Überschriften der Aufgabe, nicht des Reports */
nav.toc { border: 1px solid var(--rule); border-radius: 4px; padding: .4rem 1rem; margin: 1rem 0 1.5rem; }
nav.toc .toc-title { font-weight: 600; margin: .2rem 0; }
nav.toc ul { margin: .2rem 0; padding-left: 1.2rem; }
nav.toc > ul { padding-left: 1rem; }
nav.toc a { color: inherit; }
h2, h3 { scroll-margin-top: 1rem; }
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; }
@ -603,12 +673,50 @@ def _html_inline(value) -> str:
)
def _heading_ids(doc: list) -> dict:
"""Eindeutige Anker für H2/H3 (Index im Dokument -> id); Umlaute bleiben, Dubletten werden nummeriert."""
ids, vergeben = {}, set()
for index, block in enumerate(doc):
if block[0] in ("h2", "h3"):
basis = re.sub(r"[^\w]+", "-", block[1].lower()).strip("-") or "abschnitt"
anker, n = basis, 2
while anker in vergeben:
anker, n = f"{basis}-{n}", n + 1
vergeben.add(anker)
ids[index] = anker
return ids
def _toc_html(doc: list, ids: dict) -> str:
"""Klickbares Inhaltsverzeichnis aus H2/H3 – H4 (Fragezitate, Variablen, Restriktionen) bleibt draußen."""
abschnitte = [] # [(Link der H2, [Links der H3])]
for index, block in enumerate(doc):
if index not in ids:
continue
titel = html.escape(re.sub(r"\\[()\[\]]|\$", "", block[1]))
link = f'<a href="#{ids[index]}">{titel}</a>'
if block[0] == "h2" or not abschnitte:
abschnitte.append((link, []))
else:
abschnitte[-1][1].append(link)
eintraege = "".join(
f"<li>{h2}" + ("<ul>" + "".join(f"<li>{h3}</li>" for h3 in h3s) + "</ul>" if h3s else "") + "</li>"
for h2, h3s in abschnitte)
return f'<nav class="toc" aria-label="Inhalt"><p class="toc-title">Inhalt</p><ul>{eintraege}</ul></nav>'
def to_html(doc: list) -> str:
out = [HTML_HEAD]
for block in doc:
ids = _heading_ids(doc)
for index, block in enumerate(doc):
kind = block[0]
if kind in ("h1", "h2", "h3", "h4"):
out.append(f"<{kind}>{html.escape(block[1])}</{kind}>")
anker = f' id="{ids[index]}"' if index in ids else ""
out.append(f"<{kind}{anker}>{html.escape(block[1])}</{kind}>")
elif kind == "toc":
out.append(_toc_html(doc, ids))
elif kind == "task":
out.append(task_html(block[1]))
elif kind == "p":
out.append(f"<p>{_html_inline(block[1])}</p>")
elif kind == "warn":