Glossar-Links: Fachbegriffe im Fließtext automatisch verlinken

Auto-Linking im Build: Glossar-Begriffe werden im Fließtext automatisch
erkannt und als Link auf den Glossar-Eintrag gesetzt — erste Nennung je
Begriff je Datei, dezent gestrichelte Unterstreichung im HTML, hyperref
im PDF. Kein manuelles Markieren bei Textänderungen nötig.

Neue Infrastruktur:
- glossar_eintraege_04.py: glossar_slug() + LINK_TEXTE-Map (automatisch
  generiert aus EINTRAEGEN + Abkürzungs-Extraktion)
- erzeuge_glossar_04.py: jeder Eintrag bekommt {#gloss:<slug>}-Anchor
- build_version_04.py: resolve_glossar() — Auto-Linking + {gl:}-Marke
- web_04/site.css: .glossar-link (dezent, nicht wie Navigationslink)

5 neue Glossar-Einträge (waren als Begriff im Text, aber nicht im Glossar):
Solver, Scheduling, Graphen, Backtest, DCP (Disciplined Convex Programming)

Schutzmechanismen:
- Code-Blöcke, Inline-Code, {idx:}/{ref:}-Marken, Markdown-Links und
  Überschriften werden nicht verlinkt
- Verschachtelung verhindert: 'CP-SAT-Solver' als Ganzes, nicht 'CP'+[
  -'SAT'+[-'Solver'
- 'OR' als 2-Zeichen-Abkürzung nicht auto-verlinkt (False Positive in
  'OR-Tools'); {gl:OR} als manuelle Marke
- Glossar selbst nicht auto-verlinkt (sonst Self-Links)
- reflow_markdown(): ::: als Block-Grenze (sonst kollabiert fenced div)

619 Glossar-Links im Gesamtdokument, 239 Anchor im Glossar, 34 im Vorwort.
This commit is contained in:
dschlueter 2026-09-10 17:40:29 +02:00
commit e8abb66d75
52 changed files with 4316 additions and 1921 deletions

View file

@ -157,4 +157,4 @@ Details zu den Lernpfaden stehen in [`00_Vorwort_und_Lesehilfe.md`](00_Vorwort_u
---
*Autor / Herausgeber: Dieter Schlüter · Stand: 10. September 2026 v16.09*
*Autor / Herausgeber: Dieter Schlüter · Stand: 10. September 2026 v17.38*

View file

@ -711,6 +711,17 @@ def reflow_markdown(text: str) -> str:
i += 1
continue
# Fenced-Div-Grenzen (::: {#...} / :::): wie Ueberschriften als
# Blockgrenze behandeln, sonst wuerde reflow die ::: mit dem
# folgenden Absatz zu einer Zeile verbinden — und Pandoc wuerde den
# div nicht mehr erkennen. Genutzt im Glossar (Anhang E): jeder
# Eintrag steht in einem ::: {#gloss:<slug>} ... :::-Block.
if content_stripped.startswith(":::"):
flush()
out.append(raw)
i += 1
continue
if _HR_RE.match(content_stripped):
flush()
out.append(raw)
@ -1070,6 +1081,162 @@ def resolve_index(text: str) -> str:
return text
# --- Glossar-Links: Fachbegriffe im Fließtext automatisch verlinken -------
#
# resolve_glossar() sucht im Fließtext nach Begriffen aus LINK_TEXTE (siehe
# glossar_eintraege_04.py) und ersetzt das ERSTE Vorkommen je Begriff im
# aktuellen Text durch einen Markdown-Link auf den Glossar-Eintrag. "Erste
# Nennung" statt jedes Vorkommen: ein Begriff, der 576-mal vorkommt ('Solver'),
# wuerde sonst jeden Absatz mit Links pflastern.
#
# Ausgeschlossen vom Link sind Code-Bloecke (```...```), Inline-Code (`...`),
# Marken ({idx:...}, {ref:...}, {#...}), Markdown-Links [...](...) und
# Ueberschriften — dort wuerde der Link stoen oder kaputtgehen.
#
# fuer_seite=False: Link als [Begriff](#gloss:slug) — fuer das Gesamtdokument.
# fuer_seite=True: Link als [Begriff](anhang-glossar.md#gloss:slug) — fuer
# Einzelseiten (seitenuebergreifend).
#
# Die manuelle Marke {gl:Begriff} wird ebenfalls aufgeloest — sie erzwingt
# einen Link auch fuer Begriffe, die nicht in LINK_TEXTE stehen (z.B. weil
# sie zu mehrdeutig fuer Auto-Linking sind, aber an dieser Stelle als
# Fachbegriff gemeint sind).
_GLOSSAR_TOKEN_RE = re.compile(
r'`[^`]*`' # Inline-Code `...`
r'|\{[^}]*\}' # Marken {...}
r'|\[[^\]]*\]\([^)]*\)' # Markdown-Links [text](url)
)
_GLOSSAR_GL_RE = re.compile(r'\{gl:([^}]+)\}')
def resolve_glossar(text: str, fuer_seite: bool = False) -> str:
"""Verlinkt Glossar-Begriffe im Fließtext automatisch (erste Nennung je
Begriff im uebergebenen Text) und loest {gl:}-Marken auf."""
from glossar_eintraege_04 import LINK_TEXTE
# {gl:Begriff}-Marken zuerst: erzwingen einen Link auch fuer Begriffe,
# die nicht auto-linkbar sind. 'Begriff' ist der Anzeigetext im Link;
# das Ziel ist der Glossar-Eintrag mit diesem Namen als Indexmarke (oder
# falls vorhanden, der Slug aus LINK_TEXTE).
gl_treffer = 0
def _gl_repl(m: re.Match) -> str:
nonlocal gl_treffer
begriff = m.group(1)
slug = LINK_TEXTE.get(begriff) or _glossar_slug_fuer(begriff)
ziel = (f'anhang-glossar.md#gloss:{slug}' if fuer_seite
else f'#gloss:{slug}')
gl_treffer += 1
return f'[{begriff}]({ziel}){{.glossar-link}}'
text = _GLOSSAR_GL_RE.sub(_gl_repl, text)
if gl_treffer:
print(f"Glossar-Links (manuell): {gl_treffer} Marke(n) aufgeloest.")
# Auto-Linking: laengste Link-Texte zuerst (damit 'Value at Risk' vor
# 'VaR' gematcht wird, falls beide im Text stuenden).
texte = sorted(LINK_TEXTE.items(), key=lambda x: -len(x[0]))
lines = text.split('\n')
in_code_block = False
code_fence = None
seen: set[str] = set()
treffer = 0
for i, line in enumerate(lines):
stripped = line.lstrip()
# Code-Block: nicht verlinken, nur Fence-Ende erkennen.
if in_code_block:
if stripped.startswith(code_fence):
in_code_block = False
continue
fence_m = re.match(r'^(```+|~~~+)', stripped)
if fence_m:
code_fence = fence_m.group(1)
in_code_block = True
continue
# Ueberschriften: nicht verlinken (wuerde Inhaltsverzeichnis stoen).
if _HEADING_RE.match(stripped):
continue
# Zeile in Token-Segmente (Code/Marke/Link) und Fließtext splitten;
# nur in Fließtext-Segmenten wird gesucht.
new_parts: list[str] = []
last = 0
for m in _GLOSSAR_TOKEN_RE.finditer(line):
seg = line[last:m.start()]
seg, t = _glossar_in_segment(seg, texte, seen, fuer_seite)
treffer += t
new_parts.append(seg)
new_parts.append(m.group(0)) # Token unverändert
last = m.end()
rest = line[last:]
rest, t = _glossar_in_segment(rest, texte, seen, fuer_seite)
treffer += t
new_parts.append(rest)
lines[i] = ''.join(new_parts)
if treffer:
print(f"Glossar-Links (auto): {treffer} Begriff(e) verlinkt.")
return '\n'.join(lines)
def _glossar_slug_fuer(begriff: str) -> str:
"""Slug fuer einen Begriff, der nicht in LINK_TEXTE steht (fuer {gl:}."""
from glossar_eintraege_04 import glossar_slug
return glossar_slug(begriff)
def _glossar_in_segment(segment: str, texte: list[tuple[str, str]],
seen: set[str], fuer_seite: bool) -> tuple[str, int]:
"""Sucht im Fließtext-Segment nach Glossar-Begriffen und ersetzt das
erste Vorkommen je Begriff durch einen Link.
Alle Link-Texte werden im Original-Segment gesucht (mit finditer, also
alle Vorkommen), dann nach Position sortiert und nicht-ueberlappend
ersetzt laengste zuerst bei gleicher Position. So wird 'CP-SAT' als
Ganzes erkannt, bevor 'CP' (als Teilstring darin) ein eigenes Match
erzeugt. Wird ein Match wegen Ueberlappung übersprungen, wird das
naechste Vorkommen desselben Begriffs genommen (falls nicht ueberlappend).
"""
matches: list[tuple[int, int, str, str]] = []
for link_text, slug in texte:
if link_text in seen:
continue
pat = re.compile(r'\b' + re.escape(link_text) + r'\b')
for m in pat.finditer(segment):
matches.append((m.start(), m.end(), link_text, slug))
# Alle Vorkommen sammeln — falls das erste wegen Ueberlappung
# mit einem laengeren Begriff (z.B. 'CP-SAT-Solver' blockiert
# 'Solver') uebersprungen wird, greift das naechste.
if not matches:
return segment, 0
# Nach Position aufsteigend, bei gleicher Position laengste zuerst —
# damit 'CP-SAT-Solver' (start=X, len=13) vor 'CP-SAT' (start=X, len=6)
# und 'CP' (start=X, len=2) kommt.
matches.sort(key=lambda x: (x[0], -(x[1] - x[0])))
result: list[str] = []
last_end = 0
treffer = 0
for start, end, link_text, slug in matches:
if start < last_end:
continue # ueberlappt mit einem bereits gesetzten laengeren Match
if link_text in seen:
continue # dieser Begriff wurde schon weiter oben im Segment
# verlinkt (zweiter nicht-ueberlappender Treffer waere moeglich,
# aber 'erste Nennung je Begriff' gilt global pro Datei, nicht pro
# Segment)
result.append(segment[last_end:start])
ziel = (f'anhang-glossar.md#gloss:{slug}' if fuer_seite
else f'#gloss:{slug}')
result.append(f'[{link_text}]({ziel}){{.glossar-link}}')
seen.add(link_text)
treffer += 1
last_end = end
result.append(segment[last_end:])
return ''.join(result), treffer
# Wird als letzter Textbaustein angehaengt - landet damit unmittelbar vor dem
# von Pandoc automatisch erzeugten \end{document}. \phantomsection sorgt
# dafuer, dass hyperref einen Sprunganker fuer den (mit \section* erzeugten,
@ -1150,6 +1317,11 @@ def baue_markdown() -> str:
inhalt = inhalt.replace("Stand: {datum}", f"Stand: {datum}")
# Vorwort-Signatur 'Köln, im {vorwort_datum}' dynamisch setzen.
inhalt = inhalt.replace("{vorwort_datum}", aktuelles_monat_jahr())
# Glossar-Links pro Datei (erste Nennung je Begriff je Datei) —
# aber nicht im Glossar selbst: dort wuerde jeder Eintrag auf sich
# selbst verlinken.
if name != "94_Anhang_Glossar.md":
inhalt = resolve_glossar(inhalt, fuer_seite=False)
# Inhaltsverzeichnis vor dem Vorwort einfuegen (LaTeX-Rohblock)
if TOC_MARKE in inhalt and i == 0:
@ -2293,6 +2465,9 @@ def baue_kapitel_seiten(html_verz: str, datei_seite: dict, labels: dict,
text = text.replace("{vorwort_datum}", aktuelles_monat_jahr())
text = reflow_markdown(text)
text = resolve_numbering_seite(text, eintrag["seite"], labels, label_seite)
# Glossar-Links — aber nicht im Glossar selbst (sonst Self-Links).
if name != "94_Anhang_Glossar.md":
text = resolve_glossar(text, fuer_seite=True)
text = resolve_index(text)
text, mit_plotly = resolve_plotly(text, fuer_html=True)
text = resolve_karte(text, fuer_html=True)

View file

@ -40,7 +40,7 @@ HIER = os.path.dirname(os.path.abspath(__file__))
BASIS = os.path.dirname(HIER)
sys.path.insert(0, HIER)
from glossar_eintraege_04 import EINTRAEGE # noqa: E402
from glossar_eintraege_04 import EINTRAEGE, glossar_slug # noqa: E402
ZIEL = os.path.join(HIER, "94_Anhang_Glossar.md")
@ -70,11 +70,14 @@ BREITE = 96 # Zeilenbreite wie in den uebrigen Kapiteldateien
# hier eintragen.
NUR_IM_GLOSSAR = frozenset({
"Alternativoptima",
"Backtest",
"Binärvariable",
"CP-SAT",
"Calmar Ratio",
"DCP",
"Dualitätstheorie",
"Explainable OR",
"Graphen",
"Kanonische Standardform",
"Kohärentes Risikomaß",
"Lagrange-Multiplikator",
@ -86,7 +89,9 @@ NUR_IM_GLOSSAR = frozenset({
"Rebalancing",
"Regime-Shift",
"Relaxation",
"Scheduling",
"Schätzfehler",
"Solver",
"Solver-Status",
"Transaktionskosten",
"Unsicherheitsmenge",
@ -184,7 +189,13 @@ def baue() -> str:
zeilen += [f"## {buchstabe}", ""]
letzter = buchstabe
marke = "{idx:" + idx + "}" if idx else ""
zeilen += [absatz(f"**{name}**{marke}{text}{ziel}"), ""]
slug = glossar_slug(name)
# ::: {#gloss:<slug>} ... ::: erzeugt im HTML einen <div id="gloss-slug">-
# Anker und im PDF eine \hypertarget-Marke; beide sind das Ziel der
# Auto-Links aus dem Fließtext.
zeilen += [f"::: {{#gloss:{slug}}}",
absatz(f"**{name}**{marke}{text}{ziel}"),
":::", ""]
zeilen += ["---", "", SCHLUSS, ""]
return "\n".join(zeilen)

View file

@ -32,6 +32,41 @@ ueberschreibt sie und bricht ab, wenn er eine Handaenderung bemerkt.
EINTRAEGE = [
("Backtest", "Backtest",
"Rückblickender Test einer Strategie auf historischen Daten. Ein Backtest "
"ist nur so glaubwürdig wie seine Daten, seine Kostenannahme und seine "
"Trennung von Trainings- und Testzeitraum — "
"{ref:kap:handelsmaschine} zeigt die fünf Selbsttäuschungen, die ihn wertlos "
"machen.",
"{ref:kap:handelsmaschine}"),
("DCP (Disciplined Convex Programming)", "DCP",
"Regelwerk von CVXPY, das jede Variable als konkav, konvex oder affin "
"ausweist und aus der Kombination der Bausteine die Konkavität/Konvexität "
"der Zielfunktion und der Nebenbedingungen erschließt. {ref:kap:qp-nlp} "
"erklärt es; ein `DCPError` meldet eine Verletzung.",
"{ref:kap:qp-nlp}"),
("Graphen", "Graphen",
"Mathematische Struktur aus Knoten und Kanten; das Modellierungsmittel für "
"Netzwerke, Touren und Flüsse. {ref:kap:graphen} behandelt Min-Cost-Flow, "
"Matching und das Vehicle Routing Problem als Graphanwendungen.",
"{ref:kap:graphen}"),
("Scheduling", "Scheduling",
"Zuweisung von Tätigkeiten an Maschinen oder Personen unter "
"Ressourcen- und Reihenfolgebedingungen. {ref:kap:cpsat} behandelt es mit "
"CP-SAT (Intervallvariablen), {ref:kap:metaheuristiken} mit Simulated "
"Annealing und LNS.",
"{ref:kap:cpsat}"),
("Solver", "Solver",
"Softwarekomponente, die ein mathematisches Modell löst — also das "
"Optimierungsproblem in eine Lösung übersetzt. {ref:kap:oekosystem} "
"vergleicht die in diesem Buch verwendeten Solver (HiGHS, OR-Tools/CP-SAT, "
"CVXPY) und ihre Stärken.",
"{ref:kap:oekosystem}"),
("Absolutbetrag", "Absolutbetrag (Modellierungsmuster)",
"Modellierungsmuster für $|x-z|$: Der Betrag selbst ist nicht linear, lässt sich aber "
"durch eine Hilfsvariable $d$ mit den beiden Bedingungen $x-z \\le d$ und $z-x \\le d$ "
@ -1418,3 +1453,96 @@ EINTRAEGE = [
"{ref:anhang:fehlerdiagnose}"),
]
# --- Auto-Link-Infrastruktur ---------------------------------------------
# Der Build verlinkt Glossar-Begriffe im Fließtext automatisch — die
# {gl:}-Marke von Hand ist nur Ausnahme, nicht Regelfall. Diese beiden
# Werte liefern die dazu noetigen Daten:
#
# glossar_slug(name) Eine stabile HTML-ID je Eintrag ('cp-sat', 'var'…).
# Die Glossar-Datei traegt denselben Slug als
# {#gloss:<slug>}-Anchor; der Link im Fließtext
# zeigt dorthin.
# LINK_TEXTE {Text_im_Buch: slug} — alles, was der Build durch
# einen Link ersetzt. Generiert aus den EINTRAEGEN:
# der Anzeigename je Eintrag, plus die Abkürzung,
# falls der Name eine enthaelt (z.B. 'VaR' neben
# 'Value at Risk').
#
# Einträge, die NICHT auto-verlinkt werden sollen, fehlen hier einfach.
# 'Variable', 'Lösung', 'Matrix' etc. sind zu mehrdeutig als Fließtext und
# werden nicht auto-verlinkt — wer sie verlinkt haben will, setzt {gl:}.
import re as _re
_UMLAUT = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae",
"Ö": "Oe", "Ü": "Ue", "ß": "ss"})
def glossar_slug(name: str) -> str:
"""ASCII-sicherer Slug fuer den Glossar-Anchor (wie die sec:-Labels)."""
s = name.translate(_UMLAUT).lower()
s = _re.sub(r"[^a-z0-9]+", "-", s).strip("-")
return s or "glossar"
def _abkuerzung_aus_name(name: str) -> str | None:
"""Extrahiert die Abkürzung aus einem Glossar-Namen.
Zwei Fälle:
* 'Value at Risk (VaR)' Abkürzung im Klammerinhalt (kurz, überwiegend
Großbuchstaben, z.B. VaR, CVaR, KKT, MILP).
* 'EVPI (Expected Value ...)' Abkürzung VOR der Klammer, wenn der
Teil vor der Klammer durchgehend Großbuchstaben ist (z.B. EVPI, IIS,
TSP, DTO, GIL, SAT).
"""
m = _re.search(r"\(([^)]+)\)\s*$", name)
if not m:
return None
k = m.group(1).strip()
if len(k) <= 15 and sum(1 for c in k if c.isupper()) >= len(k) * 0.5:
return k
# Fall 2: Teil vor der Klammer ist die Abkürzung (z.B. 'EVPI (... )').
vor = name[: m.start()].strip()
if vor and len(vor) <= 8 and vor.isupper() and vor.isalpha():
return vor
return None
# Begriffe, die zwar im Glossar stehen, aber im Fließtext NICHT
# auto-verlinkt werden (zu mehrdeutig — 'Lösung' als Lösung eines
# Gleichungssystems vs. Lösung einer Aufgabe vs. Optimallösung).
# Wer sie verlinkt haben will, setzt {gl:Lösung} — das ist die Ausnahme.
_NICHT_AUTO_LINKEN = frozenset({
"Binärvariable", "Entscheidungsvariable", "Intervallvariable",
"Schlupfvariable", "Semikontinuierliche Variable",
"Lösung", "Lösung (Modellierungsmuster)", "Instabile Lösung",
"Unsinniges Ergebnis", "Verdächtig guter Backtest",
"Matrix", "Matrixform", "Matrix-Vektor-Produkt",
"Nebenbedingung (Constraint)",
"Widersprüchliche Solver",
# Fehlerbilder: werden im Glossar geführt, aber im Fließtext nicht
# jedes Vorkommen verlinkt (sonst wird aus jedem 'Tippfehler' ein Link).
})
# Abkürzungen, die nicht auto-verlinkt werden — 'OR' ist 2 Zeichen und
# taucht als Teilstring in 'OR-Tools' auf (Produktname, nicht die
# Abkürzung für Operations Research). Wer 'OR' als Fachbegriff verlinken
# will, setzt {gl:OR}. Die anderen 2-Zeichen-Abkürzungen (LP, QP, DP, CP)
# kommen im Text nur als Fachbegriff vor und bleiben auto-verlinkt.
_ZU_KURZ_ABK = frozenset({"OR"})
LINK_TEXTE: dict[str, str] = {}
for _name, _idx, _text, _ziel in EINTRAEGE:
if _name in _NICHT_AUTO_LINKEN:
continue
_slug = glossar_slug(_name)
# Anzeigename immer (z.B. 'Schattenpreis' -> 'schattenpreis').
LINK_TEXTE[_name] = _slug
# Abkürzung falls vorhanden (z.B. 'VaR' -> 'value-at-risk-var').
_abk = _abkuerzung_aus_name(_name)
if _abk and _abk not in _ZU_KURZ_ABK:
LINK_TEXTE[_abk] = _slug