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 = ( + "" + f"\n{suspect_rows}\n
GrundvorherkorrigiertZusammenhang
" + ) + 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

{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

×Rohtextkorrigiert