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:
parent
7bc5e93613
commit
6610679d80
3 changed files with 64 additions and 28 deletions
24
README.md
24
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 # → rede.korrigiert.txt
|
||||||
./rede_korrigieren.py -i rede.txt -o out/rede.txt # eigene Ausgabedatei
|
./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 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
|
./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 |
|
| `-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 |
|
| `-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`) |
|
| `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) |
|
||||||
|
|
||||||
## Ablauf
|
## Ablauf
|
||||||
|
|
@ -162,8 +163,15 @@ Multi-Domain Task Force
|
||||||
Michael Gorbatschow (Vorname so belassen)
|
Michael Gorbatschow (Vorname so belassen)
|
||||||
```
|
```
|
||||||
|
|
||||||
Ersetzungen ohne Treffer werden gemeldet (Tippfehler im Glossar).
|
Ersetzungen ohne Treffer werden gesammelt gemeldet. Bei einem Glossar für eine
|
||||||
`ramstein.glossar.txt` und `magdeburg.glossar.txt` sind Beispiele zu den Testdaten.
|
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
|
## Testdaten
|
||||||
|
|
||||||
|
|
@ -171,14 +179,14 @@ Ersetzungen ohne Treffer werden gemeldet (Tippfehler im Glossar).
|
||||||
(`.vtt`, Original) und daraus mit `vtt2txt.py` gewonnener Rohtext (`.txt`, Eingabe
|
(`.vtt`, Original) und daraus mit `vtt2txt.py` gewonnener Rohtext (`.txt`, Eingabe
|
||||||
für das Programm):
|
für das Programm):
|
||||||
|
|
||||||
- `ramstein.de.*`: Rede über die US Air Base Ramstein (mit Glossar `ramstein.glossar.txt`)
|
- `ramstein.de.*`: Rede über die US Air Base Ramstein (Glossar `ramstein.glossar.txt`)
|
||||||
- `magdeburg.de-orig.*`: Rede in Magdeburg (mit Glossar `magdeburg.glossar.txt`)
|
- `magdeburg.de-orig.*`: Rede in Magdeburg (Glossar `magdeburg.glossar.txt`)
|
||||||
- `Lahnstein.de-orig.*`: Rede „Respekt geht anders“ in Lahnstein (noch ohne Glossar,
|
- `Lahnstein.de-orig.*`: Rede „Respekt geht anders“ in Lahnstein (noch ohne Glossar,
|
||||||
geladen mit `yt_untertitel.py`)
|
geladen mit `yt_untertitel.py`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./vtt2txt.py -i "testdaten/*.vtt" # erzeugt alle .txt neu
|
./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)
|
# → 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
|
Die Tests brauchen kein Ollama: Modellaufrufe werden durch Fakes ersetzt. Geprüft
|
||||||
werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung in den Beispiel-Glossaren
|
werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung im Glossar einer Rede trifft
|
||||||
trifft ihre Testdaten“), Streaming-Auswertung, Absatzgliederung (Text bleibt
|
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.
|
unverändert, 3–10 Sätze), Zwischenspeicher/Fortsetzen und die Kommandozeile.
|
||||||
`tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl
|
`tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl
|
||||||
(Original-Audio, manuell vor automatisch) und Fehlerfälle.
|
(Original-Audio, manuell vor automatisch) und Fehlerfälle.
|
||||||
|
|
|
||||||
|
|
@ -121,13 +121,18 @@ def read_glossary(path):
|
||||||
|
|
||||||
def apply_replacements(text, replacements):
|
def apply_replacements(text, replacements):
|
||||||
"""Replace whole words in file order; report rules without any match."""
|
"""Replace whole words in file order; report rules without any match."""
|
||||||
|
unmatched = []
|
||||||
for wrong, right in replacements:
|
for wrong, right in replacements:
|
||||||
pattern = re.compile(rf"(?<!\w){re.escape(wrong)}(?!\w)")
|
pattern = re.compile(rf"(?<!\w){re.escape(wrong)}(?!\w)")
|
||||||
text, count = pattern.subn(right, text)
|
text, count = pattern.subn(right, text)
|
||||||
if count:
|
if count:
|
||||||
print(f" Ersetzt {count}× „{wrong}“ → „{right}“")
|
print(f" Ersetzt {count}× „{wrong}“ → „{right}“")
|
||||||
else:
|
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
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -366,8 +371,9 @@ def parse_args():
|
||||||
help="Ausgabedatei (Standard: <Eingabe ohne Endung>.korrigiert.txt)",
|
help="Ausgabedatei (Standard: <Eingabe ohne Endung>.korrigiert.txt)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-g", "--glossar", type=Path,
|
"-g", "--glossar", type=Path, action="append", default=[],
|
||||||
help="Glossar: ein Begriff pro Zeile oder feste Ersetzung „falsch => richtig“",
|
help="Glossar: ein Begriff pro Zeile oder feste Ersetzung „falsch => richtig“; "
|
||||||
|
"mehrfach angeben, um Glossare zu kombinieren (Reihenfolge zählt)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-m", "--model", default=MODEL, help=f"Ollama-Modell (Standard: {MODEL})"
|
"-m", "--model", default=MODEL, help=f"Ollama-Modell (Standard: {MODEL})"
|
||||||
|
|
@ -376,8 +382,9 @@ def parse_args():
|
||||||
|
|
||||||
if not args.input.is_file():
|
if not args.input.is_file():
|
||||||
parser.error(f"Eingabedatei nicht gefunden: {args.input}")
|
parser.error(f"Eingabedatei nicht gefunden: {args.input}")
|
||||||
if args.glossar is not None and not args.glossar.is_file():
|
for glossary in args.glossar:
|
||||||
parser.error(f"Glossar nicht gefunden: {args.glossar}")
|
if not glossary.is_file():
|
||||||
|
parser.error(f"Glossar nicht gefunden: {glossary}")
|
||||||
if args.output is None:
|
if args.output is None:
|
||||||
args.output = args.input.with_name(f"{args.input.stem}.korrigiert.txt")
|
args.output = args.input.with_name(f"{args.input.stem}.korrigiert.txt")
|
||||||
if args.output.resolve() == args.input.resolve():
|
if args.output.resolve() == args.input.resolve():
|
||||||
|
|
@ -396,7 +403,15 @@ def main():
|
||||||
target = args.output
|
target = args.output
|
||||||
parts_dir = target.with_suffix(".parts")
|
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)
|
system = build_system(terms)
|
||||||
|
|
||||||
raw = source.read_text(encoding="utf-8-sig")
|
raw = source.read_text(encoding="utf-8-sig")
|
||||||
|
|
@ -405,11 +420,7 @@ def main():
|
||||||
# Subtitle line breaks carry no meaning; send plain running text
|
# Subtitle line breaks carry no meaning; send plain running text
|
||||||
text = " ".join(raw.split())
|
text = " ".join(raw.split())
|
||||||
|
|
||||||
if args.glossar:
|
if replacements:
|
||||||
print(
|
|
||||||
f"Glossar {args.glossar}: {len(terms)} Begriffe, "
|
|
||||||
f"{len(replacements)} feste Ersetzungen"
|
|
||||||
)
|
|
||||||
text = apply_replacements(text, replacements)
|
text = apply_replacements(text, replacements)
|
||||||
|
|
||||||
chunks = split_chunks(text)
|
chunks = split_chunks(text)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import rede_korrigieren as rk
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
SAMPLE = REPO / "testdaten" / "ramstein.de.txt"
|
SAMPLE = REPO / "testdaten" / "ramstein.de.txt"
|
||||||
GLOSSARY = REPO / "ramstein.glossar.txt"
|
GLOSSARY = REPO / "ramstein.glossar.txt"
|
||||||
|
SHARED_GLOSSARY = REPO / "krone-schmalz.glossar.txt"
|
||||||
|
|
||||||
|
|
||||||
def flat(text):
|
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")]
|
text, [("Egon Bars", "Egon Bahrs"), ("Egon Bar", "Egon Bahr"), ("Fehlt", "x")]
|
||||||
)
|
)
|
||||||
assert result == "Egon Bahrs Politik, Egon Bahr. Barbara bleibt."
|
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():
|
def test_build_system_appends_terms():
|
||||||
|
|
@ -109,18 +110,33 @@ def test_build_system_appends_terms():
|
||||||
assert "- Kreml\n- Egon Bahr" in system
|
assert "- Kreml\n- Egon Bahr" in system
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("glossary, sample", [
|
SAMPLE_GLOSSARIES = [
|
||||||
("ramstein.glossar.txt", "ramstein.de.txt"),
|
("ramstein.glossar.txt", "ramstein.de.txt"),
|
||||||
("magdeburg.glossar.txt", "magdeburg.de-orig.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):
|
def test_sample_glossary_replacements_all_match(capsys, glossary, sample):
|
||||||
"""Every rule in a sample glossary must hit its sample transcript."""
|
"""Every rule in a speech glossary must hit its speech, after the shared one."""
|
||||||
_, replacements = rk.read_glossary(REPO / glossary)
|
text = flat((REPO / "testdaten" / sample).read_text(encoding="utf-8"))
|
||||||
text = (REPO / "testdaten" / sample).read_text(encoding="utf-8")
|
text = rk.apply_replacements(text, rk.read_glossary(SHARED_GLOSSARY)[1])
|
||||||
rk.apply_replacements(flat(text), replacements)
|
capsys.readouterr()
|
||||||
|
rk.apply_replacements(text, rk.read_glossary(REPO / glossary)[1])
|
||||||
assert "ohne Treffer" not in capsys.readouterr().out
|
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) ===
|
# === Model calls (urlopen faked) ===
|
||||||
|
|
||||||
def stream_body(pieces, done_reason="stop"):
|
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):
|
def test_main_default_output_resume_and_glossary(sample_copy, monkeypatch, capsys):
|
||||||
monkeypatch.setattr(rk, "correct_chunk", identity_correct)
|
monkeypatch.setattr(rk, "correct_chunk", identity_correct)
|
||||||
monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5))
|
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")
|
target = sample_copy.with_name("ramstein.de.korrigiert.txt")
|
||||||
result = target.read_text(encoding="utf-8")
|
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
|
# Second run: everything cached, no model calls
|
||||||
monkeypatch.setattr(rk, "correct_chunk", no_model_calls)
|
monkeypatch.setattr(rk, "correct_chunk", no_model_calls)
|
||||||
monkeypatch.setattr(rk, "find_paragraph_starts", 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
|
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", "gibtsnicht.txt"], "Eingabedatei nicht gefunden"),
|
||||||
(["-i", "{sample}", "-o", "{sample}"], "nicht die Eingabedatei"),
|
(["-i", "{sample}", "-o", "{sample}"], "nicht die Eingabedatei"),
|
||||||
(["-i", "{sample}", "-g", "gibtsnicht.txt"], "Glossar nicht gefunden"),
|
(["-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):
|
def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
|
||||||
args = [a.format(sample=sample_copy) for a in args]
|
args = [a.format(sample=sample_copy) for a in args]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue