From 6610679d8086fa732f107af8e8c86e089e82d68d Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sat, 10 Oct 2026 19:56:18 +0200 Subject: [PATCH] feat: mehrere Glossare kombinieren, gemeinsames Krone-Schmalz-Glossar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rede_korrigieren.py: -g mehrfach, Ersetzungen in Reihenfolge der Angabe - Ersetzungen ohne Treffer werden gesammelt in einer Zeile gemeldet - krone-schmalz.glossar.txt: wiederkehrende Namen und Hörfehler; die Rede-Glossare enthalten nur noch Spezielles - Magdeburg ergibt exakt denselben Text wie vorher, Ramstein profitiert von den gemeinsamen Einträgen (Russland-Versteher, Status quo, vornherein …) Co-Authored-By: Claude Opus 5.5 --- README.md | 24 +++++++++++++++-------- rede_korrigieren.py | 33 +++++++++++++++++++++----------- tests/test_rede_korrigieren.py | 35 +++++++++++++++++++++++++--------- 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 16f39cd..8f8963e 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1. ./rede_korrigieren.py -i rede.txt # → rede.korrigiert.txt ./rede_korrigieren.py -i rede.txt -o out/rede.txt # eigene Ausgabedatei ./rede_korrigieren.py -i rede.txt -g rede.glossar.txt # mit Glossar +./rede_korrigieren.py -i rede.txt -g allgemein.glossar.txt -g rede.glossar.txt # kombiniert ./rede_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt ``` @@ -129,7 +130,7 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1. |---|---| | `-i`, `--input` | Eingabedatei: Rohabschrift (`.txt`) oder Untertitel (`.vtt`, wird wie mit `vtt2txt.py` umgewandelt), Pflicht | | `-o`, `--output` | Ausgabedatei; Standard: `.korrigiert.txt` neben der Eingabe | -| `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen | +| `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen; mehrfach angeben zum Kombinieren (Ersetzungen in Reihenfolge der Angabe) | | `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) | ## Ablauf @@ -162,8 +163,15 @@ Multi-Domain Task Force Michael Gorbatschow (Vorname so belassen) ``` -Ersetzungen ohne Treffer werden gemeldet (Tippfehler im Glossar). -`ramstein.glossar.txt` und `magdeburg.glossar.txt` sind Beispiele zu den Testdaten. +Ersetzungen ohne Treffer werden gesammelt gemeldet. Bei einem Glossar für eine +einzelne Rede deutet das auf einen Tippfehler hin, bei einem gemeinsamen Glossar ist +es normal. + +Mehrere Glossare lassen sich kombinieren: ein gemeinsames für wiederkehrende Namen +und Hörfehler eines Redners und eines je Rede. Beispiel zu den Testdaten: +`krone-schmalz.glossar.txt` (gemeinsam) plus `ramstein.glossar.txt` bzw. +`magdeburg.glossar.txt`. Das gemeinsame Glossar zuerst angeben, damit seine +Ersetzungen vor den speziellen greifen. ## Testdaten @@ -171,14 +179,14 @@ Ersetzungen ohne Treffer werden gemeldet (Tippfehler im Glossar). (`.vtt`, Original) und daraus mit `vtt2txt.py` gewonnener Rohtext (`.txt`, Eingabe für das Programm): -- `ramstein.de.*`: Rede über die US Air Base Ramstein (mit Glossar `ramstein.glossar.txt`) -- `magdeburg.de-orig.*`: Rede in Magdeburg (mit Glossar `magdeburg.glossar.txt`) +- `ramstein.de.*`: Rede über die US Air Base Ramstein (Glossar `ramstein.glossar.txt`) +- `magdeburg.de-orig.*`: Rede in Magdeburg (Glossar `magdeburg.glossar.txt`) - `Lahnstein.de-orig.*`: Rede „Respekt geht anders“ in Lahnstein (noch ohne Glossar, geladen mit `yt_untertitel.py`) ```bash ./vtt2txt.py -i "testdaten/*.vtt" # erzeugt alle .txt neu -./rede_korrigieren.py -i testdaten/ramstein.de.txt -g ramstein.glossar.txt +./rede_korrigieren.py -i testdaten/ramstein.de.txt -g krone-schmalz.glossar.txt -g ramstein.glossar.txt # → testdaten/ramstein.de.korrigiert.txt (nicht versioniert) ``` @@ -191,8 +199,8 @@ python3 -m pytest ``` Die Tests brauchen kein Ollama: Modellaufrufe werden durch Fakes ersetzt. Geprüft -werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung in den Beispiel-Glossaren -trifft ihre Testdaten“), Streaming-Auswertung, Absatzgliederung (Text bleibt +werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung im Glossar einer Rede trifft +diese Rede, jede im gemeinsamen Glossar trifft mindestens eine“), Streaming-Auswertung, Absatzgliederung (Text bleibt unverändert, 3–10 Sätze), Zwischenspeicher/Fortsetzen und die Kommandozeile. `tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl (Original-Audio, manuell vor automatisch) und Fehlerfälle. diff --git a/rede_korrigieren.py b/rede_korrigieren.py index 4074c3e..79e5624 100755 --- a/rede_korrigieren.py +++ b/rede_korrigieren.py @@ -121,13 +121,18 @@ def read_glossary(path): def apply_replacements(text, replacements): """Replace whole words in file order; report rules without any match.""" + unmatched = [] for wrong, right in replacements: pattern = re.compile(rf"(?.korrigiert.txt)", ) parser.add_argument( - "-g", "--glossar", type=Path, - help="Glossar: ein Begriff pro Zeile oder feste Ersetzung „falsch => richtig“", + "-g", "--glossar", type=Path, action="append", default=[], + help="Glossar: ein Begriff pro Zeile oder feste Ersetzung „falsch => richtig“; " + "mehrfach angeben, um Glossare zu kombinieren (Reihenfolge zählt)", ) parser.add_argument( "-m", "--model", default=MODEL, help=f"Ollama-Modell (Standard: {MODEL})" @@ -376,8 +382,9 @@ def parse_args(): if not args.input.is_file(): parser.error(f"Eingabedatei nicht gefunden: {args.input}") - if args.glossar is not None and not args.glossar.is_file(): - parser.error(f"Glossar nicht gefunden: {args.glossar}") + for glossary in args.glossar: + if not glossary.is_file(): + parser.error(f"Glossar nicht gefunden: {glossary}") if args.output is None: args.output = args.input.with_name(f"{args.input.stem}.korrigiert.txt") if args.output.resolve() == args.input.resolve(): @@ -396,7 +403,15 @@ def main(): target = args.output parts_dir = target.with_suffix(".parts") - terms, replacements = read_glossary(args.glossar) if args.glossar else ([], []) + terms, replacements = [], [] + for glossary in args.glossar: + more_terms, more_replacements = read_glossary(glossary) + print( + f"Glossar {glossary}: {len(more_terms)} Begriffe, " + f"{len(more_replacements)} feste Ersetzungen" + ) + terms += more_terms + replacements += more_replacements system = build_system(terms) raw = source.read_text(encoding="utf-8-sig") @@ -405,11 +420,7 @@ def main(): # Subtitle line breaks carry no meaning; send plain running text text = " ".join(raw.split()) - if args.glossar: - print( - f"Glossar {args.glossar}: {len(terms)} Begriffe, " - f"{len(replacements)} feste Ersetzungen" - ) + if replacements: text = apply_replacements(text, replacements) chunks = split_chunks(text) diff --git a/tests/test_rede_korrigieren.py b/tests/test_rede_korrigieren.py index dfda84c..65ed1ee 100644 --- a/tests/test_rede_korrigieren.py +++ b/tests/test_rede_korrigieren.py @@ -12,6 +12,7 @@ import rede_korrigieren as rk REPO = Path(__file__).resolve().parent.parent SAMPLE = REPO / "testdaten" / "ramstein.de.txt" GLOSSARY = REPO / "ramstein.glossar.txt" +SHARED_GLOSSARY = REPO / "krone-schmalz.glossar.txt" def flat(text): @@ -99,7 +100,7 @@ def test_apply_replacements_whole_words_in_order(capsys): text, [("Egon Bars", "Egon Bahrs"), ("Egon Bar", "Egon Bahr"), ("Fehlt", "x")] ) assert result == "Egon Bahrs Politik, Egon Bahr. Barbara bleibt." - assert "Ersetzung ohne Treffer: „Fehlt“" in capsys.readouterr().out + assert "1 Ersetzung(en) ohne Treffer: „Fehlt“" in capsys.readouterr().out def test_build_system_appends_terms(): @@ -109,18 +110,33 @@ def test_build_system_appends_terms(): assert "- Kreml\n- Egon Bahr" in system -@pytest.mark.parametrize("glossary, sample", [ +SAMPLE_GLOSSARIES = [ ("ramstein.glossar.txt", "ramstein.de.txt"), ("magdeburg.glossar.txt", "magdeburg.de-orig.txt"), -]) +] + + +@pytest.mark.parametrize("glossary, sample", SAMPLE_GLOSSARIES) def test_sample_glossary_replacements_all_match(capsys, glossary, sample): - """Every rule in a sample glossary must hit its sample transcript.""" - _, replacements = rk.read_glossary(REPO / glossary) - text = (REPO / "testdaten" / sample).read_text(encoding="utf-8") - rk.apply_replacements(flat(text), replacements) + """Every rule in a speech glossary must hit its speech, after the shared one.""" + text = flat((REPO / "testdaten" / sample).read_text(encoding="utf-8")) + text = rk.apply_replacements(text, rk.read_glossary(SHARED_GLOSSARY)[1]) + capsys.readouterr() + rk.apply_replacements(text, rk.read_glossary(REPO / glossary)[1]) assert "ohne Treffer" not in capsys.readouterr().out +def test_shared_glossary_rules_match_some_speech(): + texts = [ + flat((REPO / "testdaten" / sample).read_text(encoding="utf-8")) + for _, sample in SAMPLE_GLOSSARIES + ] + for wrong, right in rk.read_glossary(SHARED_GLOSSARY)[1]: + assert any( + rk.apply_replacements(text, [(wrong, right)]) != text for text in texts + ), wrong + + # === Model calls (urlopen faked) === def stream_body(pieces, done_reason="stop"): @@ -263,7 +279,7 @@ def sample_copy(tmp_path): def test_main_default_output_resume_and_glossary(sample_copy, monkeypatch, capsys): monkeypatch.setattr(rk, "correct_chunk", identity_correct) monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) - run_main(monkeypatch, "-i", sample_copy, "-g", GLOSSARY) + run_main(monkeypatch, "-i", sample_copy, "-g", SHARED_GLOSSARY, "-g", GLOSSARY) target = sample_copy.with_name("ramstein.de.korrigiert.txt") result = target.read_text(encoding="utf-8") @@ -275,7 +291,7 @@ def test_main_default_output_resume_and_glossary(sample_copy, monkeypatch, capsy # Second run: everything cached, no model calls monkeypatch.setattr(rk, "correct_chunk", no_model_calls) monkeypatch.setattr(rk, "find_paragraph_starts", no_model_calls) - run_main(monkeypatch, "-i", sample_copy, "-g", GLOSSARY) + run_main(monkeypatch, "-i", sample_copy, "-g", SHARED_GLOSSARY, "-g", GLOSSARY) assert target.read_text(encoding="utf-8") == result @@ -362,6 +378,7 @@ def test_main_empty_input(tmp_path, monkeypatch): (["-i", "gibtsnicht.txt"], "Eingabedatei nicht gefunden"), (["-i", "{sample}", "-o", "{sample}"], "nicht die Eingabedatei"), (["-i", "{sample}", "-g", "gibtsnicht.txt"], "Glossar nicht gefunden"), + (["-i", "{sample}", "-g", str(GLOSSARY), "-g", "fehlt.txt"], "Glossar nicht gefunden: fehlt.txt"), ]) def test_cli_errors(sample_copy, monkeypatch, capsys, args, message): args = [a.format(sample=sample_copy) for a in args]