feat: Änderungsbericht listet eigenmächtige Modelländerungen „Zu prüfen“

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-11 21:32:06 +02:00
commit 82ce30ca72
6 changed files with 389 additions and 14 deletions

View file

@ -25,6 +25,9 @@ Die Werkzeuge sind einzeln oder hintereinander nutzbar:
(`ollama pull qwen3:32b`) (`ollama pull qwen3:32b`)
`abschrift_korrigieren.py` und `glossar_vorschlag.py` prüfen beides vor dem Start und `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 …`) 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. 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 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
`<Name>.aenderungen.html` (aus `rede.korrigiert.txt` wird `rede.aenderungen.html`) `<Name>.aenderungen.html` (aus `rede.korrigiert.txt` wird `rede.aenderungen.html`)
und zeigt die häufigsten Änderungen in der Konsole. Der Bericht enthält: 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), - 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 - 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 Nicht markiert werden Groß-/Kleinschreibung, Satzzeichen (auch eingefügte
Gedankenstriche) und Schreibweisen wie „z.B.“ → „z. B.“. So bleiben die Wortänderungen 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“. ü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 ```bash
./aenderungen.py -a rede.txt -b rede.korrigiert.txt # → rede.aenderungen.html ./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.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 ## 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), `tests/test_glossar_vorschlag.py` prüft Filter (nur Funde im Text, keine bekannten),
Ausgabeformat (gültiges Glossar, Unsicheres auskommentiert) und Zwischenspeicher. Ausgabeformat (gültiges Glossar, Unsicheres auskommentiert) und Zwischenspeicher.
`tests/test_aenderungen.py` prüft den Wortvergleich (Satzzeichen, Groß-/Kleinschreibung `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 `tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl
(Original-Audio, manuell vor automatisch) und Fehlerfälle. (Original-Audio, manuell vor automatisch) und Fehlerfälle.
`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und `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 - Das Modell ändert gelegentlich Wörter eigenmächtig, auch sinnverändernd
(Magdeburg-Rede ohne Glossar: „Professor“ → „Professorin“, „gedeiliche“ → (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. gehören ins Glossar.
- `qwen3:32b` ohne Denkmodus erkennt Hörfehler ohne Glossar nur teilweise und - `qwen3:32b` ohne Denkmodus erkennt Hörfehler ohne Glossar nur teilweise und
setzt kaum `[prüfen: …]`-Marken. Bekannte Fehler gehören deshalb ins Glossar. setzt kaum `[prüfen: …]`-Marken. Bekannte Fehler gehören deshalb ins Glossar.

View file

@ -12,7 +12,9 @@ import time
import urllib.error import urllib.error
import urllib.request 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 extract_text import fetch_text
from vtt2txt import ( from vtt2txt import (
add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments, add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments,
@ -616,6 +618,9 @@ def main():
).split("\n") ).split("\n")
segments = [(header, body) for (header, _), body in zip(segments, bodies)] 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] blocks = [(header, split_chunks(body)) for header, body in segments]
total = sum(len(chunks) for _, chunks in blocks) total = sum(len(chunks) for _, chunks in blocks)
@ -672,7 +677,10 @@ def main():
if not args.kein_bericht: if not args.kein_bericht:
report = report_path(target) 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_summary(comparison)
print(f"Bericht: {report}") print(f"Bericht: {report}")
@ -680,6 +688,8 @@ def main():
print(f"\n{len(warnings)} Abschnitt(e) mit auffälliger Wortzahl:") print(f"\n{len(warnings)} Abschnitt(e) mit auffälliger Wortzahl:")
for message in warnings: for message in warnings:
print(f"- {message}") 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__": if __name__ == "__main__":

View file

@ -4,6 +4,7 @@ ersetzt, eingefügt oder gestrichen? Ausgabe als HTML-Bericht."""
from collections import Counter from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass, field
from difflib import SequenceMatcher from difflib import SequenceMatcher
from functools import lru_cache
from pathlib import Path from pathlib import Path
import argparse import argparse
import html 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\.$") ABBREVIATION_END = re.compile(r"(?:^|\s)\w\.$")
EMPTY = "∅" 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): def add_token(words, token):
"""Append a token; attach punctuation-only tokens ("–") to the previous word """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 paragraph: list # paragraph number of each corrected word
marks: dict = field(default_factory=dict) # word index -> review marks after it marks: dict = field(default_factory=dict) # word index -> review marks after it
opcodes: list = field(default_factory=list) opcodes: list = field(default_factory=list)
suspects: list = field(default_factory=list) # Suspect, see review_candidates
def changes(self): def changes(self):
"""Yield (tag, raw words, corrected words) for every difference.""" """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()) 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): def change_summary(comparison):
"""[(old, new, count)] of all changes, most frequent first.""" """[(old, new, count)] of all changes, most frequent first."""
counts = Counter( counts = Counter(
@ -95,10 +187,10 @@ def change_summary(comparison):
STYLE = """ STYLE = """
:root { --bg: #fff; --fg: #1d1d1f; --muted: #6e6e73; --rep: #fff3b0; --ins: #d9f7d9; :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) { @media (prefers-color-scheme: dark) {
:root { --bg: #1c1c1e; --fg: #f2f2f7; --muted: #a1a1a6; --rep: #5c4d00; --ins: #1f4d1f; :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; 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; } 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); } del { background: var(--del); }
.orig { color: var(--muted); font-size: 0.8em; } .orig { color: var(--muted); font-size: 0.8em; }
.check { background: var(--check); font-size: 0.85em; } .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"<td><ins>{esc(new) or EMPTY}</ins></td></tr>" f"<td><ins>{esc(new) or EMPTY}</ins></td></tr>"
for old, new, count in summary for old, new, count in summary
) )
suspects = comparison.suspects
suspect_rows = "\n".join(
f"<tr><td>{esc(s.reason)}</td><td><del>{esc(s.old) or EMPTY}</del></td>"
f"<td><ins>{esc(s.new) or EMPTY}</ins></td>"
f"<td class=ctx>{suspect_context(comparison, s)}</td></tr>"
for s in suspects
)
if suspects:
suspect_table = (
"<table><tr><th>Grund</th><th>vorher</th><th>korrigiert</th>"
f"<th>Zusammenhang</th></tr>\n{suspect_rows}\n</table>"
)
else:
suspect_table = "<p class=muted>Keine auffälligen Änderungen gefunden.</p>"
# 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 # Corrected text with its paragraphs; each word knows its paragraph
parts = [] parts = []
@ -136,9 +253,13 @@ def render_html(comparison, title):
if comparison.paragraph[j] != current: if comparison.paragraph[j] != current:
current = comparison.paragraph[j] current = comparison.paragraph[j]
parts.append("</p>\n<p>") parts.append("</p>\n<p>")
if j in warn_words:
piece = f"<span class=warn title='{warn_words[j]}'>{piece}</span>"
parts.append(piece) parts.append(piece)
for mark in comparison.marks.get(j, []): for mark in comparison.marks.get(j, []):
parts.append(f"<span class=check>{esc(mark)}</span>") parts.append(f"<span class=check>{esc(mark)}</span>")
for note in warn_after.get(j, []):
parts.append(f"<span class=warn title='{note}'>⚠</span>")
for tag, i1, i2, j1, j2 in comparison.opcodes: for tag, i1, i2, j1, j2 in comparison.opcodes:
old = " ".join(comparison.raw[i1:i2]) old = " ".join(comparison.raw[i1:i2])
@ -166,9 +287,16 @@ def render_html(comparison, title):
<h1>Änderungen: {esc(title)}</h1> <h1>Änderungen: {esc(title)}</h1>
<p class=muted>{len(comparison.raw)} Wörter Rohtext, {len(comparison.words)} Wörter korrigiert. <p class=muted>{len(comparison.raw)} Wörter Rohtext, {len(comparison.words)} Wörter korrigiert.
{counts['replace']} ersetzt, {counts['insert']} eingefügt, {counts['delete']} gestrichen, {counts['replace']} ersetzt, {counts['insert']} eingefügt, {counts['delete']} gestrichen,
{review_marks} Prüfmarken. Groß-/Kleinschreibung und Satzzeichen sind nicht markiert.</p> {review_marks} Prüfmarken, {len(suspects)} Stellen zu prüfen.
Groß-/Kleinschreibung und Satzzeichen sind nicht markiert.</p>
<p><span class=rep>ersetzt</span> <span class=orig>(Original)</span> <p><span class=rep>ersetzt</span> <span class=orig>(Original)</span>
<ins>eingefügt</ins> <del>gestrichen</del> <span class=check>[prüfen: …]</span></p> <ins>eingefügt</ins> <del>gestrichen</del> <span class=check>[prüfen: …]</span>
<span class=warn>zu prüfen</span></p>
<h2>Zu prüfen ({len(suspects)})</h2>
<p class=muted>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).</p>
{suspect_table}
<h2>Alle Änderungen nach Häufigkeit</h2> <h2>Alle Änderungen nach Häufigkeit</h2>
<table><tr><th class=n>×</th><th>Rohtext</th><th>korrigiert</th></tr> <table><tr><th class=n>×</th><th>Rohtext</th><th>korrigiert</th></tr>
{rows} {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"<del>{esc(suspect.old)}</del>"
else:
middle = f"<b>{esc(' '.join(words[suspect.j1:suspect.j2]))}</b>"
return f"… {esc(before)} {middle} {esc(after)} …"
def print_summary(comparison, limit=15, suspect_limit=10):
summary = change_summary(comparison) summary = change_summary(comparison)
print(f"Änderungen gegenüber dem Rohtext: {sum(c for _, _, c in summary)}") print(f"Änderungen gegenüber dem Rohtext: {sum(c for _, _, c in summary)}")
for old, new, count in summary[:limit]: for old, new, count in summary[:limit]:
@ -187,9 +328,23 @@ def print_summary(comparison, limit=15):
if len(summary) > limit: if len(summary) > limit:
print(f" … und {len(summary) - limit} weitere, siehe Bericht") 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) 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") target.write_text(render_html(comparison, title), encoding="utf-8")
return comparison return comparison
@ -221,8 +376,17 @@ def parse_args():
"-o", "--output", type=Path, "-o", "--output", type=Path,
help="HTML-Bericht (Standard: <korrigiert ohne .korrigiert.txt>.aenderungen.html)", help="HTML-Bericht (Standard: <korrigiert ohne .korrigiert.txt>.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() 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(): if not path.is_file():
parser.error(f"Datei nicht gefunden: {path}") parser.error(f"Datei nicht gefunden: {path}")
if args.output is None: if args.output is None:
@ -231,9 +395,25 @@ def parse_args():
def main(): def main():
# abschrift_korrigieren imports this module: import it only when needed
from abschrift_korrigieren import apply_replacements, read_glossary
args = parse_args() 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") 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_summary(comparison)
print(f"Bericht: {args.output}") print(f"Bericht: {args.output}")

View file

@ -18,6 +18,22 @@ def ollama_available(request, monkeypatch):
monkeypatch.setattr(module, "check_ollama", lambda model: None) 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 @pytest.fixture
def fake_yt_dlp(): def fake_yt_dlp():
"""Factory for a subprocess.run stand-in. """Factory for a subprocess.run stand-in.

View file

@ -336,6 +336,26 @@ def test_main_writes_change_report(sample_copy, monkeypatch, capsys):
assert f"Bericht: {report}" in out 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("<h2>Zu prüfen")[1].split("<h2>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): def test_main_without_change_report(sample_copy, monkeypatch):
monkeypatch.setattr(ak, "correct_chunk", identity_correct) monkeypatch.setattr(ak, "correct_chunk", identity_correct)
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))

View file

@ -117,3 +117,121 @@ def test_cli_missing_file(tmp_path, monkeypatch, capsys):
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
ae.main() ae.main()
assert "Datei nicht gefunden" in capsys.readouterr().err 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("<h2>Zu prüfen")[1].split("<h2>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 "<span class=warn title='gültiges Wort ersetzt: „Professor“ → „Professorin“'>" in page
assert "<span class=warn title='gestrichen: „heute“ → „∅“'>⚠</span>" 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