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:
parent
6890c0f272
commit
29d058d8f1
6 changed files with 192 additions and 25 deletions
|
|
@ -249,15 +249,17 @@ Man kann `DATA` im Skript von Hand ändern und neu rechnen – eleganter geht es
|
||||||
|
|
||||||
## 7. Das Ergebnis richtig lesen
|
## 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.
|
1. **Aufgabe** – der Aufgabentext mit gerenderten Formeln (Formelsatz in reinem Text bleibt unverändert), Abbildungen mit ihrer Übertragung.
|
||||||
2. **Status und Kennzahlen.**
|
2. **Beigelegte Daten** (nur mit `--daten`) und **„Was in der Aufgabe steht“** – erkannte Teilfragen und Zahlen.
|
||||||
3. **Warnkästen (⚠) – immer lesen.** „Ergebnis unzuverlässig – bitte nicht verwenden“ bedeutet genau das.
|
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.
|
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).
|
5. **Mathematisches Modell** mit offenen Annahmen.
|
||||||
6. **Sensitivität** – was eine Lockerung einer Grenze bringen würde.
|
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. Weiter unten: **„Was in der Aufgabe steht“** (erkannte Teilfragen und Zahlen), **Modell mit Annahmen**, **Prüfbericht**, **Programm**.
|
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
|
### Wann ein Ergebnis als unzuverlässig gilt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from markdown_it import MarkdownIt
|
||||||
|
|
||||||
from reporting import charts
|
from reporting import charts
|
||||||
from core.model_schema import PipelineResult, SolverStatus
|
from core.model_schema import PipelineResult, SolverStatus
|
||||||
from solving.script_export import build_solver_script
|
from solving.script_export import build_solver_script
|
||||||
|
|
@ -104,12 +106,17 @@ def _join_text(parts: list) -> list:
|
||||||
return result
|
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:
|
def split_math(text: str) -> list:
|
||||||
"""Text in [(is_math, str), …] zerlegen; Formelteile ohne Begrenzer."""
|
"""Text in [(is_math, str), …] zerlegen; Formelteile ohne Begrenzer."""
|
||||||
parts, pos = [], 0
|
parts, pos = [], 0
|
||||||
for match in _EXPLICIT_MATH.finditer(text):
|
for match in _EXPLICIT_MATH.finditer(text):
|
||||||
if match.group("dollar") is not None and not _MATHY.search(match.group("dollar")) \
|
if _is_money(match.group("dollar")):
|
||||||
and not re.fullmatch(r"\s*[A-Za-z]\s*", match.group("dollar")):
|
|
||||||
continue # z. B. Geldbetrag "$5 und $7"
|
continue # z. B. Geldbetrag "$5 und $7"
|
||||||
if match.start() > pos:
|
if match.start() > pos:
|
||||||
parts += _split_bare(text[pos:match.start()])
|
parts += _split_bare(text[pos:match.start()])
|
||||||
|
|
@ -173,8 +180,8 @@ def _result_section(result) -> list:
|
||||||
if run.constraints and not run.feasibility:
|
if run.constraints and not run.feasibility:
|
||||||
doc.append(("p", f"Zulässigkeit deterministisch geprüft: {len(run.constraints)} Restriktionen und "
|
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."))
|
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
|
# Die Erklärung („Wie das Modell die Aufgabe sieht“) steht als eigener Abschnitt vor dem Modell, also schon
|
||||||
doc += [("h3", "Wie das Modell die Aufgabe sieht"), ("p", spec.executive_summary)]
|
# vor diesem Ergebnis – die Fachbegriffe der Tabellen sind damit eingeführt, bevor sie auftauchen.
|
||||||
doc += _graph_section(run)
|
doc += _graph_section(run)
|
||||||
for table in run.tables:
|
for table in run.tables:
|
||||||
doc += [("h3", table.title),
|
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}",
|
doc.append(("list", [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}", f"Modell: {result.usage.model}",
|
||||||
f"LLM: {result.usage.calls} Aufrufe{cost}"]))
|
f"LLM: {result.usage.calls} Aufrufe{cost}"]))
|
||||||
if result.task.strip():
|
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
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -341,11 +348,10 @@ def build_document(result: PipelineResult, model: str = "") -> list:
|
||||||
spec, review = result.specification, result.review
|
spec, review = result.specification, result.review
|
||||||
if result.rejected or spec is None:
|
if result.rejected or spec is None:
|
||||||
return _rejected_document(result)
|
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")]
|
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}"]
|
meta = [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}"]
|
||||||
if result.usage:
|
if result.usage:
|
||||||
|
|
@ -362,11 +368,14 @@ def build_document(result: PipelineResult, model: str = "") -> list:
|
||||||
if result.usage and result.usage.responded_models:
|
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()))
|
meta.append("Modell laut Antwort: " + ", ".join(f"{name} ({n}×)" for name, n in result.usage.responded_models.items()))
|
||||||
doc.append(("list", meta))
|
doc.append(("list", meta))
|
||||||
|
if not result.reliable:
|
||||||
doc += _result_section(result)
|
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():
|
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:
|
if result.data_files:
|
||||||
doc += [("h2", "Beigelegte Daten")]
|
doc += [("h2", "Beigelegte Daten")]
|
||||||
|
|
@ -405,6 +414,8 @@ def build_document(result: PipelineResult, model: str = "") -> list:
|
||||||
("table", ["Problemklasse", "Solver-Stack", "Exportformat"],
|
("table", ["Problemklasse", "Solver-Stack", "Exportformat"],
|
||||||
[[spec.problem_class.value, spec.target_stack.value, spec.suggested_export_format]]),
|
[[spec.problem_class.value, spec.target_stack.value, spec.suggested_export_format]]),
|
||||||
("p", spec.routing_rationale),
|
("p", spec.routing_rationale),
|
||||||
|
("h2", "Wie das Modell die Aufgabe sieht"),
|
||||||
|
("p", spec.executive_summary),
|
||||||
("h2", "Mathematisches Modell"),
|
("h2", "Mathematisches Modell"),
|
||||||
("h3", "Mengen und Indizes"),
|
("h3", "Mengen und Indizes"),
|
||||||
("list", list(spec.indices)), # Formeln erkennt split_math (mit/ohne Begrenzer)
|
("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"],
|
("table", ["Unklare Stelle", "Annahme", "Verworfene Lesart", "Auswirkung"],
|
||||||
[[a.unclear_point, a.assumption, a.alternative, a.impact] for a in spec.open_assumptions])]
|
[[a.unclear_point, a.assumption, a.alternative, a.impact] for a in spec.open_assumptions])]
|
||||||
|
|
||||||
|
doc += _result_section(result)
|
||||||
|
|
||||||
oc = review.objective_check
|
oc = review.objective_check
|
||||||
doc += [
|
doc += [
|
||||||
("h2", "Prüfbericht"),
|
("h2", "Prüfbericht"),
|
||||||
|
|
@ -460,6 +473,49 @@ def build_document(result: PipelineResult, model: str = "") -> list:
|
||||||
return doc
|
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 ---
|
# --- Markdown ---
|
||||||
|
|
||||||
def _md_inline(value, in_table: bool = False) -> str:
|
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$$")
|
out.append(f"$$\n{block[1]}\n$$")
|
||||||
elif kind == "pre":
|
elif kind == "pre":
|
||||||
out.append(f"```text\n{block[1]}\n```")
|
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":
|
elif kind == "code":
|
||||||
out.append(f"```{block[1]}\n{block[2]}\n```")
|
out.append(f"```{block[1]}\n{block[2]}\n```")
|
||||||
elif kind == "table":
|
elif kind == "table":
|
||||||
|
|
@ -537,6 +598,15 @@ HTML_HEAD = """<!doctype html>
|
||||||
th { background: var(--head); }
|
th { background: var(--head); }
|
||||||
pre { background: var(--code-bg); padding: .8rem; overflow-x: auto; border-radius: 4px; }
|
pre { background: var(--code-bg); padding: .8rem; overflow-x: auto; border-radius: 4px; }
|
||||||
pre.task { white-space: pre-wrap; } /* Aufgabentext umbrechen, Code nicht */
|
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; }
|
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||||
.math { overflow-x: auto; }
|
.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; }
|
.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:
|
def to_html(doc: list) -> str:
|
||||||
out = [HTML_HEAD]
|
out = [HTML_HEAD]
|
||||||
for block in doc:
|
ids = _heading_ids(doc)
|
||||||
|
for index, block in enumerate(doc):
|
||||||
kind = block[0]
|
kind = block[0]
|
||||||
if kind in ("h1", "h2", "h3", "h4"):
|
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":
|
elif kind == "p":
|
||||||
out.append(f"<p>{_html_inline(block[1])}</p>")
|
out.append(f"<p>{_html_inline(block[1])}</p>")
|
||||||
elif kind == "warn":
|
elif kind == "warn":
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,6 @@ pyscipopt>=6.2
|
||||||
cyipopt>=1.7
|
cyipopt>=1.7
|
||||||
networkx>=3.6
|
networkx>=3.6
|
||||||
pandas>=2.2
|
pandas>=2.2
|
||||||
|
markdown-it-py>=3.0 # Aufgabentext im HTML-Report (bisher nur indirekt über rich)
|
||||||
numpy
|
numpy
|
||||||
pytest
|
pytest
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,12 @@ def test_decomposition_table_gets_waterfall():
|
||||||
assert page.count("var(--viz-total)") == 1 # Gesamtbalken nur im Wasserfall
|
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)]))
|
doc = build_document(make_result([ResultTable.model_validate(PORTFOLIO_TABLE)]))
|
||||||
h2 = [b[1] for b in doc if b[0] == "h2"]
|
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 ("lead", "Das Portfolio erreicht 9,76 %.") in doc
|
||||||
assert '<p class="lead">' in to_html(doc)
|
assert '<p class="lead">' in to_html(doc)
|
||||||
|
|
||||||
|
|
@ -141,3 +143,51 @@ def test_graph_section_explains_before_it_shows():
|
||||||
assert "svg" in arten
|
assert "svg" in arten
|
||||||
assert any(b[0] == "table" and "Markierungsverlauf" in str(doc[arten.index("table") - 1]) for b in doc)
|
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
|
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('<nav class="toc"'):page.index("</nav>")]
|
||||||
|
links = re.findall(r'href="#([^"]+)"', nav)
|
||||||
|
assert links and all(f'id="{anker}"' in page for anker in links) # jeder Link hat ein Ziel
|
||||||
|
assert len(links) == len(set(links))
|
||||||
|
for titel in ("Aufgabe", "Wie das Modell die Aufgabe sieht", "Mathematisches Modell", "Ergebnis", "Prüfbericht"):
|
||||||
|
assert f">{titel}</a>" in nav
|
||||||
|
assert "Restriktionen</a>" in nav # H3
|
||||||
|
assert not re.search(r"<h4 id=", page) # H4 bleibt ohne Anker und außerhalb
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_markdown_and_formulas_are_rendered():
|
||||||
|
from reporting.report import task_html
|
||||||
|
text = ("### 1. Problemstellung\n\nAus $N = 100$ Aktien werden **genau** $k = 20$ gewählt:\n\n"
|
||||||
|
"- Rendite $\\mu_i$\n- Risiko \\(w^T \\Sigma w\\)\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n"
|
||||||
|
"$$\\max \\sum_{i} \\mu_i w_i$$\n\nDas kostet $5 und $7 Gebühr. <script>alert(1)</script>")
|
||||||
|
seite = task_html(text)
|
||||||
|
assert "<h3>1. Problemstellung</h3>" in seite and "<strong>genau</strong>" in seite
|
||||||
|
assert r"\(N = 100\)" in seite and r"\(\mu_i\)" in seite and r"\(w^T \Sigma w\)" in seite
|
||||||
|
assert r"\[\max \sum_{i} \mu_i w_i\]" in seite # Unterstrich nicht von Markdown zerstört
|
||||||
|
assert "<table>" in seite and "<li>" in seite
|
||||||
|
assert "$5 und $7" in seite # Geldbeträge bleiben Text
|
||||||
|
assert "<script>" not in seite and "<script>" in seite # kein Roh-HTML aus dem Aufgabentext
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_formula_text_stays_preformatted():
|
||||||
|
# tasks/nur_formeln.md: Markdown machte aus „*x1 + 23*“ Kursivschrift und zöge die Zeilen zusammen
|
||||||
|
from reporting.report import task_html
|
||||||
|
text = "min z = 17*x1 + 23*x2\n\ns.t.\n\n 6*x1 + 4*x2 >= 120\n 3*x1 + 9*x2 >= 105"
|
||||||
|
seite = task_html(text)
|
||||||
|
assert seite.startswith('<pre class="task">') and "<em>" not in seite and " 6*x1 + 4*x2 >= 120" in seite
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_breaks_of_the_task_are_kept():
|
||||||
|
from reporting.report import task_html
|
||||||
|
seite = task_html("Pfeile:\n1 → 2: 3\n2 → 4: 5")
|
||||||
|
assert seite.count("<br") == 2
|
||||||
|
|
|
||||||
|
|
@ -123,10 +123,10 @@ def test_report_shows_prominent_warning_for_unreliable_result():
|
||||||
session = FakeSession(specs=[HALLUCINATED] * 3, reviews=[MISMATCH] * 3)
|
session = FakeSession(specs=[HALLUCINATED] * 3, reviews=[MISMATCH] * 3)
|
||||||
result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0)
|
result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0)
|
||||||
doc = build_document(result)
|
doc = build_document(result)
|
||||||
assert doc[1][0] == "warn" # direkt unter der Überschrift
|
assert [b[0] for b in doc[:4]] == ["h1", "list", "warn", "toc"] # Titel → Laufdaten → Warnkasten → Inhalt
|
||||||
assert '<div class="warn">⚠ Ergebnis unzuverlässig' in to_html(doc)
|
assert '<div class="warn">⚠ Ergebnis unzuverlässig' in to_html(doc)
|
||||||
assert "> **⚠ Ergebnis unzuverlässig" in to_markdown(doc)
|
assert "> **⚠ Ergebnis unzuverlässig" in to_markdown(doc)
|
||||||
assert "Der Solver wurde nicht gestartet." in doc[1][1]
|
assert "Der Solver wurde nicht gestartet." in doc[2][1]
|
||||||
|
|
||||||
|
|
||||||
def test_report_shows_the_rejected_reading_of_each_assumption():
|
def test_report_shows_the_rejected_reading_of_each_assumption():
|
||||||
|
|
|
||||||
|
|
@ -52,3 +52,9 @@ def test_markdown_table_cell_escapes_pipes_outside_and_inside_math():
|
||||||
assert rendered == "Norm $\\Vert w\\Vert $ \\| Rest"
|
assert rendered == "Norm $\\Vert w\\Vert $ \\| Rest"
|
||||||
assert _md_inline(Math("|x|"), in_table=True) == "$\\vert x\\vert $"
|
assert _md_inline(Math("|x|"), in_table=True) == "$\\vert x\\vert $"
|
||||||
assert _md_inline(Math("x"), in_table=False) == "$x$"
|
assert _md_inline(Math("x"), in_table=False) == "$x$"
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_between_dollars_is_a_formula_but_amounts_stay_text():
|
||||||
|
# beobachtet in tasks/komplexes_Portfolio_Management_Nr2.md: „$0$ sonst“ blieb Rohtext
|
||||||
|
assert (True, "0") in split_math("z_i = 1, sonst $0$ sonst")
|
||||||
|
assert all(not m for m, _ in split_math("Das kostet $5 und $7 Gebühr."))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue