From 29d058d8f1a46769cafca0b7a6e667c63a021322 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 18 Sep 2026 17:44:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Report-Aufbau=20neu=20=E2=80=93=20Inhal?= =?UTF-8?q?tsverzeichnis,=20Aufgabe=20gerendert,=20Ergebnis=20nach=20dem?= =?UTF-8?q?=20Modell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
.
- „$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 
---
 BEDIENUNGSANLEITUNG.md    |  16 +++--
 reporting/report.py       | 136 ++++++++++++++++++++++++++++++++++----
 requirements.txt          |   1 +
 tests/test_charts.py      |  54 ++++++++++++++-
 tests/test_pipeline.py    |   4 +-
 tests/test_report_math.py |   6 ++
 6 files changed, 192 insertions(+), 25 deletions(-)

diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md
index e8c2a08..c3a6843 100644
--- a/BEDIENUNGSANLEITUNG.md
+++ b/BEDIENUNGSANLEITUNG.md
@@ -249,15 +249,17 @@ Man kann `DATA` im Skript von Hand ändern und neu rechnen – eleganter geht es
 
 ## 7. Das Ergebnis richtig lesen
 
-Der Report beginnt mit dem Ergebnis und erklärt, bevor er Tabellen zeigt. Empfohlene Lesereihenfolge:
+Der Report folgt dem Weg von der Aufgabe zur Lösung. Ganz oben stehen die **Laufdaten** (Modell, Kosten, Korrekturrunden), darunter gegebenenfalls der **Warnkasten** und dann ein **klickbares Inhaltsverzeichnis** (nur im HTML-Report). Die Abschnitte:
 
-1. **Antwortsatz und Antworten auf die Aufgabe** – eine je Teilfrage. Jede Zahl darin stammt aus dem Ergebnis oder dem Aufgabentext.
-2. **Status und Kennzahlen.**
-3. **Warnkästen (⚠) – immer lesen.** „Ergebnis unzuverlässig – bitte nicht verwenden“ bedeutet genau das.
+1. **Aufgabe** – der Aufgabentext mit gerenderten Formeln (Formelsatz in reinem Text bleibt unverändert), Abbildungen mit ihrer Übertragung.
+2. **Beigelegte Daten** (nur mit `--daten`) und **„Was in der Aufgabe steht“** – erkannte Teilfragen und Zahlen.
+3. **Einordnung** – Problemklasse und Solver.
 4. **„Wie das Modell die Aufgabe sieht“** – die Erklärung in Alltagssprache. Stimmt sie nicht mit Ihrer Absicht überein, ist das Modell falsch, auch wenn die Zahl plausibel aussieht.
-5. **Ergebnistabellen und Diagramme**, bindende Restriktionen (welche Grenzen voll ausgeschöpft sind).
-6. **Sensitivität** – was eine Lockerung einer Grenze bringen würde.
-7. Weiter unten: **„Was in der Aufgabe steht“** (erkannte Teilfragen und Zahlen), **Modell mit Annahmen**, **Prüfbericht**, **Programm**.
+5. **Mathematisches Modell** mit offenen Annahmen.
+6. **Ergebnis** – Antworten auf die Aufgabe (eine je Teilfrage; jede Zahl darin stammt aus dem Ergebnis oder dem Aufgabentext), Status und Kennzahlen, Ergebnistabellen und Diagramme, bindende Restriktionen, Sensitivität, alle Variablenwerte.
+7. **Prüfbericht** und **Solver-Programm**.
+
+**Warnkästen (⚠) immer lesen.** „Ergebnis unzuverlässig – bitte nicht verwenden“ bedeutet genau das; er steht oben unter den Laufdaten, weitere Warnungen im Abschnitt „Ergebnis“.
 
 ### Wann ein Ergebnis als unzuverlässig gilt
 
diff --git a/reporting/report.py b/reporting/report.py
index 4bc9166..765ee58 100644
--- a/reporting/report.py
+++ b/reporting/report.py
@@ -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 
.
+
+    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'
{html.escape(text)}
' + 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'
{gerendert}
' + + # --- 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 = """ 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'{titel}' + if block[0] == "h2" or not abschnitte: + abschnitte.append((link, [])) + else: + abschnitte[-1][1].append(link) + eintraege = "".join( + f"
  • {h2}" + ("
      " + "".join(f"
    • {h3}
    • " for h3 in h3s) + "
    " if h3s else "") + "
  • " + for h2, h3s in abschnitte) + return f'' + + 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])}") + anker = f' id="{ids[index]}"' if index in ids else "" + out.append(f"<{kind}{anker}>{html.escape(block[1])}") + elif kind == "toc": + out.append(_toc_html(doc, ids)) + elif kind == "task": + out.append(task_html(block[1])) elif kind == "p": out.append(f"

    {_html_inline(block[1])}

    ") elif kind == "warn": diff --git a/requirements.txt b/requirements.txt index 20b7ae5..8569ee0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,5 +10,6 @@ pyscipopt>=6.2 cyipopt>=1.7 networkx>=3.6 pandas>=2.2 +markdown-it-py>=3.0 # Aufgabentext im HTML-Report (bisher nur indirekt über rich) numpy pytest diff --git a/tests/test_charts.py b/tests/test_charts.py index f78a3ea..4619c46 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -86,10 +86,12 @@ def test_decomposition_table_gets_waterfall(): assert page.count("var(--viz-total)") == 1 # Gesamtbalken nur im Wasserfall -def test_result_comes_first_and_headline_is_lead(): +def test_result_follows_the_model_and_headline_is_lead(): + # Aufbau seit 18.09.2026: Aufgabe → … → Erklärung → Modell → Ergebnis → Prüfbericht doc = build_document(make_result([ResultTable.model_validate(PORTFOLIO_TABLE)])) h2 = [b[1] for b in doc if b[0] == "h2"] - assert h2[0] == "Ergebnis" and h2.index("Ergebnis") < h2.index("Mathematisches Modell") + assert h2 == ["Aufgabe", "Einordnung", "Wie das Modell die Aufgabe sieht", "Mathematisches Modell", + "Ergebnis", "Prüfbericht"] assert ("lead", "Das Portfolio erreicht 9,76 %.") in doc assert '

    ' in to_html(doc) @@ -141,3 +143,51 @@ def test_graph_section_explains_before_it_shows(): assert "svg" in arten assert any(b[0] == "table" and "Markierungsverlauf" in str(doc[arten.index("table") - 1]) for b in doc) assert any("8 + 21 = 29" in str(b) for b in doc) # Rechenweg ausgeschrieben + + +# --- Kopf, Inhaltsverzeichnis und gerenderte Aufgabe --- + +def test_head_is_title_run_data_then_table_of_contents(): + doc = build_document(make_result([ResultTable.model_validate(PORTFOLIO_TABLE)])) + assert [b[0] for b in doc[:3]] == ["h1", "list", "toc"] # zuverlässig: kein Warnkasten + assert "toc" not in to_markdown(doc) and "Inhalt" not in to_markdown(doc) + + +def test_table_of_contents_links_every_section_but_no_h4(): + page = to_html(build_document(make_result([ResultTable.model_validate(PORTFOLIO_TABLE)]))) + nav = page[page.index('