From 5a59baef88527e03a7765846d90df6bd9f7afe79 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sun, 11 Oct 2026 20:52:02 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20rede=5Fkorrigieren.py=20=E2=86=92?= =?UTF-8?q?=20abschrift=5Fkorrigieren.py,=20Texte=20neutral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Programm verarbeitet beliebige gesprochene Beiträge (Reden, Vorträge, Interviews, Videos). Prompts und Texte sprechen von „Beitrag“ statt „Rede“; README-Titel passend zum Projektverzeichnis youtube_abschrift_korrigieren. Co-Authored-By: Claude Opus 5.5 --- README.md | 68 +++--- ...korrigieren.py => abschrift_korrigieren.py | 41 ++-- aenderungen.py | 2 +- glossar_vorschlag.py | 7 +- tests/conftest.py | 4 +- ...ieren.py => test_abschrift_korrigieren.py} | 226 +++++++++--------- tests/test_glossar_vorschlag.py | 6 +- vtt2txt.py | 4 +- yt_untertitel.py | 2 +- 9 files changed, 183 insertions(+), 177 deletions(-) rename rede_korrigieren.py => abschrift_korrigieren.py (94%) rename tests/{test_rede_korrigieren.py => test_abschrift_korrigieren.py} (78%) diff --git a/README.md b/README.md index cf097d1..1410d39 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# youtube_rede_korrigieren +# youtube_abschrift_korrigieren -Korrigiert automatische Abschriften deutscher (und englischer) Reden (z. B. -YouTube-Untertitel als Text) mit einem lokalen Sprachmodell über Ollama: Rechtschreibung, Satzzeichen, +Korrigiert automatische Abschriften gesprochener Beiträge – Reden, Vorträge, +Interviews, Videos, auf Deutsch oder Englisch (z. B. YouTube-Untertitel als Text) – +mit einem lokalen Sprachmodell über Ollama: Rechtschreibung, Satzzeichen, Hörfehler, Absätze – möglichst wortgetreu, ohne Zusammenfassung oder Glättung. +Das Hauptprogramm ist `abschrift_korrigieren.py`. Die Werkzeuge sind einzeln oder hintereinander nutzbar: @@ -11,7 +13,7 @@ Die Werkzeuge sind einzeln oder hintereinander nutzbar: | `extract_text.py` | YouTube-URL → Textdatei in einem Schritt (die beiden folgenden zusammen) | | `yt_untertitel.py` | Untertitel eines YouTube-Videos als VTT herunterladen | | `vtt2txt.py` | VTT in eine Rohabschrift (Text) umwandeln | -| `rede_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren | +| `abschrift_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren | | `aenderungen.py` | Änderungsbericht: was hat das Modell gegenüber dem Rohtext geändert? | | `glossar_vorschlag.py` | Glossar-Entwurf: vermutete Hörfehler bei Namen und Begriffen | @@ -21,7 +23,7 @@ Die Werkzeuge sind einzeln oder hintereinander nutzbar: - für den Download: [yt-dlp](https://github.com/yt-dlp/yt-dlp) (`pip install yt-dlp`) - Ollama auf `127.0.0.1:11434` mit dem Modell `qwen3:32b` (`ollama pull qwen3:32b`) - `rede_korrigieren.py` und `glossar_vorschlag.py` prüfen beides vor dem Start und + `abschrift_korrigieren.py` und `glossar_vorschlag.py` prüfen beides vor dem Start und brechen sonst mit einem Hinweis ab (`sudo systemctl start ollama` bzw. `ollama pull …`) Für gute Geschwindigkeit muss das Modell komplett in den Grafikspeicher passen. @@ -34,7 +36,7 @@ der Lauf wird etwa 12-mal langsamer. Von der YouTube-URL zum korrigierten Text in einem Befehl: ```bash -./rede_korrigieren.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.korrigiert.txt +./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.korrigiert.txt # → lahnstein.korrigiert.txt, lahnstein.txt (Rohtext), lahnstein.aenderungen.html ``` @@ -42,7 +44,7 @@ Oder in zwei Schritten, um den Rohtext vorher anzusehen: ```bash ./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.txt -./rede_korrigieren.py -i lahnstein.txt # → lahnstein.korrigiert.txt +./abschrift_korrigieren.py -i lahnstein.txt # → lahnstein.korrigiert.txt ``` Dasselbe in Einzelschritten, wenn die VTT-Datei erhalten bleiben soll: @@ -50,10 +52,10 @@ Dasselbe in Einzelschritten, wenn die VTT-Datei erhalten bleiben soll: ```bash ./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten ./vtt2txt.py -i testdaten/Lahnstein.de-orig.vtt -./rede_korrigieren.py -i testdaten/Lahnstein.de-orig.txt +./abschrift_korrigieren.py -i testdaten/Lahnstein.de-orig.txt ``` -Der Schritt über `vtt2txt.py` ist optional: `rede_korrigieren.py` nimmt auch die +Der Schritt über `vtt2txt.py` ist optional: `abschrift_korrigieren.py` nimmt auch die `.vtt` direkt. Die `.txt` lohnt sich, um den Rohtext anzusehen oder ein Glossar daraus zu erstellen. @@ -70,7 +72,7 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei ./extract_text.py -u URL -f -o rede.txt # lesbarer Fließtext ./extract_text.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt ./extract_text.py -u URL -z 00:05-00:06 00:10-00:12 00:25 # mehrere Bereiche -./rede_korrigieren.py -i rede.txt # → rede.korrigiert.txt +./abschrift_korrigieren.py -i rede.txt # → rede.korrigiert.txt ``` | Option | Bedeutung | @@ -80,12 +82,12 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei | `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `__.txt`, siehe unten | | `-z`, `--zeitraeume` | nur Untertitel in diesen Bereichen, siehe [Zeitauswahl](#zeitauswahl--z) | | `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen | -| `-f`, `--fliesstext` | Fließtext, auf 80 Zeichen umbrochen (Wörter werden nie getrennt), statt der kurzen Untertitelzeilen; beides versteht `rede_korrigieren.py` | +| `-f`, `--fliesstext` | Fließtext, auf 80 Zeichen umbrochen (Wörter werden nie getrennt), statt der kurzen Untertitelzeilen; beides versteht `abschrift_korrigieren.py` | ### Standard-Dateinamen für YouTube-Videos Ohne `-o` bzw. `-n` bilden `extract_text.py`, `yt_untertitel.py` und -`rede_korrigieren.py -u` den Namen aus Kanal, Titel und Hochladedatum: +`abschrift_korrigieren.py -u` den Namen aus Kanal, Titel und Hochladedatum: | Video | Name | |---|---| @@ -136,7 +138,7 @@ Eine vorhandene Datei gleichen Namens wird überschrieben. ### Untertitel umwandeln: `vtt2txt.py` Macht aus VTT-Untertiteln (z. B. von `yt-dlp --write-auto-subs`) die Rohabschrift -für `rede_korrigieren.py`: Zeitstempel und Tags werden entfernt. YouTube-Auto-Untertitel +für `abschrift_korrigieren.py`: Zeitstempel und Tags werden entfernt. YouTube-Auto-Untertitel zeigen ein rollendes Fenster, jeder Block wiederholt das Ende des vorigen. Übernommen wird nur, was nach dieser Überlappung neu ist; sagt der Redner eine Zeile wirklich zweimal, bleibt sie zweimal erhalten. @@ -160,7 +162,7 @@ zweimal, bleibt sie zweimal erhalten. #### Zeitauswahl (`-z`) -Gilt gleich für `vtt2txt.py`, `extract_text.py` und `rede_korrigieren.py`. +Gilt gleich für `vtt2txt.py`, `extract_text.py` und `abschrift_korrigieren.py`. | Zeit | Bedeutung | |---|---| @@ -206,25 +208,25 @@ Der Dateiname enthält immer die angegebenen Zeiten. Eine fehlerhafte Datei (keine Untertitel, nicht UTF-8) wird gemeldet, die übrigen werden trotzdem umgewandelt; der Exit-Code ist dann 1. -### Abschrift korrigieren: `rede_korrigieren.py` +### Abschrift korrigieren: `abschrift_korrigieren.py` ```bash -./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 -./rede_korrigieren.py -u URL # YouTube → __.korrigiert.txt -./rede_korrigieren.py -i speech.txt -l en # englische Rede -./rede_korrigieren.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt des Videos -./rede_korrigieren.py -u URL -z 00:05-00:06 00:25 # mehrere Bereiche, eine Datei +./abschrift_korrigieren.py -i rede.txt # → rede.korrigiert.txt +./abschrift_korrigieren.py -i rede.txt -o out/rede.txt # eigene Ausgabedatei +./abschrift_korrigieren.py -i rede.txt -g rede.glossar.txt # mit Glossar +./abschrift_korrigieren.py -i rede.txt -g allgemein.glossar.txt -g rede.glossar.txt # kombiniert +./abschrift_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt +./abschrift_korrigieren.py -u URL # YouTube → __.korrigiert.txt +./abschrift_korrigieren.py -i talk.txt -l en # englischer Beitrag +./abschrift_korrigieren.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt des Videos +./abschrift_korrigieren.py -u URL -z 00:05-00:06 00:25 # mehrere Bereiche, eine Datei ``` | Option | Bedeutung | |---|---| | `-i`, `--input` | Eingabedatei: Rohabschrift (`.txt`) oder Untertitel (`.vtt`, wird wie mit `vtt2txt.py` umgewandelt) | | `-u`, `--url` | statt `-i`: YouTube-URL; Untertitel wie bei `extract_text.py`. Der Rohtext wird neben der Ausgabe gespeichert (`rede.korrigiert.txt` → `rede.txt`, sonst `.roh.txt`), eine vorhandene, abweichende Datei aber nie überschrieben | -| `-l`, `--lang` | Sprache der Rede: `de` oder `en` (auch mit Region, z. B. `en-US`); wählt Prompts und Prüfmarke. Mit `-u` zugleich die Sprache der Untertitel. Standard: `de`, mit `-u` die Sprache des Original-Audios | +| `-l`, `--lang` | Sprache des Beitrags: `de` oder `en` (auch mit Region, z. B. `en-US`); wählt Prompts und Prüfmarke. Mit `-u` zugleich die Sprache der Untertitel. Standard: `de`, mit `-u` die Sprache des Original-Audios | | `-o`, `--output` | Ausgabedatei; Standard: `.korrigiert.txt` neben der Eingabe, mit `-u` `__.korrigiert.txt`; mit `-z` zusätzlich die Bereiche im Namen | | `-z`, `--zeitraeume` | nur diese Bereiche korrigieren, siehe [Zeitauswahl](#zeitauswahl--z); nur mit `.vtt`-Eingabe oder `-u`, da reiner Text keine Zeitstempel hat | | `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen | @@ -260,13 +262,13 @@ Zweifelhafte Stellen markiert das Modell als `Lesart [prüfen: Originalwortlaut] in englischen Reden als `reading [check: original wording]`. Englische Reden bekommen eigene, englische Prompts für Korrektur und Absätze -(`SYSTEM_EN` usw. in `rede_korrigieren.py`); dort entfernt das Modell auch die +(`SYSTEM_EN` usw. in `abschrift_korrigieren.py`); dort entfernt das Modell auch die Sprecherwechsel-Marken `>>` der YouTube-Auto-Untertitel. Weitere Sprachen lassen sich über das Wörterbuch `PROMPTS` ergänzen. ## Änderungsbericht -Jeder Lauf von `rede_korrigieren.py` schreibt neben die Ausgabe +Jeder Lauf von `abschrift_korrigieren.py` schreibt neben die Ausgabe `.aenderungen.html` (aus `rede.korrigiert.txt` wird `rede.aenderungen.html`) und zeigt die häufigsten Änderungen in der Konsole. Der Bericht enthält: @@ -299,12 +301,12 @@ Multi-Domain Task Force Michael Gorbatschow (Vorname so belassen) ``` -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 +Ersetzungen ohne Treffer werden gesammelt gemeldet. Bei einem Glossar für einen +einzelnen Beitrag 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: +und Hörfehler eines Redners oder Kanals und eines je Beitrag. Beispiel zu den Testdaten: `krone-schmalz.glossar.txt` (gemeinsam) plus `ramstein.glossar.txt`, `magdeburg.glossar.txt` bzw. `lahnstein.glossar.txt`. Das gemeinsame Glossar zuerst angeben, damit seine Ersetzungen vor den speziellen greifen. Ein weiteres Kanal-Glossar: @@ -347,7 +349,7 @@ Roh- und korrigiertem Text entfernt; das Modell bekommt sie nicht zu sehen. | `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` | Der Entwurf ist ein Ausgangspunkt, kein fertiges Glossar: jeden Eintrag prüfen, -Brauchbares ins Glossar der Rede (oder ins gemeinsame) übernehmen. Die Modellantworten +Brauchbares ins Glossar des Beitrags (oder ins gemeinsame) übernehmen. Die Modellantworten werden in `.parts/` zwischengespeichert. ## Testdaten @@ -365,7 +367,7 @@ Alle drei mit dem gemeinsamen `krone-schmalz.glossar.txt` davor. ```bash ./vtt2txt.py -i "testdaten/*.vtt" # erzeugt alle .txt neu -./rede_korrigieren.py -i testdaten/ramstein.de.txt -g krone-schmalz.glossar.txt -g ramstein.glossar.txt +./abschrift_korrigieren.py -i testdaten/ramstein.de.txt -g krone-schmalz.glossar.txt -g ramstein.glossar.txt # → testdaten/ramstein.de.korrigiert.txt (nicht versioniert) ``` @@ -402,7 +404,7 @@ Teil neu berechnet. Den Ordner kann man jederzeit löschen. ## Einstellungen -Oben in `rede_korrigieren.py`: +Oben in `abschrift_korrigieren.py`: | Konstante | Standard | Bedeutung | |---|---|---| diff --git a/rede_korrigieren.py b/abschrift_korrigieren.py similarity index 94% rename from rede_korrigieren.py rename to abschrift_korrigieren.py index 4a63544..5520b77 100755 --- a/rede_korrigieren.py +++ b/abschrift_korrigieren.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -"""Korrigiert eine automatische Redeabschrift abschnittsweise mit einem lokalen Ollama-Modell.""" +"""Korrigiert eine automatische Abschrift (Rede, Vortrag, Interview, Video) abschnittsweise +mit einem lokalen Ollama-Modell.""" from pathlib import Path import argparse import hashlib @@ -33,7 +34,8 @@ MIN_PARAGRAPH_SENTENCES = 3 # kürzere Absätze werden mit dem Nachbarn verbun SENTENCE_END = re.compile(r"[.!?…][\"'»«“”)\]]*$") SENTENCE_SPLIT = re.compile(r"(?:(?<=[.!?…])|(?<=[.!?…][\"'»«“”)\]]))\s+") -SYSTEM = """Du korrigierst eine automatische Abschrift einer deutschen Rede. +SYSTEM = """Du korrigierst die automatische Abschrift eines deutschsprachigen +gesprochenen Beitrags (z. B. Rede, Vortrag, Interview, Video). Erstelle eine lesbare, möglichst wortgetreue Abschrift. Keine Zusammenfassung und keine stilistische Neufassung. @@ -59,7 +61,7 @@ Keine Zusammenfassung und keine stilistische Neufassung. """ -PARAGRAPH_SYSTEM = f"""Du gliederst eine Redeabschrift in Absätze. +PARAGRAPH_SYSTEM = f"""Du gliederst eine Abschrift in Absätze. Die Sätze sind mit [Nummer] gekennzeichnet. Bestimme, mit welchen Sätzen ein neuer Absatz beginnt. @@ -72,7 +74,7 @@ ein neuer Absatz beginnt. - Antworte nur mit JSON: {{"absatzanfaenge": [aufsteigende Satznummern]}} """ -SPLIT_SYSTEM = f"""Du teilst einen zu langen Absatz einer Redeabschrift. +SPLIT_SYSTEM = f"""Du teilst einen zu langen Absatz einer Abschrift. Die Sätze sind mit [Nummer] gekennzeichnet. Teile den Absatz an inhaltlich passenden Stellen in kürzere Absätze von je 3 bis 8 Sätzen, @@ -83,7 +85,8 @@ an, mit denen ein neuer Absatz beginnt (nicht den ersten Satz). - Antworte nur mit JSON: {{"absatzanfaenge": [aufsteigende Satznummern]}} """ -SYSTEM_EN = """You correct an automatic transcript of an English speech. +SYSTEM_EN = """You correct an automatic transcript of English spoken content +(e.g. a speech, talk, interview or video). Produce a readable transcript that is as close to verbatim as possible. No summary and no stylistic rewrite. @@ -112,7 +115,7 @@ No summary and no stylistic rewrite. - Output nothing but the corrected transcript. """ -PARAGRAPH_SYSTEM_EN = f"""You divide a speech transcript into paragraphs. +PARAGRAPH_SYSTEM_EN = f"""You divide a transcript into paragraphs. The sentences are marked with [number]. Decide which sentences start a new paragraph. @@ -125,7 +128,7 @@ paragraph. - Answer only with JSON: {{"absatzanfaenge": [ascending sentence numbers]}} """ -SPLIT_SYSTEM_EN = f"""You split a paragraph of a speech transcript that is too long. +SPLIT_SYSTEM_EN = f"""You split a paragraph of a transcript that is too long. The sentences are marked with [number]. Split the paragraph at suitable points into shorter paragraphs of 3 to 8 sentences each, none longer than @@ -136,16 +139,16 @@ start a new paragraph (not the first sentence). - Answer only with JSON: {{"absatzanfaenge": [ascending sentence numbers]}} """ -# Prompts per language of the speech +# Prompts per language of the recording PROMPTS = { "de": { "system": SYSTEM, "paragraph": PARAGRAPH_SYSTEM, "split": SPLIT_SYSTEM, "glossary": "Glossar: korrekte Schreibweisen von Namen und Begriffen, die in " - "dieser Rede vorkommen. Falsch erkannte Varianten davon in der Abschrift " + "diesem Beitrag vorkommen. Falsch erkannte Varianten davon in der Abschrift " "stillschweigend korrigieren:", - "excerpt": "Der Text ist ein Ausschnitt aus einer längeren Rede und kann mitten " + "excerpt": "Der Text ist ein Ausschnitt aus einem längeren Beitrag und kann mitten " "im Satz beginnen oder enden. Vervollständige angeschnittene Sätze nicht und " "ergänze kein Wort, auch nicht in einer Markierung; lass einen unvollständigen " "Anfang oder Schluss unverändert stehen.", @@ -155,9 +158,9 @@ PROMPTS = { "paragraph": PARAGRAPH_SYSTEM_EN, "split": SPLIT_SYSTEM_EN, "glossary": "Glossary: correct spellings of names and terms that occur in " - "this speech. Silently correct misrecognized variants of them in the " + "this recording. Silently correct misrecognized variants of them in the " "transcript:", - "excerpt": "The text is an excerpt from a longer speech and may begin or end " + "excerpt": "The text is an excerpt from a longer recording and may begin or end " "in the middle of a sentence. Do not complete cut-off sentences and do not " "add any word, not even in a mark; leave an incomplete beginning or ending " "unchanged.", @@ -217,7 +220,7 @@ def apply_replacements(text, replacements): print(f" Ersetzt {count}× „{wrong}“ → „{right}“") else: unmatched.append(wrong) - # Normal for a shared glossary, a hint at a typo in one for a single speech + # Normal for a shared glossary, a hint at a typo in one for a single recording if unmatched: names = ", ".join(f"„{wrong}“" for wrong in unmatched) print(f" {len(unmatched)} Ersetzung(en) ohne Treffer: {names}") @@ -473,8 +476,8 @@ def build_paragraphs(text, parts_dir, lang=DEFAULT_LANGUAGE): def parse_args(): parser = argparse.ArgumentParser( - description="Korrigiert eine automatische Abschrift einer deutschen Rede " - "mit einem lokalen Sprachmodell über Ollama." + description="Korrigiert die automatische Abschrift eines gesprochenen Beitrags " + "(Rede, Vortrag, Interview, Video) mit einem lokalen Sprachmodell über Ollama." ) source = parser.add_mutually_exclusive_group(required=True) source.add_argument( @@ -487,7 +490,7 @@ def parse_args(): ) parser.add_argument( "-l", "--lang", - help=f"Sprache der Rede ({', '.join(PROMPTS)}), mit -u auch die der Untertitel " + help=f"Sprache des Beitrags ({', '.join(PROMPTS)}), mit -u auch die der Untertitel " f"(Standard: {DEFAULT_LANGUAGE}, mit -u die Sprache des Original-Audios)", ) parser.add_argument( @@ -555,13 +558,13 @@ def save_raw_text(path, raw): def read_source(args): - """Return the raw transcript, the output path and the language of the speech.""" + """Return the raw transcript, the output path and the language of the recording.""" if args.url is not None: info, text = fetch_text(args.url, args.lang, args.bereiche, args.exakt) lang = base_language(args.lang or original_language(info) or DEFAULT_LANGUAGE) if lang not in PROMPTS: raise SystemExit( - f"Die Rede ist auf „{lang}“; unterstützt: {', '.join(PROMPTS)} (-l)." + f"Der Beitrag ist auf „{lang}“; unterstützt: {', '.join(PROMPTS)} (-l)." ) suffix = time_suffix(args.bereiche) target = args.output or Path(f"{default_name(info)}{suffix}.korrigiert.txt") @@ -584,7 +587,7 @@ def main(): print(f"Modell: {MODEL}") check_ollama(MODEL) raw, target, lang = read_source(args) - print(f"Sprache der Rede: {lang}") + print(f"Sprache des Beitrags: {lang}") parts_dir = target.with_suffix(".parts") terms, replacements = [], [] diff --git a/aenderungen.py b/aenderungen.py index 3faf12f..68e0930 100755 --- a/aenderungen.py +++ b/aenderungen.py @@ -11,7 +11,7 @@ import re from vtt2txt import vtt_to_lines -# "[prüfen: …]" in German speeches, "[check: …]" in English ones +# "[prüfen: …]" in German transcripts, "[check: …]" in English ones REVIEW_MARK = re.compile(r"\[(?:prüfen|check):[^\]]*\]") TOKEN = re.compile(rf"{REVIEW_MARK.pattern}|\S+") NON_WORD = re.compile(r"[^\w]+") diff --git a/glossar_vorschlag.py b/glossar_vorschlag.py index 9369d36..2750478 100755 --- a/glossar_vorschlag.py +++ b/glossar_vorschlag.py @@ -11,13 +11,14 @@ import json import re from aenderungen import compare, read_raw -from rede_korrigieren import ( +from abschrift_korrigieren import ( MODEL, apply_replacements, chat_request, check_ollama, ollama_open, read_glossary, split_chunks, ) from vtt2txt import split_segments -SYSTEM = """Du suchst Hörfehler in der automatischen Abschrift einer deutschen Rede. +SYSTEM = """Du suchst Hörfehler in der automatischen Abschrift eines deutschsprachigen +gesprochenen Beitrags (z. B. Rede, Vortrag, Interview, Video). Liste Wörter und Wortgruppen auf, die die Spracherkennung vermutlich falsch erkannt hat, vor allem: @@ -163,7 +164,7 @@ def render(source, model, suggestions, changes): lines = [ f"# Glossar-ENTWURF für {source.name} (glossar_vorschlag.py, Modell {model})", "# Alle Einträge sind auskommentiert. Jeden prüfen; zum Übernehmen „# ? “", - "# entfernen und die Zeile ins Glossar der Rede (oder ins gemeinsame) kopieren.", + "# entfernen und die Zeile ins Glossar des Beitrags (oder ins gemeinsame) kopieren.", ] for title, wanted in (("laut Modell sicher", True), ("laut Modell unsicher", False)): lines += ["", f"# --- Vom Modell vermutete Hörfehler, {title} ---"] diff --git a/tests/conftest.py b/tests/conftest.py index db2f92f..1a5eccc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,8 @@ def ollama_available(request, monkeypatch): if "real_ollama_check" in request.keywords: return import glossar_vorschlag - import rede_korrigieren - for module in (rede_korrigieren, glossar_vorschlag): + import abschrift_korrigieren + for module in (abschrift_korrigieren, glossar_vorschlag): monkeypatch.setattr(module, "check_ollama", lambda model: None) diff --git a/tests/test_rede_korrigieren.py b/tests/test_abschrift_korrigieren.py similarity index 78% rename from tests/test_rede_korrigieren.py rename to tests/test_abschrift_korrigieren.py index 6e22d1b..6d38d58 100644 --- a/tests/test_rede_korrigieren.py +++ b/tests/test_abschrift_korrigieren.py @@ -1,4 +1,4 @@ -"""Tests for rede_korrigieren.py; the Ollama model is always replaced by fakes.""" +"""Tests for abschrift_korrigieren.py; the Ollama model is always replaced by fakes.""" import io import json import shutil @@ -8,7 +8,7 @@ from pathlib import Path import pytest -import rede_korrigieren as rk +import abschrift_korrigieren as ak REPO = Path(__file__).resolve().parent.parent SAMPLE = REPO / "testdaten" / "ramstein.de.txt" @@ -47,38 +47,38 @@ def no_model_calls(*args, **kwargs): def run_main(monkeypatch, *args): - monkeypatch.setattr(sys, "argv", ["rede_korrigieren.py", *map(str, args)]) + monkeypatch.setattr(sys, "argv", ["abschrift_korrigieren.py", *map(str, args)]) # main() sets the module-wide model; restore it after the test - monkeypatch.setattr(rk, "MODEL", rk.MODEL) - rk.main() + monkeypatch.setattr(ak, "MODEL", ak.MODEL) + ak.main() # === Chunking and word counts === def test_split_chunks_keeps_text_and_ends_at_sentence(): text = flat(SAMPLE.read_text(encoding="utf-8")) - chunks = rk.split_chunks(text) + chunks = ak.split_chunks(text) assert "".join(chunks) == text assert len(chunks) > 1 for chunk in chunks[:-1]: - assert rk.SENTENCE_END.search(chunk.rstrip()) - assert rk.CHUNK_SIZE <= len(chunk.split()) <= rk.CHUNK_SIZE + rk.MAX_EXTRA_WORDS + assert ak.SENTENCE_END.search(chunk.rstrip()) + assert ak.CHUNK_SIZE <= len(chunk.split()) <= ak.CHUNK_SIZE + ak.MAX_EXTRA_WORDS def test_split_chunks_cuts_at_limit_without_sentence_end(monkeypatch): - monkeypatch.setattr(rk, "CHUNK_SIZE", 3) - monkeypatch.setattr(rk, "MAX_EXTRA_WORDS", 2) - chunks = rk.split_chunks("a b c d e f g h") + monkeypatch.setattr(ak, "CHUNK_SIZE", 3) + monkeypatch.setattr(ak, "MAX_EXTRA_WORDS", 2) + chunks = ak.split_chunks("a b c d e f g h") assert [len(c.split()) for c in chunks] == [5, 3] def test_split_chunks_empty(): - assert rk.split_chunks("") == [] + assert ak.split_chunks("") == [] def test_count_words_ignores_review_marks(): - assert rk.count_words("Ramstein [prüfen: Rammstein] liegt") == 2 + assert ak.count_words("Ramstein [prüfen: Rammstein] liegt") == 2 # === Glossary === @@ -90,14 +90,14 @@ def test_read_glossary(tmp_path): "Multi-Domain Task Force\nkaputt =>\n", encoding="utf-8", ) - terms, replacements = rk.read_glossary(path) + terms, replacements = ak.read_glossary(path) assert terms == ["Multi-Domain Task Force"] assert replacements == [("Kremmel", "Kreml"), ("Willly Brand", "Willy Brandt")] def test_apply_replacements_whole_words_in_order(capsys): text = "Egon Bars Politik, Egon Bar. Barbara bleibt." - result = rk.apply_replacements( + result = ak.apply_replacements( text, [("Egon Bars", "Egon Bahrs"), ("Egon Bar", "Egon Bahr"), ("Fehlt", "x")] ) assert result == "Egon Bahrs Politik, Egon Bahr. Barbara bleibt." @@ -105,9 +105,9 @@ def test_apply_replacements_whole_words_in_order(capsys): def test_build_system_appends_terms(): - assert rk.build_system([]) == rk.SYSTEM - system = rk.build_system(["Kreml", "Egon Bahr"]) - assert system.startswith(rk.SYSTEM) + assert ak.build_system([]) == ak.SYSTEM + system = ak.build_system(["Kreml", "Egon Bahr"]) + assert system.startswith(ak.SYSTEM) assert "- Kreml\n- Egon Bahr" in system @@ -122,9 +122,9 @@ SAMPLE_GLOSSARIES = [ def test_sample_glossary_replacements_all_match(capsys, glossary, sample): """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]) + text = ak.apply_replacements(text, ak.read_glossary(SHARED_GLOSSARY)[1]) capsys.readouterr() - rk.apply_replacements(text, rk.read_glossary(REPO / glossary)[1]) + ak.apply_replacements(text, ak.read_glossary(REPO / glossary)[1]) assert "ohne Treffer" not in capsys.readouterr().out @@ -133,9 +133,9 @@ def test_shared_glossary_rules_match_some_speech(): flat((REPO / "testdaten" / sample).read_text(encoding="utf-8")) for _, sample in SAMPLE_GLOSSARIES ] - for wrong, right in rk.read_glossary(SHARED_GLOSSARY)[1]: + for wrong, right in ak.read_glossary(SHARED_GLOSSARY)[1]: assert any( - rk.apply_replacements(text, [(wrong, right)]) != text for text in texts + ak.apply_replacements(text, [(wrong, right)]) != text for text in texts ), wrong @@ -149,68 +149,68 @@ def stream_body(pieces, done_reason="stop"): def test_correct_chunk_joins_stream(monkeypatch, capsys): - monkeypatch.setattr(rk.urllib.request, "urlopen", + monkeypatch.setattr(ak.urllib.request, "urlopen", fake_urlopen(stream_body(["Ich grüße ", "Sie. ", "\n"]))) - assert rk.correct_chunk("ich grüße sie", 1, rk.SYSTEM) == "Ich grüße Sie." + assert ak.correct_chunk("ich grüße sie", 1, ak.SYSTEM) == "Ich grüße Sie." assert "10.0 Tokens/s" in capsys.readouterr().out def test_correct_chunk_truncated(monkeypatch): - monkeypatch.setattr(rk.urllib.request, "urlopen", + monkeypatch.setattr(ak.urllib.request, "urlopen", fake_urlopen(stream_body(["Text"], done_reason="length"))) with pytest.raises(RuntimeError, match="abgeschnitten"): - rk.correct_chunk("text", 1, rk.SYSTEM) + ak.correct_chunk("text", 1, ak.SYSTEM) def test_correct_chunk_empty(monkeypatch): - monkeypatch.setattr(rk.urllib.request, "urlopen", fake_urlopen(stream_body([" "]))) + monkeypatch.setattr(ak.urllib.request, "urlopen", fake_urlopen(stream_body([" "]))) with pytest.raises(RuntimeError, match="leere Modellantwort"): - rk.correct_chunk("text", 1, rk.SYSTEM) + ak.correct_chunk("text", 1, ak.SYSTEM) def test_correct_chunk_ollama_error(monkeypatch): - monkeypatch.setattr(rk.urllib.request, "urlopen", + monkeypatch.setattr(ak.urllib.request, "urlopen", fake_urlopen(b'{"error": "model not found"}\n')) with pytest.raises(RuntimeError, match="model not found"): - rk.correct_chunk("text", 1, rk.SYSTEM) + ak.correct_chunk("text", 1, ak.SYSTEM) def test_find_paragraph_starts_filters_numbers(monkeypatch): content = json.dumps({"absatzanfaenge": [1, 10, 12, 15, 20, "x"]}) body = json.dumps({"message": {"content": content}}).encode("utf-8") - monkeypatch.setattr(rk.urllib.request, "urlopen", fake_urlopen(body)) + monkeypatch.setattr(ak.urllib.request, "urlopen", fake_urlopen(body)) # Window holds sentences 10..15: 10 and 15 allowed, 1/20/"x" dropped - assert rk.find_paragraph_starts(["s"] * 6, 10, rk.PARAGRAPH_SYSTEM) == {10, 12, 15} + assert ak.find_paragraph_starts(["s"] * 6, 10, ak.PARAGRAPH_SYSTEM) == {10, 12, 15} def test_find_paragraph_starts_never_first_sentence(monkeypatch): content = json.dumps({"absatzanfaenge": [1, 3]}) body = json.dumps({"message": {"content": content}}).encode("utf-8") - monkeypatch.setattr(rk.urllib.request, "urlopen", fake_urlopen(body)) - assert rk.find_paragraph_starts(["s"] * 5, 1, rk.PARAGRAPH_SYSTEM) == {3} + monkeypatch.setattr(ak.urllib.request, "urlopen", fake_urlopen(body)) + assert ak.find_paragraph_starts(["s"] * 5, 1, ak.PARAGRAPH_SYSTEM) == {3} def test_find_paragraph_starts_bad_json(monkeypatch): body = json.dumps({"message": {"content": "kein JSON"}}).encode("utf-8") - monkeypatch.setattr(rk.urllib.request, "urlopen", fake_urlopen(body)) + monkeypatch.setattr(ak.urllib.request, "urlopen", fake_urlopen(body)) with pytest.raises(RuntimeError, match="unbrauchbare Antwort"): - rk.find_paragraph_starts(["s"] * 3, 1, rk.PARAGRAPH_SYSTEM) + ak.find_paragraph_starts(["s"] * 3, 1, ak.PARAGRAPH_SYSTEM) # === Sentences and paragraphs === def test_split_sentences_keeps_closing_quotes(): text = "Er sagte: „Ja.“ Dann ging er. Wirklich?! [applaus] Gut… Ende." - sentences = rk.split_sentences(text) + sentences = ak.split_sentences(text) assert sentences == ["Er sagte: „Ja.“", "Dann ging er.", "Wirklich?!", "[applaus] Gut…", "Ende."] assert " ".join(sentences) == text def test_group_paragraphs(): - assert rk.group_paragraphs(7, {3, 6}) == [(1, 2), (3, 5), (6, 7)] - assert rk.group_paragraphs(3, set()) == [(1, 3)] - assert rk.group_paragraphs(3, {1, 9}) == [(1, 3)] + assert ak.group_paragraphs(7, {3, 6}) == [(1, 2), (3, 5), (6, 7)] + assert ak.group_paragraphs(3, set()) == [(1, 3)] + assert ak.group_paragraphs(3, {1, 9}) == [(1, 3)] @pytest.mark.parametrize("paragraphs, expected", [ @@ -221,52 +221,52 @@ def test_group_paragraphs(): ([(1, 10), (11, 11)], [(1, 10), (11, 11)]), # no room anywhere ]) def test_merge_short_paragraphs(paragraphs, expected): - assert rk.merge_short_paragraphs(paragraphs) == expected + assert ak.merge_short_paragraphs(paragraphs) == expected def paragraph_lengths(text): - return [len(rk.split_sentences(p)) for p in text.split("\n\n")] + return [len(ak.split_sentences(p)) for p in text.split("\n\n")] def test_build_paragraphs_keeps_text_and_limits(tmp_path, monkeypatch): text = flat(SAMPLE.read_text(encoding="utf-8")) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) - result = rk.build_paragraphs(text, tmp_path) + result = ak.build_paragraphs(text, tmp_path) assert flat(result) == text lengths = paragraph_lengths(result) - assert min(lengths) >= rk.MIN_PARAGRAPH_SENTENCES - assert max(lengths) <= rk.MAX_PARAGRAPH_SENTENCES + assert min(lengths) >= ak.MIN_PARAGRAPH_SENTENCES + assert max(lengths) <= ak.MAX_PARAGRAPH_SENTENCES def test_build_paragraphs_splits_long_and_falls_back(tmp_path, monkeypatch, capsys): text = flat(SAMPLE.read_text(encoding="utf-8")) def find(sentences, first, system): - if system == rk.PARAGRAPH_SYSTEM: + if system == ak.PARAGRAPH_SYSTEM: return every_nth_start(25)(sentences, first, system) # far too long if first < 100: return every_nth_start(6)(sentences, first, system) # split works return set() # split fails - monkeypatch.setattr(rk, "find_paragraph_starts", find) - result = rk.build_paragraphs(text, tmp_path) + monkeypatch.setattr(ak, "find_paragraph_starts", find) + result = ak.build_paragraphs(text, tmp_path) out = capsys.readouterr().out assert "Teile langen Absatz" in out assert "gleichmäßig" in out assert flat(result) == text - assert max(paragraph_lengths(result)) <= rk.MAX_PARAGRAPH_SENTENCES + assert max(paragraph_lengths(result)) <= ak.MAX_PARAGRAPH_SENTENCES def test_build_paragraphs_uses_cache(tmp_path, monkeypatch): text = flat(SAMPLE.read_text(encoding="utf-8")) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) - first = rk.build_paragraphs(text, tmp_path) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) + first = ak.build_paragraphs(text, tmp_path) - monkeypatch.setattr(rk, "find_paragraph_starts", no_model_calls) - assert rk.build_paragraphs(text, tmp_path) == first + monkeypatch.setattr(ak, "find_paragraph_starts", no_model_calls) + assert ak.build_paragraphs(text, tmp_path) == first # === Command line and end-to-end === @@ -279,8 +279,8 @@ 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)) + monkeypatch.setattr(ak, "correct_chunk", identity_correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) run_main(monkeypatch, "-i", sample_copy, "-g", SHARED_GLOSSARY, "-g", GLOSSARY) target = sample_copy.with_name("ramstein.de.korrigiert.txt") @@ -291,15 +291,15 @@ def test_main_default_output_resume_and_glossary(sample_copy, monkeypatch, capsy assert "WARNUNG" not in capsys.readouterr().out # Second run: everything cached, no model calls - monkeypatch.setattr(rk, "correct_chunk", no_model_calls) - monkeypatch.setattr(rk, "find_paragraph_starts", no_model_calls) + monkeypatch.setattr(ak, "correct_chunk", no_model_calls) + monkeypatch.setattr(ak, "find_paragraph_starts", no_model_calls) run_main(monkeypatch, "-i", sample_copy, "-g", SHARED_GLOSSARY, "-g", GLOSSARY) assert target.read_text(encoding="utf-8") == result def test_main_output_option_creates_directories(sample_copy, monkeypatch): - monkeypatch.setattr(rk, "correct_chunk", identity_correct) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + monkeypatch.setattr(ak, "correct_chunk", identity_correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) target = sample_copy.parent / "neu" / "unter" / "rede.txt" run_main(monkeypatch, "-i", sample_copy, "-o", target) assert target.is_file() @@ -307,8 +307,8 @@ def test_main_output_option_creates_directories(sample_copy, monkeypatch): def test_main_vtt_input_matches_txt_input(sample_copy, monkeypatch): - monkeypatch.setattr(rk, "correct_chunk", identity_correct) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + monkeypatch.setattr(ak, "correct_chunk", identity_correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) vtt = sample_copy.parent / "vtt" / "ramstein.de.vtt" vtt.parent.mkdir() shutil.copy(SAMPLE.with_suffix(".vtt"), vtt) @@ -325,8 +325,8 @@ def test_main_writes_change_report(sample_copy, monkeypatch, capsys): def correct(chunk, index, system): return chunk.strip().replace("Rammstein", "Ramstein") - monkeypatch.setattr(rk, "correct_chunk", correct) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + monkeypatch.setattr(ak, "correct_chunk", correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) run_main(monkeypatch, "-i", sample_copy) report = sample_copy.with_name("ramstein.de.aenderungen.html") @@ -337,22 +337,22 @@ def test_main_writes_change_report(sample_copy, monkeypatch, capsys): def test_main_without_change_report(sample_copy, monkeypatch): - monkeypatch.setattr(rk, "correct_chunk", identity_correct) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + monkeypatch.setattr(ak, "correct_chunk", identity_correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) run_main(monkeypatch, "-i", sample_copy, "--kein-bericht") assert not sample_copy.with_name("ramstein.de.aenderungen.html").exists() def test_main_model_option(sample_copy, monkeypatch): - default = rk.MODEL + default = ak.MODEL used = [] def correct(chunk, index, system): - used.append(rk.MODEL) + used.append(ak.MODEL) return chunk.strip() - monkeypatch.setattr(rk, "correct_chunk", correct) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + monkeypatch.setattr(ak, "correct_chunk", correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) parts = sample_copy.parent / "ramstein.de.korrigiert.parts" run_main(monkeypatch, "-i", sample_copy) @@ -371,9 +371,9 @@ def test_correct_chunk_sends_model(monkeypatch): sent.update(json.loads(request.data)) return FakeResponse(stream_body(["Text."])) - monkeypatch.setattr(rk, "MODEL", "anderes:7b") - monkeypatch.setattr(rk.urllib.request, "urlopen", urlopen) - rk.correct_chunk("Text.", 1, "system") + monkeypatch.setattr(ak, "MODEL", "anderes:7b") + monkeypatch.setattr(ak.urllib.request, "urlopen", urlopen) + ak.correct_chunk("Text.", 1, "system") assert sent["model"] == "anderes:7b" @@ -392,8 +392,8 @@ VTT = ( def from_url(fake_yt_dlp, yt_dlp_installed, monkeypatch): """Fake download plus fake model for runs with -u.""" monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=VTT)) - monkeypatch.setattr(rk, "correct_chunk", identity_correct) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + monkeypatch.setattr(ak, "correct_chunk", identity_correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) def test_main_url_default_names(from_url, tmp_path, monkeypatch): @@ -432,30 +432,30 @@ def recording_model(monkeypatch): systems.append(system) return set() - monkeypatch.setattr(rk, "correct_chunk", correct) - monkeypatch.setattr(rk, "find_paragraph_starts", find) + monkeypatch.setattr(ak, "correct_chunk", correct) + monkeypatch.setattr(ak, "find_paragraph_starts", find) return systems def test_build_system_english(): - system = rk.build_system(["Mearsheimer"], "en") - assert system.startswith(rk.SYSTEM_EN) + system = ak.build_system(["Mearsheimer"], "en") + assert system.startswith(ak.SYSTEM_EN) assert "Glossary:" in system and "- Mearsheimer" in system def test_main_english_prompts(sample_copy, monkeypatch, capsys): systems = recording_model(monkeypatch) run_main(monkeypatch, "-i", sample_copy, "-l", "en-US") - assert systems[0] == rk.SYSTEM_EN - assert rk.PARAGRAPH_SYSTEM_EN in systems - assert "Sprache der Rede: en" in capsys.readouterr().out + assert systems[0] == ak.SYSTEM_EN + assert ak.PARAGRAPH_SYSTEM_EN in systems + assert "Sprache des Beitrags: en" in capsys.readouterr().out def test_main_default_language_is_german(sample_copy, monkeypatch): systems = recording_model(monkeypatch) run_main(monkeypatch, "-i", sample_copy) - assert systems[0] == rk.SYSTEM - assert rk.PARAGRAPH_SYSTEM in systems + assert systems[0] == ak.SYSTEM + assert ak.PARAGRAPH_SYSTEM in systems @pytest.mark.parametrize("language, expected", [("en-US", "SYSTEM_EN"), ("de", "SYSTEM")]) @@ -466,7 +466,7 @@ def test_main_url_language_from_video( monkeypatch.setattr(subprocess, "run", fake_yt_dlp(info, vtt=VTT)) systems = recording_model(monkeypatch) run_main(monkeypatch, "-u", URL, "-o", tmp_path / "rede.korrigiert.txt") - assert systems[0] == getattr(rk, expected) + assert systems[0] == getattr(ak, expected) def test_main_url_unsupported_language(fake_yt_dlp, yt_dlp_installed, tmp_path, monkeypatch): @@ -478,8 +478,8 @@ def test_main_url_unsupported_language(fake_yt_dlp, yt_dlp_installed, tmp_path, def test_raw_text_path(): - assert rk.raw_text_path(Path("a/x.korrigiert.txt")) == Path("a/x.txt") - assert rk.raw_text_path(Path("a/x.txt")) == Path("a/x.roh.txt") + assert ak.raw_text_path(Path("a/x.korrigiert.txt")) == Path("a/x.txt") + assert ak.raw_text_path(Path("a/x.txt")) == Path("a/x.roh.txt") def test_main_warns_on_word_count_deviation(sample_copy, monkeypatch, capsys): @@ -487,8 +487,8 @@ def test_main_warns_on_word_count_deviation(sample_copy, monkeypatch, capsys): words = chunk.split() return " ".join(words[: int(len(words) * 0.9)]) if index == 2 else chunk.strip() - monkeypatch.setattr(rk, "correct_chunk", shortening) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + monkeypatch.setattr(ak, "correct_chunk", shortening) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) run_main(monkeypatch, "-i", sample_copy) out = capsys.readouterr().out assert "WARNUNG: Abschnitt 2:" in out @@ -547,8 +547,8 @@ def test_main_url_time_range_without_text(from_url, tmp_path, monkeypatch): def test_main_vtt_time_range(tmp_path, monkeypatch): vtt = tmp_path / "rede.de.vtt" vtt.write_text(VTT, encoding="utf-8") - monkeypatch.setattr(rk, "correct_chunk", identity_correct) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + monkeypatch.setattr(ak, "correct_chunk", identity_correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) run_main(monkeypatch, "-i", vtt, "-z", "0-0:00:02") result = tmp_path / "rede.de_bis_00-00-02.korrigiert.txt" @@ -560,7 +560,7 @@ def test_main_vtt_time_range(tmp_path, monkeypatch): def test_main_vtt_time_range_without_text(tmp_path, monkeypatch): vtt = tmp_path / "rede.de.vtt" vtt.write_text(VTT, encoding="utf-8") - monkeypatch.setattr(rk, "correct_chunk", no_model_calls) + monkeypatch.setattr(ak, "correct_chunk", no_model_calls) with pytest.raises(SystemExit, match="im gewählten Zeitraum keinen Text"): run_main(monkeypatch, "-i", vtt, "-z", "1:00") @@ -589,55 +589,55 @@ def tags_urlopen(*names): @pytest.mark.real_ollama_check @pytest.mark.parametrize("model", ["qwen3:32b", "llama3"]) def test_check_ollama_model_installed(monkeypatch, model): - monkeypatch.setattr(rk.urllib.request, "urlopen", tags_urlopen("qwen3:32b", "llama3:latest")) - rk.check_ollama(model) + monkeypatch.setattr(ak.urllib.request, "urlopen", tags_urlopen("qwen3:32b", "llama3:latest")) + ak.check_ollama(model) @pytest.mark.real_ollama_check def test_check_ollama_model_missing(monkeypatch): - monkeypatch.setattr(rk.urllib.request, "urlopen", tags_urlopen("qwen3:8b")) + monkeypatch.setattr(ak.urllib.request, "urlopen", tags_urlopen("qwen3:8b")) with pytest.raises(SystemExit, match="ollama pull qwen3:32b"): - rk.check_ollama("qwen3:32b") + ak.check_ollama("qwen3:32b") def refused(request, timeout=None): - raise rk.urllib.error.URLError(ConnectionRefusedError(111, "Connection refused")) + raise ak.urllib.error.URLError(ConnectionRefusedError(111, "Connection refused")) @pytest.mark.real_ollama_check def test_main_ollama_down_stops_before_download(fake_yt_dlp, yt_dlp_installed, monkeypatch): run = fake_yt_dlp(VIDEO, vtt=VTT) monkeypatch.setattr(subprocess, "run", run) - monkeypatch.setattr(rk.urllib.request, "urlopen", refused) + monkeypatch.setattr(ak.urllib.request, "urlopen", refused) with pytest.raises(SystemExit, match="Ollama nicht erreichbar.*systemctl start ollama"): run_main(monkeypatch, "-u", URL) assert run.calls == [] def test_correct_chunk_ollama_gone_midway(monkeypatch): - monkeypatch.setattr(rk.urllib.request, "urlopen", refused) + monkeypatch.setattr(ak.urllib.request, "urlopen", refused) with pytest.raises(SystemExit, match="Ollama nicht erreichbar"): - rk.correct_chunk("Text.", 1, "system") + ak.correct_chunk("Text.", 1, "system") def test_ollama_open_passes_http_errors(monkeypatch): def not_found(request, timeout=None): - raise rk.urllib.error.HTTPError(rk.OLLAMA_URL, 404, "Not Found", {}, None) - monkeypatch.setattr(rk.urllib.request, "urlopen", not_found) - with pytest.raises(rk.urllib.error.HTTPError): - rk.ollama_open(rk.OLLAMA_URL) + raise ak.urllib.error.HTTPError(ak.OLLAMA_URL, 404, "Not Found", {}, None) + monkeypatch.setattr(ak.urllib.request, "urlopen", not_found) + with pytest.raises(ak.urllib.error.HTTPError): + ak.ollama_open(ak.OLLAMA_URL) # === Excerpts: sentence bounds and prompt note === @pytest.mark.parametrize("lang", ["de", "en"]) def test_build_system_excerpt_note(lang): - prompts = rk.PROMPTS[lang] - assert prompts["excerpt"] not in rk.build_system(["X"], lang) - system = rk.build_system(["X"], lang, excerpt=True) + prompts = ak.PROMPTS[lang] + assert prompts["excerpt"] not in ak.build_system(["X"], lang) + system = ak.build_system(["X"], lang, excerpt=True) assert system.startswith(prompts["system"]) assert system.index(prompts["excerpt"]) < system.index(prompts["glossary"]) - assert rk.build_system([], lang, excerpt=True).endswith(prompts["excerpt"] + "\n") + assert ak.build_system([], lang, excerpt=True).endswith(prompts["excerpt"] + "\n") CUT_VTT = ( @@ -658,7 +658,7 @@ def test_main_url_excerpt(fake_yt_dlp, yt_dlp_installed, tmp_path, monkeypatch, run_main(monkeypatch, "-u", URL, "-z", "0:00:02-0:00:03", "-o", target, *exact) assert (tmp_path / "rede.txt").read_text(encoding="utf-8") == raw - assert rk.PROMPTS["de"]["excerpt"] in systems[0] + assert ak.PROMPTS["de"]["excerpt"] in systems[0] # === Several time ranges (-z): blocks with headers === @@ -678,8 +678,8 @@ def chunk_recorder(monkeypatch): sent.append((chunk, system)) return chunk.strip() - monkeypatch.setattr(rk, "correct_chunk", correct) - monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + monkeypatch.setattr(ak, "correct_chunk", correct) + monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5)) return sent @@ -700,7 +700,7 @@ def test_main_url_several_ranges(fake_yt_dlp, yt_dlp_installed, tmp_path, monkey ) # One chunk per block, never a header; excerpt note in the prompt assert [chunk for chunk, _ in sent] == ["Der Kreml ist in Moskau.", "Das ist bekannt."] - assert all(rk.PROMPTS["de"]["excerpt"] in system for _, system in sent) + assert all(ak.PROMPTS["de"]["excerpt"] in system for _, system in sent) out = capsys.readouterr().out assert "Abschnitt 2/2" in out # Headers are no change; only the glossary replacement is @@ -725,4 +725,4 @@ def test_main_txt_with_headers_corrected_in_blocks(tmp_path, monkeypatch): def test_main_without_headers_has_no_excerpt_note(sample_copy, monkeypatch): sent = chunk_recorder(monkeypatch) run_main(monkeypatch, "-i", sample_copy) - assert rk.PROMPTS["de"]["excerpt"] not in sent[0][1] + assert ak.PROMPTS["de"]["excerpt"] not in sent[0][1] diff --git a/tests/test_glossar_vorschlag.py b/tests/test_glossar_vorschlag.py index 527c6de..ac86ec2 100644 --- a/tests/test_glossar_vorschlag.py +++ b/tests/test_glossar_vorschlag.py @@ -7,7 +7,7 @@ import urllib.request import pytest import glossar_vorschlag as gv -import rede_korrigieren as rk +import abschrift_korrigieren as ak RAW = ( "Der Kremmel und Egon Bar trafen Host Telchik. Die Rechtsstaatbahn war gut. " @@ -114,12 +114,12 @@ def test_drafted_entries_are_valid_glossary(raw_file, monkeypatch, tmp_path): monkeypatch.setattr(urllib.request, "urlopen", fake_model(ENTRIES[:2])) out = tmp_path / "entwurf.txt" run_main(monkeypatch, "-i", raw_file, "-o", out) - assert rk.read_glossary(out) == ([], []) # nothing active + assert ak.read_glossary(out) == ([], []) # nothing active # Removing "# ? " turns the lines into glossary entries accepted = tmp_path / "glossar.txt" accepted.write_text(out.read_text(encoding="utf-8").replace("# ? ", ""), encoding="utf-8") - _, replacements = rk.read_glossary(accepted) + _, replacements = ak.read_glossary(accepted) assert replacements == [("Grotzki", "Grotzky"), ("Ghard Kocher", "Gerhard Kocher")] diff --git a/vtt2txt.py b/vtt2txt.py index c10a7b9..80287a1 100755 --- a/vtt2txt.py +++ b/vtt2txt.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """Wandelt WebVTT-Untertitel (z. B. YouTube-Auto-Untertitel) in eine Rohabschrift um, -die anschließend mit rede_korrigieren.py bearbeitet werden kann.""" +die anschließend mit abschrift_korrigieren.py bearbeitet werden kann.""" from pathlib import Path import argparse import glob @@ -407,7 +407,7 @@ def expand_inputs(patterns): def parse_args(): parser = argparse.ArgumentParser( - description="Wandelt VTT-Untertitel in eine Textdatei für rede_korrigieren.py um." + description="Wandelt VTT-Untertitel in eine Textdatei für abschrift_korrigieren.py um." ) parser.add_argument( "-i", "--input", required=True, nargs="+", diff --git a/yt_untertitel.py b/yt_untertitel.py index 9a90b32..62b99bd 100755 --- a/yt_untertitel.py +++ b/yt_untertitel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """Lädt die Untertitel eines YouTube-Videos als VTT-Datei herunter (über yt-dlp), -als Eingabe für vtt2txt.py bzw. rede_korrigieren.py.""" +als Eingabe für vtt2txt.py bzw. abschrift_korrigieren.py.""" from pathlib import Path import argparse import json