diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 0f044ca..3b8ca79 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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 diff --git a/README.md b/README.md index 6deb17b..d77acb7 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/aenderungen.py b/aenderungen.py index 8874f72..f2eca03 100755 --- a/aenderungen.py +++ b/aenderungen.py @@ -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"
| Grund | vorher | korrigiert | " - f"Zusammenhang |
|---|
| Grund | vorher | korrigiert | " + f"Zusammenhang |
|---|
Keine sinnverändernden Änderungen gefunden.
" else: suspect_table = "Keine auffälligen Änderungen gefunden.
" + if forms: + suspect_table += ( + f"\nNur Endung oder Umlaut anders („Punkten“ → „Punkte“, " + "„das“ → „dass“), meist Grammatik; ein kurzer Blick genügt.
\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("\n") if j in warn_words: - piece = f"{piece}" + css, note = warn_words[j] + piece = f"{piece}" parts.append(piece) for mark in comparison.marks.get(j, []): parts.append(f"{esc(mark)}") @@ -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), diff --git a/tests/test_aenderungen.py b/tests/test_aenderungen.py index 13c7506..ce492dd 100644 --- a/tests/test_aenderungen.py +++ b/tests/test_aenderungen.py @@ -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 "" in page + assert "" in page assert "⚠" 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("dass") + assert "" 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“")