diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index bf1b34f..8da24e7 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -476,7 +476,10 @@ Sinn verändern können. Was die Glossare ersetzt haben, erscheint hier nie. Nicht gemeldet werden ähnlich bleibende Hörfehler-Korrekturen („Gradwanderung“ → „Gratwanderung“), reine Zusammen-, Getrennt- und Bindestrich-Schreibung („Clan -Kriminalität“ → „Clankriminalität“) und Wörter, die im Glossar stehen. +Kriminalität“ → „Clankriminalität“) und Schreibweisen aus dem Glossar („Bar“ → +„Bahr“, wenn „Egon Bahr“ im Glossar steht). Ergänzt das Modell dagegen Wörter, etwa +einen Vornamen („Dobrind“ → „Alexander Dobrindt“), erscheint das auch dann, wenn der +volle Name im Glossar steht: Gesagt wurde er so nicht. **So arbeitet man die Liste ab:** diff --git a/README.md b/README.md index 7e7646e..1e5bebd 100644 --- a/README.md +++ b/README.md @@ -317,9 +317,13 @@ Ersetzungen des Glossars), mit seinem Ergebnis. Gemeldet werden: 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. +Bindestrich-Schreibung („Clan Kriminalität“ → „Clankriminalität“) und Schreibweisen +aus dem Glossar („Bar“ → „Bahr“ bei „Egon Bahr“ im Glossar): Die neuen Wörter müssen +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. 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: @@ -478,7 +482,7 @@ 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` | äh, ähm, … | Füllwörter, deren Streichung nicht gemeldet wird | +| `FILLERS` | je Sprache: äh, ähm, …; uh, um, er, … | Füllwörter, deren Streichung nicht gemeldet wird | In `vtt2txt.py`: diff --git a/abschrift_korrigieren.py b/abschrift_korrigieren.py index 02bc4b9..b6171d4 100755 --- a/abschrift_korrigieren.py +++ b/abschrift_korrigieren.py @@ -14,7 +14,7 @@ import urllib.error import urllib.request from aenderungen import ( - REVIEW_MARK, glossary_words, print_summary, report_path, write_report, + REVIEW_MARK, glossary_entries, print_summary, report_path, write_report, ) from extract_text import fetch_text from vtt2txt import ( @@ -801,7 +801,7 @@ def main(): report = report_path(target) comparison = write_report( raw_text, text, report, target.name, model_input=model_text, - known=glossary_words(terms, replacements), lang=lang, + glossary=glossary_entries(terms, replacements), lang=lang, ) print_summary(comparison) print(f"Bericht: {report}") diff --git a/aenderungen.py b/aenderungen.py index adb7fe6..8874f72 100755 --- a/aenderungen.py +++ b/aenderungen.py @@ -30,7 +30,11 @@ WORDLISTS = { } 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"} +# Per language: "er" and "um" are fillers in English, real words in German +FILLERS = { + "de": {"äh", "ähm", "öh", "öhm", "hm", "hmm"}, + "en": {"uh", "uhm", "um", "er", "erm", "hm", "hmm"}, +} CONTEXT_WORDS = 6 VALID_WORD = "gültiges Wort ersetzt" @@ -132,10 +136,10 @@ def load_wordlist(lang): return {line.strip().lower() for line in words if line.strip()} -def glossary_words(terms, replacements): - """Normalized words of glossary terms and replacement targets.""" +def glossary_entries(terms, replacements): + """Glossary terms and replacement targets as tuples of normalized words.""" texts = list(terms) + [right for _, right in replacements] - return {normalize(word) for text in texts for word in text.split()} + return {tuple(normalize(word) for word in text.split()) for text in texts} - {()} def spelling_key(words): @@ -143,33 +147,47 @@ def spelling_key(words): return re.sub(r"[\W_]+", "", "".join(words).lower()) -def review_candidates(comparison, wordlist=None, known=()): +def similarity(old, new): + return SequenceMatcher(None, spelling_key(old), spelling_key(new)).ratio() + + +def glossary_spelling(old, new, glossary): + """True if `new` is a glossary spelling of `old`: the new words stand + together in one glossary entry and resemble the old ones. Common words of + longer entries ("finden Sie") explain nothing on their own.""" + words = tuple(normalize(word) for word in new) + size = len(words) + in_entry = any( + entry[start:start + size] == words + for entry in glossary for start in range(len(entry) - size + 1) + ) + return in_entry and similarity(old, new) >= MIN_SIMILARITY + + +def review_candidates(comparison, wordlist=None, glossary=(), fillers=FILLERS["de"]): """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. + 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. """ 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): + 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: + 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 - elif SequenceMatcher(None, old_key, new_key).ratio() < MIN_SIMILARITY: + elif similarity(old, new) < MIN_SIMILARITY: reason = CHANGED_A_LOT else: continue @@ -336,7 +354,7 @@ def print_summary(comparison, limit=15, suspect_limit=10): print(f" … und {len(suspects) - suspect_limit} weitere, siehe Bericht") -def write_report(raw, corrected, target, title, model_input=None, known=(), lang="de"): +def write_report(raw, corrected, target, title, model_input=None, glossary=(), 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 @@ -344,7 +362,9 @@ def write_report(raw, corrected, target, title, model_input=None, known=(), lang """ 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)) + comparison.suspects = review_candidates( + by_model, load_wordlist(lang), glossary, FILLERS.get(lang, set()) + ) target.write_text(render_html(comparison, title), encoding="utf-8") return comparison @@ -397,6 +417,7 @@ def parse_args(): def main(): # abschrift_korrigieren imports this module: import it only when needed from abschrift_korrigieren import apply_replacements, read_glossary + from yt_untertitel import base_language args = parse_args() raw = read_raw(args.roh) @@ -411,8 +432,8 @@ def main(): corrected = args.korrigiert.read_text(encoding="utf-8") comparison = write_report( raw, corrected, args.output, args.korrigiert.name, - model_input=model_input, known=glossary_words(terms, replacements), - lang=args.lang, + model_input=model_input, glossary=glossary_entries(terms, replacements), + lang=base_language(args.lang), ) print_summary(comparison) print(f"Bericht: {args.output}") diff --git a/tests/test_aenderungen.py b/tests/test_aenderungen.py index 49fdfef..13c7506 100644 --- a/tests/test_aenderungen.py +++ b/tests/test_aenderungen.py @@ -124,8 +124,10 @@ def test_cli_missing_file(tmp_path, monkeypatch, capsys): 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)) +def suspects(model_input, corrected, wordlist=WORDS, glossary=(), fillers=ae.FILLERS["de"]): + found = ae.review_candidates( + ae.compare(model_input, corrected), wordlist, glossary, fillers + ) return [(s.reason, s.old, s.new) for s in found] @@ -153,13 +155,37 @@ 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) == [] +def test_review_candidates_glossary_spellings_are_not_reported(): + glossary = ae.glossary_entries(["Egon Bahr", ""], [("Bars", "Bahrs")]) + assert glossary == {("egon", "bahr"), ("bahrs",)} + assert suspects("Bar sagte", "Bahr sagte", glossary=glossary) == [] assert suspects("Bar sagte", "Bahr sagte") == [(ae.VALID_WORD, "Bar", "Bahr")] +@pytest.mark.parametrize("model_input, corrected, expected", [ + # A common word of a longer entry explains nothing on its own + ("das sagt ihn nicht", "Das sagt Sie nicht.", [(ae.CHANGED_A_LOT, "ihn", "Sie")]), + # Inserted words stay suspect even if they are in the glossary + ("er kam heute", "er ist kam heute", [(ae.INSERTED, "", "ist")]), + ("Dobrind sagte", "Alexander Dobrindt sagte", + [(ae.CHANGED_A_LOT, "Dobrind", "Alexander Dobrindt")]), +]) +def test_review_candidates_glossary_explains_only_spellings(model_input, corrected, expected): + glossary = ae.glossary_entries( + ["Alexander Dobrindt"], [("befinden Sie", "finden Sie"), ("is", "ist")] + ) + assert suspects(model_input, corrected, glossary=glossary) == expected + + +def test_review_candidates_fillers_per_language(): + # "er" and "um" are real German words; in English they are fillers + 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"]) + assert english == [] + + 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) == []