From 82ce30ca72088bd93e94a7e470922f13a70ec00b Mon Sep 17 00:00:00 2001
From: dschlueter
Date: Sun, 11 Oct 2026 21:32:06 +0200
Subject: [PATCH] =?UTF-8?q?feat:=20=C3=84nderungsbericht=20listet=20eigenm?=
=?UTF-8?q?=C3=A4chtige=20Modell=C3=A4nderungen=20=E2=80=9EZu=20pr=C3=BCfe?=
=?UTF-8?q?n=E2=80=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Verglichen wird der Text nach den Glossar-Ersetzungen mit dem Ergebnis des
Modells. Gemeldet werden ersetzte gültige Wörter (Systemwortliste
/usr/share/dict/ngerman bzw. american-english), stark veränderte, eingefügte
und gestrichene Wörter; Hörfehler-Korrekturen, reine Zusammen-/Getrennt-
schreibung, Füllwörter und Glossarwörter nicht. Eigener Abschnitt im
HTML-Bericht, Rahmen im Text, Liste in der Konsole. aenderungen.py versteht
dafür -g und -l.
Co-Authored-By: Claude Opus 5.5
---
README.md | 39 +++++-
abschrift_korrigieren.py | 14 +-
aenderungen.py | 196 ++++++++++++++++++++++++++--
tests/conftest.py | 16 +++
tests/test_abschrift_korrigieren.py | 20 +++
tests/test_aenderungen.py | 118 +++++++++++++++++
6 files changed, 389 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index 1410d39..01b2113 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,9 @@ Die Werkzeuge sind einzeln oder hintereinander nutzbar:
(`ollama pull qwen3:32b`)
`abschrift_korrigieren.py` und `glossar_vorschlag.py` prüfen beides vor dem Start und
brechen sonst mit einem Hinweis ab (`sudo systemctl start ollama` bzw. `ollama pull …`)
+- für den Änderungsbericht eine Wortliste: `/usr/share/dict/ngerman` (Paket `wngerman`),
+ für englische Beiträge `/usr/share/dict/american-english` (Paket `wamerican`).
+ Fehlt sie, gibt es einen Hinweis und die Prüfung „gültiges Wort ersetzt“ entfällt
Für gute Geschwindigkeit muss das Modell komplett in den Grafikspeicher passen.
Mit `OLLAMA_NUM_PARALLEL=1` braucht `qwen3:32b` bei 16k Kontext rund 24 GB
@@ -272,19 +275,43 @@ Jeder Lauf von `abschrift_korrigieren.py` schreibt neben die Ausgabe
`.aenderungen.html` (aus `rede.korrigiert.txt` wird `rede.aenderungen.html`)
und zeigt die häufigsten Änderungen in der Konsole. Der Bericht enthält:
+- **Zu prüfen:** eigenmächtige Änderungen des Modells, die den Sinn ändern können,
+ mit Grund und Zusammenhang (siehe unten),
- alle Änderungen nach Häufigkeit (`gedeiliche → gedeihliche`, `∅ → ist` für Einfügungen),
- den korrigierten Text mit seinen Absätzen: ersetzte Wörter gelb mit dem Original
- dahinter, Einfügungen grün, Streichungen rot durchgestrichen, `[prüfen: …]` orange.
+ dahinter, Einfügungen grün, Streichungen rot durchgestrichen, `[prüfen: …]` orange,
+ Stellen „zu prüfen“ rot umrahmt (gestrichene als ⚠, Grund beim Darüberfahren).
Nicht markiert werden Groß-/Kleinschreibung, Satzzeichen (auch eingefügte
Gedankenstriche) und Schreibweisen wie „z.B.“ → „z. B.“. So bleiben die Wortänderungen
übrig, die man prüfen sollte, gerade eigenmächtige wie „gedeiliche“ → „geregelte“.
-Für vorhandene Dateien gibt es das eigene Skript:
+### Zu prüfen
+
+Verglichen wird hier der Text, den das Modell bekommen hat (nach den festen
+Ersetzungen des Glossars), mit seinem Ergebnis. Gemeldet werden:
+
+| Grund | Beispiel |
+|---|---|
+| gültiges Wort ersetzt | „Professor“ → „Professorin“, „unterschreiben“ → „unterscheiden“ (das alte Wort steht in der Wortliste, war also kein Hörfehler) |
+| stark verändert | „CT1“ → „ZDF“ (Ähnlichkeit unter 60 %) |
+| eingefügt | „∅“ → „ist“ |
+| gestrichen | „Sicherungskonzepte geht“ → „∅“ (Füllwörter wie „äh“ nicht) |
+
+Nicht gemeldet werden Hörfehler-Korrekturen, die ähnlich bleiben
+(„Gradwanderung“ → „Gratwanderung“), reine Zusammen-, Getrennt- und
+Bindestrich-Schreibung („Clan Kriminalität“ → „Clankriminalität“) und neue Wörter,
+die im Glossar stehen. Die Liste erscheint auch in der Konsole. Kleine
+Grammatik-Korrekturen wie „das“ → „dass“ tauchen ebenfalls auf; ein Blick genügt.
+
+Für vorhandene Dateien gibt es das eigene Skript. Mit denselben Glossaren wie bei
+der Korrektur gelten deren Ersetzungen und Begriffe nicht als verdächtig:
```bash
./aenderungen.py -a rede.txt -b rede.korrigiert.txt # → rede.aenderungen.html
./aenderungen.py -a rede.de.vtt -b rede.korrigiert.txt -o bericht.html
+./aenderungen.py -a rede.txt -b rede.korrigiert.txt -g krone-schmalz.glossar.txt -g rede.glossar.txt
+./aenderungen.py -a talk.txt -b talk.korrigiert.txt -l en # englische Wortliste
```
## Glossar
@@ -386,7 +413,9 @@ unverändert, 3–10 Sätze), Zwischenspeicher/Fortsetzen und die Kommandozeile.
`tests/test_glossar_vorschlag.py` prüft Filter (nur Funde im Text, keine bekannten),
Ausgabeformat (gültiges Glossar, Unsicheres auskommentiert) und Zwischenspeicher.
`tests/test_aenderungen.py` prüft den Wortvergleich (Satzzeichen, Groß-/Kleinschreibung
-und „z. B.“ zählen nicht) und den HTML-Bericht (vollständiger Text, Absätze, Escaping).
+und „z. B.“ zählen nicht), die Liste „Zu prüfen“ (Gründe, Ausnahmen, Glossar,
+fehlende Wortliste) und den HTML-Bericht (vollständiger Text, Absätze, Escaping).
+Die Tests nutzen eine kleine eigene Wortliste statt der des Systems.
`tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl
(Original-Audio, manuell vor automatisch) und Fehlerfälle.
`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und
@@ -418,7 +447,9 @@ Oben in `abschrift_korrigieren.py`:
- Das Modell ändert gelegentlich Wörter eigenmächtig, auch sinnverändernd
(Magdeburg-Rede ohne Glossar: „Professor“ → „Professorin“, „gedeiliche“ →
- „geregelte“ statt „gedeihliche“). Stichproben lohnen sich; gefundene Fälle
+ „geregelte“ statt „gedeihliche“). Die Liste „Zu prüfen“ im Bericht findet die
+ meisten solcher Fälle, aber nicht alle: Ein falsch erkanntes Wort, das das Modell
+ durch ein ähnliches, aber falsches ersetzt, fällt nicht auf. Gefundene Fälle
gehören ins Glossar.
- `qwen3:32b` ohne Denkmodus erkennt Hörfehler ohne Glossar nur teilweise und
setzt kaum `[prüfen: …]`-Marken. Bekannte Fehler gehören deshalb ins Glossar.
diff --git a/abschrift_korrigieren.py b/abschrift_korrigieren.py
index 5520b77..71843fe 100755
--- a/abschrift_korrigieren.py
+++ b/abschrift_korrigieren.py
@@ -12,7 +12,9 @@ import time
import urllib.error
import urllib.request
-from aenderungen import REVIEW_MARK, print_summary, report_path, write_report
+from aenderungen import (
+ REVIEW_MARK, glossary_words, print_summary, report_path, write_report,
+)
from extract_text import fetch_text
from vtt2txt import (
add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments,
@@ -616,6 +618,9 @@ def main():
).split("\n")
segments = [(header, body) for (header, _), body in zip(segments, bodies)]
+ # What the model gets; only its own changes against this count as suspect
+ model_text = join_segments(segments)
+
blocks = [(header, split_chunks(body)) for header, body in segments]
total = sum(len(chunks) for _, chunks in blocks)
@@ -672,7 +677,10 @@ def main():
if not args.kein_bericht:
report = report_path(target)
- comparison = write_report(raw_text, text, report, target.name)
+ comparison = write_report(
+ raw_text, text, report, target.name, model_input=model_text,
+ known=glossary_words(terms, replacements), lang=lang,
+ )
print_summary(comparison)
print(f"Bericht: {report}")
@@ -680,6 +688,8 @@ def main():
print(f"\n{len(warnings)} Abschnitt(e) mit auffälliger Wortzahl:")
for message in warnings:
print(f"- {message}")
+ if not args.kein_bericht and comparison.suspects:
+ print(f"\n{len(comparison.suspects)} Stelle(n) zu prüfen, siehe {report}")
if __name__ == "__main__":
diff --git a/aenderungen.py b/aenderungen.py
index 68e0930..adb7fe6 100755
--- a/aenderungen.py
+++ b/aenderungen.py
@@ -4,6 +4,7 @@ ersetzt, eingefügt oder gestrichen? Ausgabe als HTML-Bericht."""
from collections import Counter
from dataclasses import dataclass, field
from difflib import SequenceMatcher
+from functools import lru_cache
from pathlib import Path
import argparse
import html
@@ -21,6 +22,22 @@ ABBREVIATION = re.compile(r"\w\.") # one part of "z. B.", "d. h."
ABBREVIATION_END = re.compile(r"(?:^|\s)\w\.$")
EMPTY = "∅"
+# System word lists (Debian/Ubuntu packages wngerman, wamerican): a replaced
+# word found there was a real word, not a recognition error
+WORDLISTS = {
+ "de": Path("/usr/share/dict/ngerman"),
+ "en": Path("/usr/share/dict/american-english"),
+}
+WORDLIST_PACKAGES = {"de": "wngerman", "en": "wamerican"}
+MIN_SIMILARITY = 0.6 # below this a replacement counts as heavily changed
+FILLERS = {"äh", "ähm", "öh", "öhm", "hm", "hmm", "uh", "uhm", "um", "er", "erm"}
+CONTEXT_WORDS = 6
+
+VALID_WORD = "gültiges Wort ersetzt"
+CHANGED_A_LOT = "stark verändert"
+INSERTED = "eingefügt"
+DELETED = "gestrichen"
+
def add_token(words, token):
"""Append a token; attach punctuation-only tokens ("–") to the previous word
@@ -51,6 +68,7 @@ class Comparison:
paragraph: list # paragraph number of each corrected word
marks: dict = field(default_factory=dict) # word index -> review marks after it
opcodes: list = field(default_factory=list)
+ suspects: list = field(default_factory=list) # Suspect, see review_candidates
def changes(self):
"""Yield (tag, raw words, corrected words) for every difference."""
@@ -85,6 +103,80 @@ def compare(raw, corrected):
return Comparison(raw_words, words, paragraph, marks, matcher.get_opcodes())
+@dataclass
+class Suspect:
+ """A change by the model worth a look; j1:j2 are corrected word indices."""
+ j1: int
+ j2: int
+ old: str
+ new: str
+ reason: str
+
+
+@lru_cache(maxsize=None)
+def load_wordlist(lang):
+ """Lower-case words of the system word list, or None if it is missing.
+
+ Read once per run (the German list has some 350,000 words).
+ """
+ path = WORDLISTS.get(lang)
+ if path is None or not path.is_file():
+ package = WORDLIST_PACKAGES.get(lang)
+ hint = f" (Paket {package})" if package else ""
+ print(
+ f"Hinweis: keine Wortliste für „{lang}“{hint}; "
+ "ersetzte gültige Wörter werden nicht erkannt."
+ )
+ return None
+ with path.open(encoding="utf-8", errors="replace") as words:
+ return {line.strip().lower() for line in words if line.strip()}
+
+
+def glossary_words(terms, replacements):
+ """Normalized words of glossary terms and replacement targets."""
+ texts = list(terms) + [right for _, right in replacements]
+ return {normalize(word) for text in texts for word in text.split()}
+
+
+def spelling_key(words):
+ """Letters only: joining, splitting and hyphens make no difference."""
+ return re.sub(r"[\W_]+", "", "".join(words).lower())
+
+
+def review_candidates(comparison, wordlist=None, known=()):
+ """Changes the model made on its own that may alter the meaning.
+
+ Not reported: changes of spacing, hyphens and punctuation only, new words
+ from the glossary, deleted filler words, and replacements of words not in
+ the word list (recognition errors) that stay similar.
+ """
+ suspects = []
+ for tag, i1, i2, j1, j2 in comparison.opcodes:
+ if tag == "equal":
+ continue
+ old, new = comparison.raw[i1:i2], comparison.words[j1:j2]
+ if new and all(normalize(word) in known for word in new):
+ continue
+ if tag == "delete":
+ if all(normalize(word) in FILLERS for word in old):
+ continue
+ reason = DELETED
+ elif tag == "insert":
+ reason = INSERTED
+ else:
+ old_key, new_key = spelling_key(old), spelling_key(new)
+ if old_key == new_key:
+ continue
+ if wordlist is not None and all(normalize(w) in wordlist for w in old):
+ reason = VALID_WORD
+ elif SequenceMatcher(None, old_key, new_key).ratio() < MIN_SIMILARITY:
+ reason = CHANGED_A_LOT
+ else:
+ continue
+ suspects.append(Suspect(j1, j2, " ".join(old), " ".join(new), reason))
+ return suspects
+
+
def change_summary(comparison):
"""[(old, new, count)] of all changes, most frequent first."""
counts = Counter(
@@ -95,10 +187,10 @@ def change_summary(comparison):
STYLE = """
:root { --bg: #fff; --fg: #1d1d1f; --muted: #6e6e73; --rep: #fff3b0; --ins: #d9f7d9;
- --del: #fde2e2; --check: #ffe0b3; --line: #ddd; }
+ --del: #fde2e2; --check: #ffe0b3; --line: #ddd; --warn: #d70015; }
@media (prefers-color-scheme: dark) {
:root { --bg: #1c1c1e; --fg: #f2f2f7; --muted: #a1a1a6; --rep: #5c4d00; --ins: #1f4d1f;
- --del: #5c1f1f; --check: #5c3a00; --line: #3a3a3c; }
+ --del: #5c1f1f; --check: #5c3a00; --line: #3a3a3c; --warn: #ff6961; }
}
body { background: var(--bg); color: var(--fg); font: 16px/1.6 system-ui, sans-serif;
max-width: 50rem; margin: 0 auto; padding: 1.5rem 1rem; }
@@ -112,6 +204,8 @@ ins { background: var(--ins); text-decoration: none; }
del { background: var(--del); }
.orig { color: var(--muted); font-size: 0.8em; }
.check { background: var(--check); font-size: 0.85em; }
+.warn { outline: 2px solid var(--warn); outline-offset: 1px; border-radius: 2px; }
+td.ctx { color: var(--muted); } td.ctx b { color: var(--fg); }
"""
@@ -126,6 +220,29 @@ def render_html(comparison, title):
f"| {esc(new) or EMPTY} | "
for old, new, count in summary
)
+ suspects = comparison.suspects
+ suspect_rows = "\n".join(
+ f"| {esc(s.reason)} | {esc(s.old) or EMPTY} | "
+ f"{esc(s.new) or EMPTY} | "
+ f"{suspect_context(comparison, s)} |
"
+ for s in suspects
+ )
+ if suspects:
+ suspect_table = (
+ "| Grund | vorher | korrigiert | "
+ f"Zusammenhang |
\n{suspect_rows}\n
"
+ )
+ else:
+ suspect_table = "Keine auffälligen Änderungen gefunden.
"
+
+ # Suspect words are framed; a suspect deletion shows ⚠ after the word before it
+ warn_words, warn_after = {}, {}
+ for s in suspects:
+ note = esc(f"{s.reason}: „{s.old or EMPTY}“ → „{s.new or EMPTY}“", quote=True)
+ if s.j1 == s.j2:
+ warn_after.setdefault(max(s.j1 - 1, 0), []).append(note)
+ for j in range(s.j1, s.j2):
+ warn_words[j] = note
# Corrected text with its paragraphs; each word knows its paragraph
parts = []
@@ -136,9 +253,13 @@ def render_html(comparison, title):
if comparison.paragraph[j] != current:
current = comparison.paragraph[j]
parts.append("
\n")
+ if j in warn_words:
+ piece = f"{piece}"
parts.append(piece)
for mark in comparison.marks.get(j, []):
parts.append(f"{esc(mark)}")
+ for note in warn_after.get(j, []):
+ parts.append(f"⚠")
for tag, i1, i2, j1, j2 in comparison.opcodes:
old = " ".join(comparison.raw[i1:i2])
@@ -166,9 +287,16 @@ def render_html(comparison, title):
Änderungen: {esc(title)}
{len(comparison.raw)} Wörter Rohtext, {len(comparison.words)} Wörter korrigiert.
{counts['replace']} ersetzt, {counts['insert']} eingefügt, {counts['delete']} gestrichen,
-{review_marks} Prüfmarken. Groß-/Kleinschreibung und Satzzeichen sind nicht markiert.
+{review_marks} Prüfmarken, {len(suspects)} Stellen zu prüfen.
+Groß-/Kleinschreibung und Satzzeichen sind nicht markiert.
ersetzt (Original)
-eingefügt gestrichen [prüfen: …]
+eingefügt gestrichen [prüfen: …]
+zu prüfen
+Zu prüfen ({len(suspects)})
+Eigenmächtige Änderungen des Modells, die den Sinn ändern können:
+gültige Wörter ersetzt, stark veränderte, eingefügte oder gestrichene Wörter.
+„vorher“ ist der Text, den das Modell bekommen hat (nach den Glossar-Ersetzungen).
+{suspect_table}
Alle Änderungen nach Häufigkeit
| × | Rohtext | korrigiert |
{rows}
@@ -179,7 +307,20 @@ def render_html(comparison, title):
"""
-def print_summary(comparison, limit=15):
+def suspect_context(comparison, suspect):
+ """HTML: the change in bold with a few corrected words around it."""
+ esc = html.escape
+ words = comparison.words
+ before = " ".join(words[max(0, suspect.j1 - CONTEXT_WORDS):suspect.j1])
+ after = " ".join(words[suspect.j2:suspect.j2 + CONTEXT_WORDS])
+ if suspect.j1 == suspect.j2:
+ middle = f"{esc(suspect.old)}"
+ else:
+ middle = f"{esc(' '.join(words[suspect.j1:suspect.j2]))}"
+ return f"… {esc(before)} {middle} {esc(after)} …"
+
+
+def print_summary(comparison, limit=15, suspect_limit=10):
summary = change_summary(comparison)
print(f"Änderungen gegenüber dem Rohtext: {sum(c for _, _, c in summary)}")
for old, new, count in summary[:limit]:
@@ -187,9 +328,23 @@ def print_summary(comparison, limit=15):
if len(summary) > limit:
print(f" … und {len(summary) - limit} weitere, siehe Bericht")
+ suspects = comparison.suspects
+ print(f"Zu prüfen: {len(suspects)} Stellen")
+ for s in suspects[:suspect_limit]:
+ print(f" {s.reason}: „{s.old or EMPTY}“ → „{s.new or EMPTY}“")
+ if len(suspects) > suspect_limit:
+ print(f" … und {len(suspects) - suspect_limit} weitere, siehe Bericht")
-def write_report(raw, corrected, target, title):
+
+def write_report(raw, corrected, target, title, model_input=None, known=(), lang="de"):
+ """Write the HTML report; return the comparison with the suspect changes.
+
+ model_input is the text the model got (raw text after glossary
+ replacements); only changes against it count as suspect.
+ """
comparison = compare(raw, corrected)
+ by_model = compare(raw if model_input is None else model_input, corrected)
+ comparison.suspects = review_candidates(by_model, load_wordlist(lang), set(known))
target.write_text(render_html(comparison, title), encoding="utf-8")
return comparison
@@ -221,8 +376,17 @@ def parse_args():
"-o", "--output", type=Path,
help="HTML-Bericht (Standard: .aenderungen.html)",
)
+ parser.add_argument(
+ "-g", "--glossar", type=Path, action="append", default=[],
+ help="Glossar(e) der Korrektur: Ersetzungen und Begriffe gelten nicht als "
+ "verdächtig (mehrfach angeben wie bei abschrift_korrigieren.py)",
+ )
+ parser.add_argument(
+ "-l", "--lang", default="de",
+ help="Sprache des Beitrags für die Wortliste (de, en; Standard: de)",
+ )
args = parser.parse_args()
- for path in (args.roh, args.korrigiert):
+ for path in (args.roh, args.korrigiert, *args.glossar):
if not path.is_file():
parser.error(f"Datei nicht gefunden: {path}")
if args.output is None:
@@ -231,9 +395,25 @@ def parse_args():
def main():
+ # abschrift_korrigieren imports this module: import it only when needed
+ from abschrift_korrigieren import apply_replacements, read_glossary
+
args = parse_args()
+ raw = read_raw(args.roh)
+ terms, replacements = [], []
+ for glossary in args.glossar:
+ more_terms, more_replacements = read_glossary(glossary)
+ terms += more_terms
+ replacements += more_replacements
+ # Like abschrift_korrigieren.py: replacements work on running text
+ model_input = apply_replacements(" ".join(raw.split()), replacements)
+
corrected = args.korrigiert.read_text(encoding="utf-8")
- comparison = write_report(read_raw(args.roh), corrected, args.output, args.korrigiert.name)
+ comparison = write_report(
+ raw, corrected, args.output, args.korrigiert.name,
+ model_input=model_input, known=glossary_words(terms, replacements),
+ lang=args.lang,
+ )
print_summary(comparison)
print(f"Bericht: {args.output}")
diff --git a/tests/conftest.py b/tests/conftest.py
index 1a5eccc..667f80f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -18,6 +18,22 @@ def ollama_available(request, monkeypatch):
monkeypatch.setattr(module, "check_ollama", lambda model: None)
+@pytest.fixture(autouse=True)
+def small_wordlist(tmp_path_factory, monkeypatch):
+ """Reports use a small word list instead of the system one."""
+ import aenderungen
+ folder = tmp_path_factory.mktemp("wordlist")
+ de = folder / "de.txt"
+ de.write_text(
+ "\n".join(["Professor", "das", "dass", "ist", "Haus", "unterschreiben", "Bar"]),
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(aenderungen, "WORDLISTS", {"de": de, "en": folder / "fehlt.txt"})
+ aenderungen.load_wordlist.cache_clear()
+ yield
+ aenderungen.load_wordlist.cache_clear()
+
+
@pytest.fixture
def fake_yt_dlp():
"""Factory for a subprocess.run stand-in.
diff --git a/tests/test_abschrift_korrigieren.py b/tests/test_abschrift_korrigieren.py
index 6d38d58..497f819 100644
--- a/tests/test_abschrift_korrigieren.py
+++ b/tests/test_abschrift_korrigieren.py
@@ -336,6 +336,26 @@ def test_main_writes_change_report(sample_copy, monkeypatch, capsys):
assert f"Bericht: {report}" in out
+def test_main_report_suspects_only_model_changes(sample_copy, monkeypatch, capsys):
+ glossary = sample_copy.with_name("g.txt")
+ glossary.write_text("Rammstein => Ramstein\n", encoding="utf-8")
+
+ def correct(chunk, index, system):
+ return chunk.strip().replace(" das ", " dies ", 1)
+
+ monkeypatch.setattr(ak, "correct_chunk", correct)
+ monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
+ run_main(monkeypatch, "-i", sample_copy, "-g", glossary)
+
+ page = sample_copy.with_name("ramstein.de.aenderungen.html").read_text(encoding="utf-8")
+ section = page.split("Zu prüfen")[1].split("Alle Änderungen")[0]
+ assert "Rammstein" in page and "Rammstein" not in section
+ assert "dies" in section
+ out = capsys.readouterr().out
+ assert "gültiges Wort ersetzt: „das“ → „dies“" in out
+ assert "Stelle(n) zu prüfen, siehe" in out
+
+
def test_main_without_change_report(sample_copy, monkeypatch):
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
diff --git a/tests/test_aenderungen.py b/tests/test_aenderungen.py
index 2dcd7d3..49fdfef 100644
--- a/tests/test_aenderungen.py
+++ b/tests/test_aenderungen.py
@@ -117,3 +117,121 @@ def test_cli_missing_file(tmp_path, monkeypatch, capsys):
with pytest.raises(SystemExit):
ae.main()
assert "Datei nicht gefunden" in capsys.readouterr().err
+
+
+# === Suspect changes ===
+
+WORDS = {"professor", "das", "dass", "ist", "haus", "unterschreiben", "bar"}
+
+
+def suspects(model_input, corrected, wordlist=WORDS, known=()):
+ found = ae.review_candidates(ae.compare(model_input, corrected), wordlist, set(known))
+ return [(s.reason, s.old, s.new) for s in found]
+
+
+@pytest.mark.parametrize("model_input, corrected, expected", [
+ ("Der Professor kam", "Der Professorin kam", [(ae.VALID_WORD, "Professor", "Professorin")]),
+ ("wir unterschreiben das", "wir unterscheiden das",
+ [(ae.VALID_WORD, "unterschreiben", "unterscheiden")]),
+ ("im CT1 heute", "im ZDF heute", [(ae.CHANGED_A_LOT, "CT1", "ZDF")]),
+ ("er kam heute", "er ist kam heute", [(ae.INSERTED, "", "ist")]),
+ ("er kam heute", "er kam", [(ae.DELETED, "heute", "")]),
+])
+def test_review_candidates_reports(model_input, corrected, expected):
+ assert suspects(model_input, corrected) == expected
+
+
+@pytest.mark.parametrize("model_input, corrected", [
+ ("das Kremmel heute", "das Kreml heute"), # recognition error
+ ("die Gradwanderung", "die Gratwanderung"),
+ ("die Clan Kriminalität", "die Clankriminalität"), # spacing only
+ ("KI generierte Videos", "KI-generierte Videos"), # hyphen only
+ ("er äh kam ähm heute", "er kam heute"), # fillers
+ ("das ist gut", "Das ist gut."), # case, punctuation
+])
+def test_review_candidates_ignores(model_input, corrected):
+ assert suspects(model_input, corrected) == []
+
+
+def test_review_candidates_glossary_words_are_known():
+ known = ae.glossary_words(["Egon Bahr"], [("Bars", "Bahrs")])
+ assert known == {"egon", "bahr", "bahrs"}
+ assert suspects("Bar sagte", "Bahr sagte", known=known) == []
+ assert suspects("Bar sagte", "Bahr sagte") == [(ae.VALID_WORD, "Bar", "Bahr")]
+
+
+def test_review_candidates_without_wordlist():
+ # Similar replacements of real words go unnoticed, the rest still counts
+ assert suspects("Der Professor kam", "Der Professorin kam", wordlist=None) == []
+ assert suspects("im CT1 heute", "im ZDF heute", wordlist=None) == [
+ (ae.CHANGED_A_LOT, "CT1", "ZDF")
+ ]
+
+
+def test_review_candidates_indices_point_into_corrected_words():
+ comparison = ae.compare("a b Professor c d", "A b Professorin c d.")
+ [suspect] = ae.review_candidates(comparison, WORDS, set())
+ assert comparison.words[suspect.j1:suspect.j2] == ["Professorin"]
+
+
+def test_load_wordlist(capsys):
+ assert "professor" in ae.load_wordlist("de")
+ assert ae.load_wordlist("en") is None
+ assert "keine Wortliste für „en“ (Paket wamerican)" in capsys.readouterr().out
+
+
+def suspect_section(page):
+ return page.split("Zu prüfen")[1].split("Alle Änderungen")[0]
+
+
+def test_report_suspects_only_against_model_input(tmp_path, capsys):
+ target = tmp_path / "bericht.html"
+ # Glossary turned "Kremmel" into "Kreml" before the model; the model
+ # replaced "Professor" and deleted "heute" on its own
+ comparison = ae.write_report(
+ "der Professor im Kremmel heute dort", "Der Professorin im Kreml dort.", target,
+ "rede", model_input="der Professor im Kreml heute dort",
+ )
+ page = target.read_text(encoding="utf-8")
+ section = suspect_section(page)
+
+ assert [s.reason for s in comparison.suspects] == [ae.VALID_WORD, ae.DELETED]
+ assert "Zu prüfen (2)" in page
+ assert "Professorin" in section and "Kremmel" not in section
+ assert "" in page
+ assert "⚠" in page
+ # Framing changes nothing about the text itself
+ assert report_text(page) == "Der Professorin im Kreml ⚠ dort.".split()
+
+ ae.print_summary(comparison)
+ out = capsys.readouterr().out
+ assert "Zu prüfen: 2 Stellen" in out
+ assert "gültiges Wort ersetzt: „Professor“ → „Professorin“" in out
+
+
+def test_report_without_suspects(tmp_path):
+ target = tmp_path / "bericht.html"
+ comparison = ae.write_report("das Kremmel", "Das Kreml.", target, "rede")
+ assert comparison.suspects == []
+ assert "Keine auffälligen Änderungen" in suspect_section(target.read_text(encoding="utf-8"))
+
+
+def test_cli_glossary(tmp_path, monkeypatch, capsys):
+ raw = tmp_path / "rede.txt"
+ raw.write_text("das Kremmel und\nBar heute\n", encoding="utf-8")
+ corrected = tmp_path / "rede.korrigiert.txt"
+ corrected.write_text("Das Kreml und Bahr heute.\n", encoding="utf-8")
+ glossary = tmp_path / "g.txt"
+ glossary.write_text("Kremmel => Kreml\nEgon Bahr\n", encoding="utf-8")
+
+ argv = ["aenderungen.py", "-a", str(raw), "-b", str(corrected)]
+ monkeypatch.setattr(sys, "argv", argv)
+ ae.main()
+ assert "gültiges Wort ersetzt: „Bar“ → „Bahr“" in capsys.readouterr().out
+
+ monkeypatch.setattr(sys, "argv", [*argv, "-g", str(glossary)])
+ ae.main()
+ out = capsys.readouterr().out
+ assert "Zu prüfen: 0 Stellen" in out
+ # The report still compares against the raw transcript
+ assert "1× „Kremmel“ → „Kreml“" in out