feat: „Zu prüfen“ zeigt bloße Wortformen getrennt am Ende

Ersetzt das Modell ein gültiges Wort nur durch eine andere Form
(„Punkten“ → „Punkte“, „das“ → „dass“, „sehe“ → „sähe“), heißt der Grund
jetzt „Wortform geändert“. Diese Fälle stehen im Bericht in einer eigenen
Tabelle nach den sinnverändernden, im Text gestrichelt umrahmt, und in
der Konsole am Ende. „-in“ zählt nicht als Endung: „Professor“ →
„Professorin“ bleibt oben.

Außerdem: Die Tabellen scrollen auf schmalen Bildschirmen waagrecht, und
aenderungen.py meldet Zeit-Überschriften nicht als Einfügung, wenn der
Rohtext (z. B. eine .vtt) keine hat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-11 22:01:18 +02:00
commit cd62feab69
4 changed files with 163 additions and 32 deletions

View file

@ -399,10 +399,11 @@ Fertig: rede.korrigiert.txt
Änderungen gegenüber dem Rohtext: 82
5× „Rammstein“ → „Ramstein“
…
Zu prüfen: 19 Stellen ← diese Stellen ansehen!
Zu prüfen: 20 Stellen (davon 2 Wortformen) ← diese Stellen ansehen!
gültiges Wort ersetzt: „in“ → „dass“
stark verändert: „OSola“ → „Ursula“
…
Wortform geändert: „sehe“ → „sähe“ ← Wortformen am Ende
Bericht: rede.aenderungen.html
19 Stelle(n) zu prüfen, siehe rede.aenderungen.html
@ -444,7 +445,8 @@ xdg-open rede.aenderungen.html
1. **Kopfzeile:** Wortzahlen, Anzahl der Ersetzungen, Einfügungen, Streichungen,
Prüfmarken und Stellen „zu prüfen“.
2. **Zu prüfen:** Tabelle der verdächtigen Änderungen mit Grund, Text vorher,
korrigiertem Text und Zusammenhang. **Hier beginnen.**
korrigiertem Text und Zusammenhang. **Hier beginnen.** Darunter in einer eigenen
Tabelle die bloßen Wortformen („Wortform geändert“).
3. **Alle Änderungen nach Häufigkeit:** jede Wortänderung mit Anzahl, z. B.
`5 Rammstein → Ramstein`. Gut, um wiederkehrende Hörfehler fürs Glossar zu
finden.
@ -457,6 +459,7 @@ xdg-open rede.aenderungen.html
| rot durchgestrichen | gestrichenes Wort |
| orange `[prüfen: …]` | Prüfmarke des Modells |
| rot umrahmt | Stelle „zu prüfen“; beim Darüberfahren erscheint der Grund |
| rot gestrichelt umrahmt | geänderte Wortform (meist Grammatik) |
| rot umrahmtes ⚠ | hier hat das Modell eigenmächtig etwas gestrichen |
Groß-/Kleinschreibung, Satzzeichen und Schreibweisen wie „z.B.“ → „z. B.“ werden
@ -470,6 +473,7 @@ Sinn verändern können. Was die Glossare ersetzt haben, erscheint hier nie.
| Grund | Bedeutung | Beispiel |
|---|---|---|
| gültiges Wort ersetzt | Das alte Wort ist ein echtes Wort (steht in der Wortliste), also vermutlich kein Hörfehler. | „Professor“ → „Professorin“, „unterschreiben“ → „unterscheiden“ |
| Wortform geändert | Ein echtes Wort hat nur eine andere Endung oder einen Umlaut bekommen. Meist eine richtige Grammatik-Korrektur; steht in einer eigenen Gruppe am Ende. | „Punkten“ → „Punkte“, „das“ → „dass“, „sehe“ → „sähe“ |
| stark verändert | Das neue Wort ähnelt dem alten kaum (unter 60 %). | „CT1“ → „ZDF“ |
| eingefügt | Das Modell hat ein Wort ergänzt. | „∅“ → „ist“ |
| gestrichen | Das Modell hat ein Wort weggelassen (Füllwörter wie „äh“ zählen nicht). | „Sicherungskonzepte geht“ → „∅“ |
@ -484,8 +488,8 @@ volle Name im Glossar steht: Gesagt wurde er so nicht.
**So arbeitet man die Liste ab:**
1. Für jede Zeile den Zusammenhang lesen.
2. Ist die Änderung richtig (oft bei Grammatik: „das“ → „dass“, „sehe“ → „sähe“),
nichts tun.
2. Ist die Änderung richtig, nichts tun. Bei den Wortformen am Ende ist das der
Normalfall; dort genügt ein kurzer Blick.
3. Ist sie falsch, im korrigierten Text zurückändern. Tritt der Fehler öfter auf
oder geht es um einen Namen, besser das Glossar ergänzen (nächster Schritt).
4. Bei „gestrichen“ prüfen, ob wirklich etwas fehlt: Oft entfernt das Modell

View file

@ -319,6 +319,7 @@ 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) |
| Wortform geändert | „Punkten“ → „Punkte“, „das“ → „dass“, „sehe“ → „sähe“: ein gültiges Wort, nur Endung oder Umlaut anders (meist Grammatik); eigene Gruppe am Ende der Liste |
| stark verändert | „CT1“ → „ZDF“ (Ähnlichkeit unter 60 %) |
| eingefügt | „∅“ → „ist“ |
| gestrichen | „Sicherungskonzepte geht“ → „∅“ (Füllwörter wie „äh“ nicht) |
@ -330,8 +331,12 @@ aus dem Glossar („Bar“ → „Bahr“ bei „Egon Bahr“ im Glossar): Die n
zusammenhängend in einem Glossareintrag stehen und dem alten Text ähneln. Einzelne
Allerweltswörter aus längeren Einträgen („Sie“ aus „finden Sie“) und eingefügte
Wörter (ein ergänzter Vorname „Alexander“) erklärt das Glossar nicht. Die Liste
erscheint auch in der Konsole. Kleine Grammatik-Korrekturen wie „das“ → „dass“
tauchen ebenfalls auf; ein Blick genügt.
erscheint auch in der Konsole, sinnverändernde Fälle zuerst, Wortformen danach.
Als Wortform gilt ein einzelnes Wort, das bis auf die Endung gleich bleibt (Stamm
mindestens 3 Buchstaben, beide Endungen aus `INFLECTIONS`, z. B. -e, -n, -s, -en,
-er, -es, -st, -t) oder sich nur im Umlaut unterscheidet. „-in“ gehört bewusst nicht
dazu: „Professor“ → „Professorin“ bleibt in der Hauptliste.
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:
@ -491,6 +496,8 @@ In `aenderungen.py`:
| `WORDLISTS` | `/usr/share/dict/ngerman`, `…/american-english` | Wortlisten je Sprache |
| `MIN_SIMILARITY` | 0.6 | darunter gilt eine Ersetzung als „stark verändert“ |
| `FILLERS` | je Sprache: äh, ähm, …; uh, um, er, … | Füllwörter, deren Streichung nicht gemeldet wird |
| `INFLECTIONS` | je Sprache: -e, -n, -s, -en, …; -s, -ed, -ing, … | Endungen, die nur die Wortform ändern |
| `MIN_STEM` | 3 | kürzester gemeinsamer Wortstamm einer Wortform-Änderung |
In `vtt2txt.py`:

View file

@ -10,7 +10,7 @@ import argparse
import html
import re
from vtt2txt import vtt_to_lines
from vtt2txt import split_segments, vtt_to_lines
# "[prüfen: …]" in German transcripts, "[check: …]" in English ones
REVIEW_MARK = re.compile(r"\[(?:prüfen|check):[^\]]*\]")
@ -35,12 +35,20 @@ FILLERS = {
"de": {"äh", "ähm", "öh", "öhm", "hm", "hmm"},
"en": {"uh", "uhm", "um", "er", "erm", "hm", "hmm"},
}
# Endings that turn one form of a word into another ("Punkten" -> "Punkte")
INFLECTIONS = {
"de": {"", "e", "n", "r", "s", "t", "em", "en", "er", "es", "et", "st", "te", "est", "ten"},
"en": {"", "d", "s", "ed", "es", "ing"},
}
UMLAUT_FOLD = str.maketrans({"ä": "e", "ö": "o", "ü": "u", "ß": "ss"})
MIN_STEM = 3 # shortest common stem of two word forms
CONTEXT_WORDS = 6
VALID_WORD = "gültiges Wort ersetzt"
CHANGED_A_LOT = "stark verändert"
INSERTED = "eingefügt"
DELETED = "gestrichen"
WORD_FORM = "Wortform geändert"
def add_token(words, token):
@ -164,13 +172,31 @@ def glossary_spelling(old, new, glossary):
return in_entry and similarity(old, new) >= MIN_SIMILARITY
def review_candidates(comparison, wordlist=None, glossary=(), fillers=FILLERS["de"]):
def word_form_change(old, new, lang="de"):
"""True if one word becomes another form of itself: only the ending
("Punkten" -> "Punkte", "das" -> "dass") or an umlaut ("sehe" -> "sähe")
differs. "-in" is no such ending: "Professor" -> "Professorin" counts."""
if len(old) != 1 or len(new) != 1:
return False
a, b = (NON_WORD.sub("", w).lower().translate(UMLAUT_FOLD) for w in (old[0], new[0]))
if a == b:
return True
stem = 0
while stem < min(len(a), len(b)) and a[stem] == b[stem]:
stem += 1
endings = INFLECTIONS.get(lang, set())
return stem >= MIN_STEM and a[stem:] in endings and b[stem:] in endings
def review_candidates(comparison, wordlist=None, glossary=(), lang="de"):
"""Changes the model made on its own that may alter the meaning.
Not reported: changes of spacing, hyphens and punctuation only, glossary
spellings of the old words, deleted filler words, and replacements of
words not in the word list (recognition errors) that stay similar.
Replaced real words that only change their form get their own reason.
"""
fillers = FILLERS.get(lang, set())
suspects = []
for tag, i1, i2, j1, j2 in comparison.opcodes:
if tag == "equal":
@ -186,7 +212,7 @@ def review_candidates(comparison, wordlist=None, glossary=(), fillers=FILLERS["d
if spelling_key(old) == spelling_key(new) or glossary_spelling(old, new, glossary):
continue
if wordlist is not None and all(normalize(w) in wordlist for w in old):
reason = VALID_WORD
reason = WORD_FORM if word_form_change(old, new, lang) else VALID_WORD
elif similarity(old, new) < MIN_SIMILARITY:
reason = CHANGED_A_LOT
else:
@ -223,6 +249,9 @@ 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; }
.warn.form { outline-style: dashed; outline-width: 1px; }
h3 { font-size: 1rem; margin-top: 1.5rem; }
.scroll { overflow-x: auto; }
td.ctx { color: var(--muted); } td.ctx b { color: var(--fg); }
"""
@ -239,28 +268,43 @@ def render_html(comparison, title):
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>"
important, forms = split_suspects(suspects)
def table(rows):
body = "\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 rows
)
return (
"<div class=scroll><table><tr><th>Grund</th><th>vorher</th><th>korrigiert</th>"
f"<th>Zusammenhang</th></tr>\n{body}\n</table></div>"
)
if important:
suspect_table = table(important)
elif forms:
suspect_table = "<p class=muted>Keine sinnverändernden Änderungen gefunden.</p>"
else:
suspect_table = "<p class=muted>Keine auffälligen Änderungen gefunden.</p>"
if forms:
suspect_table += (
f"\n<h3>Wortform geändert ({len(forms)})</h3>\n"
"<p class=muted>Nur Endung oder Umlaut anders („Punkten“ → „Punkte“, "
"„das“ → „dass“), meist Grammatik; ein kurzer Blick genügt.</p>\n"
+ table(forms)
)
# 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)
css = "warn form" if s.reason == WORD_FORM else "warn"
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
warn_words[j] = (css, note)
# Corrected text with its paragraphs; each word knows its paragraph
parts = []
@ -272,7 +316,8 @@ def render_html(comparison, title):
current = comparison.paragraph[j]
parts.append("</p>\n<p>")
if j in warn_words:
piece = f"<span class=warn title='{warn_words[j]}'>{piece}</span>"
css, note = warn_words[j]
piece = f"<span class='{css}' title='{note}'>{piece}</span>"
parts.append(piece)
for mark in comparison.marks.get(j, []):
parts.append(f"<span class=check>{esc(mark)}</span>")
@ -325,6 +370,12 @@ gültige Wörter ersetzt, stark veränderte, eingefügte oder gestrichene Wörte
"""
def split_suspects(suspects):
"""(possible meaning changes, mere word forms), each in text order."""
forms = [s for s in suspects if s.reason == WORD_FORM]
return [s for s in suspects if s.reason != WORD_FORM], forms
def suspect_context(comparison, suspect):
"""HTML: the change in bold with a few corrected words around it."""
esc = html.escape
@ -346,8 +397,11 @@ def print_summary(comparison, limit=15, suspect_limit=10):
if len(summary) > limit:
print(f" … und {len(summary) - limit} weitere, siehe Bericht")
suspects = comparison.suspects
print(f"Zu prüfen: {len(suspects)} Stellen")
important, forms = split_suspects(comparison.suspects)
# Meaning changes first, word forms after them
suspects = important + forms
of_which = f" (davon {len(forms)} Wortformen)" if forms else ""
print(f"Zu prüfen: {len(suspects)} Stellen{of_which}")
for s in suspects[:suspect_limit]:
print(f" {s.reason}: „{s.old or EMPTY}“ → „{s.new or EMPTY}“")
if len(suspects) > suspect_limit:
@ -362,9 +416,7 @@ def write_report(raw, corrected, target, title, model_input=None, glossary=(), l
"""
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), glossary, FILLERS.get(lang, set())
)
comparison.suspects = review_candidates(by_model, load_wordlist(lang), glossary, lang)
target.write_text(render_html(comparison, title), encoding="utf-8")
return comparison
@ -375,6 +427,11 @@ def report_path(corrected_path):
return corrected_path.with_name(f"{stem}.aenderungen.html")
def without_headers(text):
"""Text without time headers ("[00:04:57 - 00:06:04]") of excerpts."""
return "\n\n".join(body for _, body in split_segments(text))
def read_raw(path):
text = path.read_text(encoding="utf-8-sig")
if path.suffix.lower() == ".vtt":
@ -430,6 +487,9 @@ def main():
model_input = apply_replacements(" ".join(raw.split()), replacements)
corrected = args.korrigiert.read_text(encoding="utf-8")
# A raw .vtt has no time headers: those of the excerpts are no insertions
if without_headers(raw) == raw.strip():
corrected = without_headers(corrected)
comparison = write_report(
raw, corrected, args.output, args.korrigiert.name,
model_input=model_input, glossary=glossary_entries(terms, replacements),

View file

@ -124,10 +124,8 @@ def test_cli_missing_file(tmp_path, monkeypatch, capsys):
WORDS = {"professor", "das", "dass", "ist", "haus", "unterschreiben", "bar"}
def suspects(model_input, corrected, wordlist=WORDS, glossary=(), fillers=ae.FILLERS["de"]):
found = ae.review_candidates(
ae.compare(model_input, corrected), wordlist, glossary, fillers
)
def suspects(model_input, corrected, wordlist=WORDS, glossary=(), lang="de"):
found = ae.review_candidates(ae.compare(model_input, corrected), wordlist, glossary, lang)
return [(s.reason, s.old, s.new) for s in found]
@ -182,7 +180,7 @@ def test_review_candidates_fillers_per_language():
assert suspects("dann kam er um zu helfen", "Dann kam, zu helfen.") == [
(ae.DELETED, "er um", "")
]
english = suspects("so er we um go", "So we go.", fillers=ae.FILLERS["en"])
english = suspects("so er we um go", "So we go.", lang="en")
assert english == []
@ -224,7 +222,7 @@ def test_report_suspects_only_against_model_input(tmp_path, capsys):
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='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()
@ -242,6 +240,20 @@ def test_report_without_suspects(tmp_path):
assert "Keine auffälligen Änderungen" in suspect_section(target.read_text(encoding="utf-8"))
def test_cli_ignores_time_headers_missing_in_raw(tmp_path, monkeypatch, capsys):
raw = tmp_path / "rede.vtt"
raw.write_text(
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\ndas Kremmel heute\n", encoding="utf-8"
)
corrected = tmp_path / "rede.korrigiert.txt"
corrected.write_text("[00:00:01 - 00:00:02]\n\nDas Kreml heute.\n", encoding="utf-8")
monkeypatch.setattr(sys, "argv", ["aenderungen.py", "-a", str(raw), "-b", str(corrected)])
ae.main()
out = capsys.readouterr().out
assert "Zu prüfen: 0 Stellen" in out
assert "00:00:01" not in out
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")
@ -261,3 +273,51 @@ def test_cli_glossary(tmp_path, monkeypatch, capsys):
assert "Zu prüfen: 0 Stellen" in out
# The report still compares against the raw transcript
assert "1× „Kremmel“ → „Kreml“" in out
@pytest.mark.parametrize("old, new, expected", [
("Punkten", "Punkte", True),
("das", "dass", True),
("sehe", "sähe", True), # umlaut
("sein", "seien", True), # both endings change
("kommt,", "kommen,", True), # punctuation does not count
("Professor", "Professorin", False), # "-in" is no inflection
("geworben", "geworden", False),
("unterschreiben", "unterscheiden", False),
("ich", "sich", False),
("den", "der", False), # stem too short
("sich dazu", "zu", False), # more than one word
])
def test_word_form_change(old, new, expected):
assert ae.word_form_change(old.split(), new.split()) is expected
def test_word_form_change_english():
assert ae.word_form_change(["walk"], ["walked"], "en")
assert not ae.word_form_change(["walk"], ["walker"], "en")
def test_review_candidates_word_forms():
words = WORDS | {"punkten", "punkte"}
assert suspects("die Punkten und das", "die Punkte und dass", wordlist=words) == [
(ae.WORD_FORM, "Punkten", "Punkte"), (ae.WORD_FORM, "das", "dass"),
]
def test_report_and_summary_put_word_forms_last(tmp_path, capsys):
target = tmp_path / "bericht.html"
comparison = ae.write_report(
"das sagte der Professor", "dass sagte der Professorin", target, "rede"
)
assert [s.reason for s in comparison.suspects] == [ae.WORD_FORM, ae.VALID_WORD]
page = target.read_text(encoding="utf-8")
section = suspect_section(page)
# Meaning change in the main table, the word form in its own group after it
assert section.index("Professorin") < section.index("Wortform geändert (1)")
assert section.index("Wortform geändert (1)") < section.index("<ins>dass</ins>")
assert "<span class='warn form' title='Wortform geändert: „das“ → „dass“'>" in page
ae.print_summary(comparison)
out = capsys.readouterr().out.split("Zu prüfen: ")[1]
assert out.startswith("2 Stellen (davon 1 Wortformen)")
assert out.index("Professorin") < out.index("„das“ → „dass“")