feat: mehrere Glossare kombinieren, gemeinsames Krone-Schmalz-Glossar

- 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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-10 19:56:18 +02:00
commit 6610679d80
3 changed files with 64 additions and 28 deletions

View file

@ -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: `<Eingabe ohne Endung>.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.

View file

@ -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"(?<!\w){re.escape(wrong)}(?!\w)")
text, count = pattern.subn(right, text)
if count:
print(f" Ersetzt {count}× „{wrong}“ → „{right}“")
else:
print(f" WARNUNG: Ersetzung ohne Treffer: „{wrong}“")
unmatched.append(wrong)
# Normal for a shared glossary, a hint at a typo in one for a single speech
if unmatched:
names = ", ".join(f"„{wrong}“" for wrong in unmatched)
print(f" {len(unmatched)} Ersetzung(en) ohne Treffer: {names}")
return text
@ -366,8 +371,9 @@ def parse_args():
help="Ausgabedatei (Standard: <Eingabe ohne Endung>.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)

View file

@ -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]