Compare commits

...
Author SHA1 Message Date
33869e5f3e chore: Glossare echter Beiträge nur noch lokal
*.glossar.txt enthält Namen realer Personen und Hörfehler realer
Beiträge und steht jetzt in .gitignore; versioniert bleibt nur das
Glossar der erfundenen Beispielrede (tests/daten/beispiel.glossar.txt).
Die Tests der echten Glossare laufen nur, wenn Reden und Glossare lokal
vorhanden sind; die URL-Tests nutzen ein eigenes Mini-Glossar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:44:46 +02:00
3be1ff8058 docs: README und Bedienungsanleitung auf den Stand nach der Testdaten-Umstellung
- Beispiele schreiben nicht mehr nach testdaten/ (jetzt nur für lokale
  echte Reden), das glossar_vorschlag-Beispiel läuft auch im frischen Klon.
- Bedienungsanleitung: Ausprobieren mit der Beispielrede (gemessen: 2:32
  Minuten mit qwen3:32b), Beispielglossar, Namensregel des Prompts.
- README: Namensregel im Ablauf, Testbeschreibung mit Beispielrede,
  übersprungenen Rede-Tests, Handarbeitsschutz, Wortformen, Bereichshinweis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:41:26 +02:00
93fb99eda2 test: eigene Beispielrede statt echter Reden, testdaten/ nur noch lokal
Die Abschriften echter Reden sind urheberrechtlich geschützt und gehören
nicht in ein öffentliches Repository. Die Tests nutzen jetzt eine frei
erfundene Festrede (tests/daten/beispiel.de.vtt/.txt, YouTube-Format mit
rollendem Fenster) mit eigenem Glossar. testdaten/ steht in .gitignore;
liegen die Reden dort lokal, laufen die Tests ihrer Glossare zusätzlich,
sonst werden sie übersprungen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:14:49 +02:00
0106c307e7 docs: Testanzahl in der README aktualisiert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:01:25 +02:00
cd62feab69 feat: „Zu prüfen“ zeigt bloße Wortformen getrennt am Ende
Ersetzt das Modell ein gültiges Wort nur durch eine andere Form
(„Punkten“ → „Punkte“, „das“ → „dass“, „sehe“ → „sähe“), heißt der Grund
jetzt „Wortform geändert“. Diese Fälle stehen im Bericht in einer eigenen
Tabelle nach den sinnverändernden, im Text gestrichelt umrahmt, und in
der Konsole am Ende. „-in“ zählt nicht als Endung: „Professor“ →
„Professorin“ bleibt oben.

Außerdem: Die Tabellen scrollen auf schmalen Bildschirmen waagrecht, und
aenderungen.py meldet Zeit-Überschriften nicht als Einfügung, wenn der
Rohtext (z. B. eine .vtt) keine hat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:01:18 +02:00
011d201215 fix: Namensregel in die Hauptanweisung, damit sie wirkt
Der Hinweis im Glossar-Teil allein half nicht: Im Lahnstein-Abschnitt
schrieb qwen3:32b weiter „Jamal Khashoggi“ und „Alexander Dobrindt“, wo
nur der Nachname gesagt wurde. Als Regel in der Hauptliste (de/en)
ergänzt das Modell keine Namensteile mehr (nachgeprüft mit Ollama).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:00:45 +02:00
d9512bcedc feat: Hinweis, wenn ein Zeitbereich über das Ende der Untertitel reicht
„-z 0-05:00“ bedeutet fünf Stunden und bearbeitete bisher stillschweigend
das ganze Video. vtt2txt.py (und damit extract_text.py und
abschrift_korrigieren.py) nennt jetzt das Ende der Untertitel, bei
Bereichen ab einer Stunde mit dem Tipp „Minute 5 ist 00:05“.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:58:12 +02:00
37e4a2bca0 feat: von Hand nachbearbeitete Ausgabe wird nicht mehr überschrieben
abschrift_korrigieren.py merkt sich die Prüfsumme der geschriebenen
Ausgabe in <Ausgabe>.parts/ausgabe.sha256. Weicht die vorhandene Datei
davon und vom neuen Ergebnis ab, bleibt sie erhalten; das Ergebnis kommt
nach <Name>.neu.korrigiert.txt (mit eigenem Bericht) und es gibt eine
Warnung. --ueberschreiben ersetzt die Datei trotzdem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:57:12 +02:00
ed721b1773 feat: Modell ergänzt keine Vornamen oder Titel aus dem Glossar
Steht „Alexander Dobrindt“ im Glossar, machte das Modell aus „Dobrind“
den vollen Namen. Der Glossar-Teil des Prompts (de/en) verlangt jetzt,
nur die Schreibweise des Gesagten zu korrigieren. Läufe mit Glossar
werden dadurch einmal neu berechnet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:55:17 +02:00
45bf2da07b fix: „Zu prüfen“ übersah Streichungen von „er“/„um“ und Glossar-Allerweltswörter
- Füllwörter je Sprache: „er“ und „um“ sind nur im Englischen Füllwörter;
  im Deutschen wurde ihre Streichung bisher nie gemeldet.
- Eine Änderung gilt nur noch als vom Glossar erklärt, wenn die neuen
  Wörter zusammenhängend in einem Eintrag stehen und dem alten Text
  ähneln. Bisher machte jedes Wort eines Eintrags („Sie“ aus „finden Sie“)
  Änderungen unsichtbar, auch eingefügte Vornamen.
- aenderungen.py -l en-US findet die englische Wortliste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:53:17 +02:00
12 changed files with 3663 additions and 154 deletions

8
.gitignore vendored
View file

@ -12,3 +12,11 @@ __pycache__/
# Eigene Ergebnisse und Testläufe
output/
# Echte Reden (Urheberrecht): nur lokal; die Tests nutzen tests/daten/
testdaten/
# Glossare echter Beiträge (Namen, Hörfehler): nur lokal; ins Repository gehört
# nur das Glossar der erfundenen Beispielrede
*.glossar.txt
!tests/daten/beispiel.glossar.txt

View file

@ -149,6 +149,16 @@ Mit eigenem Dateinamen:
# → lahnstein.korrigiert.txt, lahnstein.txt (Rohtext), lahnstein.aenderungen.html
```
**Ausprobieren ohne YouTube:** Im Ordner `tests/daten/` liegt eine frei erfundene
Beispielrede (Festrede zum Jubiläum der Gemeinde „Auental“) mit typischen Hörfehlern
und einem passenden Glossar. Ein Lauf dauert etwa zwei bis drei Minuten und zeigt alle
Ergebnisse, auch den Änderungsbericht:
```bash
./abschrift_korrigieren.py -i tests/daten/beispiel.de.txt -g tests/daten/beispiel.glossar.txt -o beispiel.korrigiert.txt
# → beispiel.korrigiert.txt, beispiel.aenderungen.html
```
---
## 4. Der Arbeitsablauf im Überblick
@ -213,10 +223,10 @@ versteht `abschrift_korrigieren.py`.
Soll die Original-Untertiteldatei (`.vtt`, mit Zeitstempeln) erhalten bleiben:
```bash
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten
# → testdaten/Lahnstein.de-orig.vtt
./vtt2txt.py -i testdaten/Lahnstein.de-orig.vtt
# → testdaten/Lahnstein.de-orig.txt
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d untertitel
# → untertitel/Lahnstein.de-orig.vtt
./vtt2txt.py -i untertitel/Lahnstein.de-orig.vtt
# → untertitel/Lahnstein.de-orig.txt
```
Achtung: `yt_untertitel.py` nimmt ohne `-l` Deutsch (`de`), nicht die
@ -293,9 +303,13 @@ Namen und Hörfehler) plus eines **je Beitrag**:
```
Das gemeinsame Glossar zuerst angeben, damit seine Ersetzungen zuerst greifen.
Im Programmordner liegen Beispiele: `krone-schmalz.glossar.txt` (gemeinsam),
`ramstein.glossar.txt`, `magdeburg.glossar.txt`, `lahnstein.glossar.txt`
(je eine Rede) und `iamfabian.glossar.txt` (Kanal über KI-Coding-Tools).
Glossare enthalten Namen realer Personen und Hörfehler realer Beiträge. Sie werden
deshalb nicht ins Git-Repository übernommen: Jede Datei `*.glossar.txt` ist über
`.gitignore` ausgeschlossen und bleibt nur auf dem eigenen Rechner. Das ist
gewollt; Glossare lieber gesondert sichern. Einzige Ausnahme ist das kleine,
leicht nachvollziehbare Glossar zur erfundenen Beispielrede in
`tests/daten/beispiel.glossar.txt`, das sich gut als Vorlage eignet.
### 6.3 Entwurf vom Modell: `glossar_vorschlag.py`
@ -365,7 +379,9 @@ Statt `-i rede.txt` geht auch `-i rede.de.vtt` (Untertiteldatei) oder
4. **Korrektur:** Der Text wird in Abschnitte von etwa 1200 Wörtern geteilt, immer
am Satzende, und Abschnitt für Abschnitt korrigiert. Abkürzungen wie „z. B.“,
„Dr.“, „bzw.“ und Ordnungszahlen wie „2. Weltkrieg“ gelten dabei nicht als
Satzende.
Satzende. Das Modell ist angewiesen, nichts zu ergänzen, auch keine Vornamen oder
Titel aus dem Glossar: Steht dort „Alexander Dobrindt“, bleibt ein gesagtes
„Dobrindt“ trotzdem „Dobrindt“.
5. **Wortzahl-Prüfung:** Weicht die Wortzahl eines Abschnitts um mehr als 5 % ab,
warnt das Programm (das Modell könnte etwas ausgelassen oder ergänzt haben).
6. **Absätze:** Das Modell nennt die Sätze, mit denen ein neuer Absatz beginnt; der
@ -399,10 +415,11 @@ Fertig: rede.korrigiert.txt
Änderungen gegenüber dem Rohtext: 82
5× „Rammstein“ → „Ramstein“
…
Zu prüfen: 19 Stellen ← diese Stellen ansehen!
Zu prüfen: 20 Stellen (davon 2 Wortformen) ← diese Stellen ansehen!
gültiges Wort ersetzt: „in“ → „dass“
stark verändert: „OSola“ → „Ursula“
…
Wortform geändert: „sehe“ → „sähe“ ← Wortformen am Ende
Bericht: rede.aenderungen.html
19 Stelle(n) zu prüfen, siehe rede.aenderungen.html
@ -444,7 +461,8 @@ xdg-open rede.aenderungen.html
1. **Kopfzeile:** Wortzahlen, Anzahl der Ersetzungen, Einfügungen, Streichungen,
Prüfmarken und Stellen „zu prüfen“.
2. **Zu prüfen:** Tabelle der verdächtigen Änderungen mit Grund, Text vorher,
korrigiertem Text und Zusammenhang. **Hier beginnen.**
korrigiertem Text und Zusammenhang. **Hier beginnen.** Darunter in einer eigenen
Tabelle die bloßen Wortformen („Wortform geändert“).
3. **Alle Änderungen nach Häufigkeit:** jede Wortänderung mit Anzahl, z. B.
`5 Rammstein → Ramstein`. Gut, um wiederkehrende Hörfehler fürs Glossar zu
finden.
@ -457,6 +475,7 @@ xdg-open rede.aenderungen.html
| rot durchgestrichen | gestrichenes Wort |
| orange `[prüfen: …]` | Prüfmarke des Modells |
| rot umrahmt | Stelle „zu prüfen“; beim Darüberfahren erscheint der Grund |
| rot gestrichelt umrahmt | geänderte Wortform (meist Grammatik) |
| rot umrahmtes ⚠ | hier hat das Modell eigenmächtig etwas gestrichen |
Groß-/Kleinschreibung, Satzzeichen und Schreibweisen wie „z.B.“ → „z. B.“ werden
@ -470,19 +489,23 @@ Sinn verändern können. Was die Glossare ersetzt haben, erscheint hier nie.
| Grund | Bedeutung | Beispiel |
|---|---|---|
| gültiges Wort ersetzt | Das alte Wort ist ein echtes Wort (steht in der Wortliste), also vermutlich kein Hörfehler. | „Professor“ → „Professorin“, „unterschreiben“ → „unterscheiden“ |
| Wortform geändert | Ein echtes Wort hat nur eine andere Endung oder einen Umlaut bekommen. Meist eine richtige Grammatik-Korrektur; steht in einer eigenen Gruppe am Ende. | „Punkten“ → „Punkte“, „das“ → „dass“, „sehe“ → „sähe“ |
| stark verändert | Das neue Wort ähnelt dem alten kaum (unter 60 %). | „CT1“ → „ZDF“ |
| eingefügt | Das Modell hat ein Wort ergänzt. | „∅“ → „ist“ |
| gestrichen | Das Modell hat ein Wort weggelassen (Füllwörter wie „äh“ zählen nicht). | „Sicherungskonzepte geht“ → „∅“ |
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:**
1. Für jede Zeile den Zusammenhang lesen.
2. Ist die Änderung richtig (oft bei Grammatik: „das“ → „dass“, „sehe“ → „sähe“),
nichts tun.
2. Ist die Änderung richtig, nichts tun. Bei den Wortformen am Ende ist das der
Normalfall; dort genügt ein kurzer Blick.
3. Ist sie falsch, im korrigierten Text zurückändern. Tritt der Fehler öfter auf
oder geht es um einen Namen, besser das Glossar ergänzen (nächster Schritt).
4. Bei „gestrichen“ prüfen, ob wirklich etwas fehlt: Oft entfernt das Modell
@ -521,9 +544,22 @@ neu, was sich geändert hat:
- Die Absatzgliederung wird für geänderte Textstellen ebenfalls neu bestimmt; das
geht schnell.
Von Hand nachbearbeitete `*.korrigiert.txt` werden bei einem erneuten Lauf
**überschrieben**. Handarbeit deshalb erst ganz am Ende machen oder die Datei vorher
unter anderem Namen sichern.
**Handarbeit bleibt erhalten.** Wurde `rede.korrigiert.txt` seit dem letzten Lauf
von Hand geändert, überschreibt ein erneuter Lauf sie nicht. Das neue Ergebnis kommt
dann nach `rede.neu.korrigiert.txt` (Bericht: `rede.neu.aenderungen.html`), mit
einer Warnung:
```text
WARNUNG: rede.korrigiert.txt wurde seit dem letzten Lauf geändert (von Hand?) und bleibt erhalten. Neues Ergebnis: rede.neu.korrigiert.txt (mit --ueberschreiben wird die Datei ersetzt)
```
Die eigenen Änderungen lassen sich dann mit einem Vergleichsprogramm übertragen,
z. B. `meld rede.korrigiert.txt rede.neu.korrigiert.txt`. Soll die Handarbeit
verworfen werden: `--ueberschreiben` angeben.
Das Programm erkennt Handarbeit an einer Prüfsumme in `rede.korrigiert.parts/`.
Fehlt sie (erster Lauf mit dieser Version, Ordner gelöscht), gilt jede abweichende
vorhandene Datei als von Hand geändert.
---
@ -543,6 +579,8 @@ Option kennen `extract_text.py` und `vtt2txt.py`.
> **Vorsicht:** `05:00` bedeutet 5 Stunden, nicht 5 Minuten. Für „5 Minuten“
> `00:05` oder `5` schreiben, für 5 Minuten 30 Sekunden `00:05:30`.
> Reicht ein Bereich über das Ende des Videos hinaus, weist das Programm darauf hin
> und nennt die Länge der Untertitel.
### 10.2 Bereiche
@ -615,7 +653,7 @@ Beispiel für `-i rede.txt` bzw. `-o rede.korrigiert.txt`:
| Datei | Inhalt | Wird überschrieben? |
|---|---|---|
| `rede.txt` | Rohtext (bei `-u` automatisch gespeichert) | nie, wenn sie schon mit anderem Inhalt existiert (dann Warnung) |
| `rede.korrigiert.txt` | korrigierte Abschrift | ja, bei jedem Lauf |
| `rede.korrigiert.txt` | korrigierte Abschrift | ja, außer sie wurde von Hand geändert (dann `rede.neu.korrigiert.txt`, siehe [Abschnitt 9](#9-schritt-5-nachbessern-und-erneut-laufen-lassen)) |
| `rede.aenderungen.html` | Änderungsbericht | ja, bei jedem Lauf |
| `rede.korrigiert.parts/` | Zwischenstände | wird ergänzt; darf jederzeit gelöscht werden |
| `rede.glossar.entwurf.txt` | Glossar-Entwurf von `glossar_vorschlag.py` | ja |
@ -690,12 +728,14 @@ Weitere Einstellungen (Abschnittsgröße, Absatzlängen, Schwellen) sind in der
| `Die Eingabedatei ist leer.` | Datei prüfen. |
| `Ausgabedatei darf nicht die Eingabedatei sein.` | Mit `-o` einen anderen Namen wählen. |
| `Glossar nicht gefunden: …` | Pfad bei `-g` prüfen. |
| `WARNUNG: rede.korrigiert.txt wurde seit dem letzten Lauf geändert (von Hand?) …` | Die Datei wurde nachbearbeitet und bleibt erhalten; das neue Ergebnis steht in `rede.neu.korrigiert.txt`. Änderungen übertragen oder mit `--ueberschreiben` ersetzen, siehe [Abschnitt 9](#9-schritt-5-nachbessern-und-erneut-laufen-lassen). |
| `WARNUNG: rede.txt existiert mit anderem Inhalt und bleibt unverändert.` | Bei `-u` wird ein vorhandener, z. B. von Hand bearbeiteter Rohtext nie überschrieben. Korrigiert wird trotzdem der frisch geladene Text. Wer den neuen Rohtext braucht, die alte Datei umbenennen. |
| `WARNUNG: Abschnitt 3: 1180 → 1090 Wörter (8% Abweichung) …` | Das Modell hat möglicherweise etwas ausgelassen oder ergänzt. Abschnitt im Bericht prüfen. Bei Bedarf die genannte Datei in `.parts/` löschen und erneut laufen lassen. |
| `Abschnitt 3 wurde abgeschnitten. CHUNK_SIZE verkleinern und erneut starten.` | Die Antwort des Modells war zu lang. `CHUNK_SIZE` oben in `abschrift_korrigieren.py` verkleinern (z. B. 900). |
| `Absatzgliederung: unbrauchbare Antwort: …` / `Absätze verbinden: unbrauchbare Antwort: …` | Das Modell hat kein gültiges JSON geliefert; selten. Erneut starten, die Korrektur selbst ist zwischengespeichert. Tritt es wiederholt auf, ein anderes Modell versuchen. |
| `Hinweis: keine Wortliste für „de“ (Paket wngerman); …` | `sudo apt install wngerman` (für Englisch `wamerican`). Ohne Wortliste fehlt im Bericht nur der Grund „gültiges Wort ersetzt“. |
| `Hinweis: keine Untertitel im Bereich …` | Einer von mehreren Bereichen ist leer und wird übersprungen. |
| `Hinweis: keine Untertitel im Bereich …` | Einer von mehreren Bereichen ist leer und wird übersprungen. In Klammern steht, wo die Untertitel enden. |
| `Hinweis: Bereich Anfang – 05:00:00 reicht über das Ende der Untertitel hinaus (…)` | Der Bereich endet nach dem Video. Meist war Minute statt Stunde gemeint: `05:00` ist Stunde 5, Minute 5 ist `00:05`. Bearbeitet wird trotzdem, bis zum Ende des Videos. |
| `Keine Berechtigung` beim Start mit `./…` | `chmod +x *.py` oder `python3 programm.py …` aufrufen. |
| Lauf ist extrem langsam | Das Modell passt nicht in den Grafikspeicher. `OLLAMA_NUM_PARALLEL=1` setzen (siehe [Abschnitt 2.2](#22-grafikkarte)), andere Programme mit Grafikspeicherbedarf schließen oder ein kleineres Modell wählen. Prüfen mit `ollama ps` (Spalte PROCESSOR sollte „100% GPU“ zeigen). |
@ -760,6 +800,7 @@ einfügen.
| `-z BEREICH …` | nur diese Zeitbereiche (mit `.vtt` oder `-u`) |
| `--exakt` | genau an den Zeiten schneiden |
| `--kein-bericht` | keinen Änderungsbericht schreiben |
| `--ueberschreiben` | von Hand geänderte Ausgabedatei trotzdem ersetzen |
### `extract_text.py`

128
README.md
View file

@ -58,9 +58,9 @@ Oder in zwei Schritten, um den Rohtext vorher anzusehen:
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
./abschrift_korrigieren.py -i testdaten/Lahnstein.de-orig.txt
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d untertitel
./vtt2txt.py -i untertitel/Lahnstein.de-orig.vtt
./abschrift_korrigieren.py -i untertitel/Lahnstein.de-orig.txt
```
Der Schritt über `vtt2txt.py` ist optional: `abschrift_korrigieren.py` nimmt auch die
@ -76,7 +76,7 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
```bash
./extract_text.py -u 'https://www.youtube.com/watch?v=0LmCveTM788'
# → John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30.txt
./extract_text.py -u URL -l de -o testdaten/rede.txt # Sprache und Ziel
./extract_text.py -u URL -l de -o texte/rede.txt # Sprache und Ziel
./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
@ -131,7 +131,7 @@ Region gefunden: `-l en` findet manuelle Untertitel `en-US`.
```bash
./yt_untertitel.py -u URL # → <Kanal>_<Titel>_<Datum>.de-orig.vtt
./yt_untertitel.py -u URL -n Lahnstein -d testdaten # → testdaten/Lahnstein.de-orig.vtt
./yt_untertitel.py -u URL -n Lahnstein -d untertitel # → untertitel/Lahnstein.de-orig.vtt
```
| Option | Bedeutung |
@ -182,7 +182,9 @@ Gilt gleich für `vtt2txt.py`, `extract_text.py` und `abschrift_korrigieren.py`.
| `25` | Minute 25 |
Achtung: Zwei Zahlen sind Stunden:Minuten, `05:00` ist also Stunde 5, nicht
Minute 5 (dafür `00:05` oder `5`).
Minute 5 (dafür `00:05` oder `5`). Reicht ein Bereich über die letzte
Untertitelzeile hinaus, nennt ein Hinweis das Ende der Untertitel, bei Bereichen ab
einer Stunde mit diesem Tipp.
| Aufruf | Text |
|---|---|
@ -247,6 +249,12 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1.
| `-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`) |
| `--kein-bericht` | keinen Änderungsbericht schreiben |
| `--ueberschreiben` | Ausgabedatei auch ersetzen, wenn sie seit dem letzten Lauf von Hand geändert wurde |
Eine von Hand nachbearbeitete Ausgabedatei überschreibt ein erneuter Lauf nicht: Das
Ergebnis kommt dann nach `<Name>.neu.korrigiert.txt`, mit Warnung. Erkannt wird das
an der Prüfsumme der zuletzt geschriebenen Ausgabe in `<Ausgabe>.parts/ausgabe.sha256`;
ohne Prüfsumme gilt jede abweichende vorhandene Datei als von Hand geändert.
Mit Zeitauswahl wird jeder Bereich als eigener Block korrigiert und gegliedert;
die Überschriften `[00:04:57 - 00:06:04]` bekommt das Modell nicht zu sehen, sie
@ -266,7 +274,9 @@ Blöcken korrigiert.
3. **Korrektur:** Der Text wird in Abschnitte von ca. 1200 Wörtern geteilt (immer
am Satzende; Abkürzungen wie „z. B.“, „Dr.“, „bzw.“ und Ordnungszahlen wie
„2. Weltkrieg“ zählen nicht als Satzende) und abschnittsweise vom Modell
korrigiert. Fortschritt wird live angezeigt.
korrigiert. Fortschritt wird live angezeigt. Der Prompt verbietet Ergänzungen,
ausdrücklich auch Vornamen, Titel oder Namensteile aus dem Glossar, die nicht
gesagt wurden (sonst wurde aus „Dobrindt“ gern „Alexander Dobrindt“).
4. **Wortzahl-Prüfung:** Weicht die Wortzahl eines Abschnitts um mehr als 5 % ab,
gibt es eine Warnung (mögliche Auslassungen oder Ergänzungen).
5. **Absätze:** Das Modell nennt nur Satznummern, an denen ein Absatz beginnt; der
@ -311,15 +321,24 @@ Ersetzungen des Glossars), mit seinem Ergebnis. Gemeldet werden:
| Grund | Beispiel |
|---|---|
| gültiges Wort ersetzt | „Professor“ → „Professorin“, „unterschreiben“ → „unterscheiden“ (das alte Wort steht in der Wortliste, war also kein Hörfehler) |
| Wortform geändert | „Punkten“ → „Punkte“, „das“ → „dass“, „sehe“ → „sähe“: ein gültiges Wort, nur Endung oder Umlaut anders (meist Grammatik); eigene Gruppe am Ende der Liste |
| stark verändert | „CT1“ → „ZDF“ (Ähnlichkeit unter 60 %) |
| eingefügt | „∅“ → „ist“ |
| gestrichen | „Sicherungskonzepte geht“ → „∅“ (Füllwörter wie „äh“ nicht) |
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, sinnverändernde Fälle zuerst, Wortformen danach.
Als Wortform gilt ein einzelnes Wort, das bis auf die Endung gleich bleibt (Stamm
mindestens 3 Buchstaben, beide Endungen aus `INFLECTIONS`, z. B. -e, -n, -s, -en,
-er, -es, -st, -t) oder sich nur im Umlaut unterscheidet. „-in“ gehört bewusst nicht
dazu: „Professor“ → „Professorin“ bleibt in der Hauptliste.
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:
@ -350,11 +369,14 @@ einzelnen Beitrag deutet das auf einen Tippfehler hin, bei einem gemeinsamen Glo
es normal.
Mehrere Glossare lassen sich kombinieren: ein gemeinsames für wiederkehrende Namen
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:
`iamfabian.glossar.txt` für Videos über KI-Coding-Tools (Claude Code, Codex, OpenCode …).
und Hörfehler eines Redners oder Kanals und eines je Beitrag, z. B.
`krone-schmalz.glossar.txt` (gemeinsam) plus `ramstein.glossar.txt`. Das gemeinsame
Glossar zuerst angeben, damit seine Ersetzungen vor den speziellen greifen.
Glossare gehören zu realen Beiträgen und Personen und werden deshalb nicht
versioniert: `*.glossar.txt` steht in `.gitignore`, eigene Glossare liegen nur lokal.
Im Repository ist als Beispiel nur `tests/daten/beispiel.glossar.txt` zur erfundenen
Beispielrede.
### Glossar-Entwurf: `glossar_vorschlag.py`
@ -379,9 +401,9 @@ erneut vorgeschlagen. Zeit-Überschriften von Ausschnitten
Roh- und korrigiertem Text entfernt; das Modell bekommt sie nicht zu sehen.
```bash
./glossar_vorschlag.py -i testdaten/Lahnstein.de-orig.txt \
-k testdaten/Lahnstein.de-orig.korrigiert.txt -g krone-schmalz.glossar.txt
# → testdaten/Lahnstein.de-orig.glossar.entwurf.txt
./glossar_vorschlag.py -i rede.txt -k rede.korrigiert.txt -g krone-schmalz.glossar.txt
# → rede.glossar.entwurf.txt
./glossar_vorschlag.py -i tests/daten/beispiel.de.txt # an der Beispielrede ausprobieren
```
| Option | Bedeutung |
@ -398,24 +420,31 @@ werden in `<Entwurf>.parts/` zwischengespeichert.
## Testdaten
`testdaten/` enthält drei Reden, jeweils als automatische YouTube-Untertitel
(`.vtt`, Original) und daraus mit `vtt2txt.py` gewonnener Rohtext (`.txt`, Eingabe
für das Programm):
**Im Repository:** `tests/daten/` enthält eine frei erfundene Beispielrede
(Festrede zum Jubiläum der Gemeinde „Auental“; Ort und Personen gibt es nicht), als
Untertitel im Format der YouTube-Auto-Untertitel (`beispiel.de.vtt`, rollendes
Fenster, Wort-Zeitstempel), daraus gewonnener Rohtext (`beispiel.de.txt`) und ein
passendes Glossar (`beispiel.glossar.txt`) für die eingebauten Hörfehler wie
„Auen Tal“ oder „Haber Kamp“. Die Tests nutzen nur diese Daten.
```bash
./abschrift_korrigieren.py -i tests/daten/beispiel.de.txt -g tests/daten/beispiel.glossar.txt -o /tmp/beispiel.korrigiert.txt
```
**Nur lokal:** Echte Reden gehören wegen des Urheberrechts nicht ins Repository;
`testdaten/` ist deshalb in `.gitignore`, ebenso die Glossare (`*.glossar.txt`).
Liegen die drei Reden, mit denen das Programm entwickelt wurde, in `testdaten/` und
ihre Glossare im Programmordner, laufen zusätzlich die Tests, die diese Glossare
prüfen; sonst werden sie übersprungen:
- `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 (Glossar
`lahnstein.glossar.txt`, erstellt mit Hilfe von `glossar_vorschlag.py`)
`lahnstein.glossar.txt`, erstellt mit Hilfe von `glossar_vorschlag.py`), zu laden mit
`./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten`
Alle drei mit dem gemeinsamen `krone-schmalz.glossar.txt` davor.
```bash
./vtt2txt.py -i "testdaten/*.vtt" # erzeugt alle .txt neu
./abschrift_korrigieren.py -i testdaten/ramstein.de.txt -g krone-schmalz.glossar.txt -g ramstein.glossar.txt
# → testdaten/ramstein.de.korrigiert.txt (nicht versioniert)
```
Ein Lauf dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa 6 Minuten.
Alle drei mit dem gemeinsamen `krone-schmalz.glossar.txt` davor. Ein Lauf über eine
Rede dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa 6–8 Minuten.
## Tests
@ -423,27 +452,36 @@ Ein Lauf dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa 6 Minuten.
python3 -m pytest
```
Die Tests (291) brauchen weder Ollama noch Netz: Modellaufrufe und `yt-dlp` werden
durch Fakes ersetzt. `tests/test_abschrift_korrigieren.py` prüft Abschnittsbildung,
Satzgrenzen (Abkürzungen und Ordnungszahlen sind kein Satzende), 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), Verbinden kurzer Absätze (Modell wird nur gefragt, wenn
beide Nachbarn passen), Zwischenspeicher/Fortsetzen, Zeitauswahl, Sprachen und die
Die Tests brauchen weder Ollama noch Netz: Modellaufrufe und `yt-dlp` werden durch
Fakes ersetzt. Als Text dient die erfundene Beispielrede in `tests/daten/` (siehe
[Testdaten](#testdaten)). In einem frischen Klon laufen 318 Tests, 7 werden
übersprungen; sie prüfen die Glossare der echten Reden und laufen nur, wenn diese
lokal in `testdaten/` liegen (dann 325).
`tests/test_abschrift_korrigieren.py` prüft Abschnittsbildung, Satzgrenzen
(Abkürzungen und Ordnungszahlen sind kein Satzende), Glossar (jede Ersetzung des
Beispielglossars trifft die Beispielrede; lokal zusätzlich: jede Ersetzung im
Glossar einer Rede trifft diese Rede, jede im gemeinsamen Glossar mindestens eine),
Prompts (keine Namensteile ergänzen), Streaming-Auswertung, Absatzgliederung (Text
bleibt unverändert, 3–10 Sätze), Verbinden kurzer Absätze (Modell wird nur gefragt,
wenn beide Nachbarn passen), Zwischenspeicher/Fortsetzen, Schutz von Handarbeit
(`.neu.korrigiert.txt`, `--ueberschreiben`), Zeitauswahl, Sprachen und die
Kommandozeile.
`tests/test_glossar_vorschlag.py` prüft Filter (nur Funde im Text, keine bekannten),
Ausgabeformat (gültiges Glossar, Unsicheres auskommentiert) und Zwischenspeicher.
`tests/test_aenderungen.py` prüft den Wortvergleich (Satzzeichen, Groß-/Kleinschreibung
und „z. B.“ zählen nicht), die Liste „Zu prüfen“ (Gründe, Ausnahmen, Glossar,
fehlende Wortliste) und den HTML-Bericht (vollständiger Text, Absätze, Escaping).
und „z. B.“ zählen nicht), die Liste „Zu prüfen“ (Gründe, Wortformen, Füllwörter je
Sprache, Glossar-Schreibweisen, fehlende Wortliste) und den HTML-Bericht
(vollständiger Text, Absätze, Escaping, Reihenfolge).
Die Tests nutzen eine kleine eigene Wortliste statt der des Systems.
`tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl
(Original-Audio, manuell vor automatisch) und Fehlerfälle.
`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und
Fehlerfälle (ohne Netz, `yt-dlp` wird durch Fakes ersetzt).
`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: jede `.vtt` in `testdaten/`
ergibt genau die zugehörige `.txt`, echte Wiederholungen bleiben erhalten, auch bei
Untertiteln ohne rollendes Fenster, dazu Zeitauswahl, Muster, Ausgabeverzeichnis und
`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: die Beispiel-`.vtt` (und lokal
jede Rede in `testdaten/`) ergibt genau die zugehörige `.txt`, echte Wiederholungen
bleiben erhalten, auch bei Untertiteln ohne rollendes Fenster, dazu Zeitauswahl
(mit Hinweis bei Bereichen über das Ende hinaus), Muster, Ausgabeverzeichnis und
Fehlerfälle.
## Zwischenstände
@ -478,7 +516,9 @@ 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 |
| `INFLECTIONS` | je Sprache: -e, -n, -s, -en, …; -s, -ed, -ing, … | Endungen, die nur die Wortform ändern |
| `MIN_STEM` | 3 | kürzester gemeinsamer Wortstamm einer Wortform-Änderung |
In `vtt2txt.py`:

View file

@ -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 (
@ -33,6 +33,7 @@ MAX_WORD_DEVIATION = 0.05 # ab dieser Abweichung der Wortzahl wird gewarnt
PARAGRAPH_WINDOW = 150 # Sätze pro Anfrage bei der Absatzgliederung
MAX_PARAGRAPH_SENTENCES = 10 # längere Absätze werden nachträglich geteilt
MIN_PARAGRAPH_SENTENCES = 3 # kürzere Absätze werden mit dem Nachbarn verbunden
OUTPUT_RECORD = "ausgabe.sha256" # in <Ausgabe>.parts/: Prüfsumme der zuletzt geschriebenen Ausgabe
SENTENCE_END = re.compile(r"[.!?…][\"'»«“”)\]]*$")
SENTENCE_SPLIT = re.compile(r"(?:(?<=[.!?…])|(?<=[.!?…][\"'»«“”)\]]))\s+")
@ -57,6 +58,9 @@ Keine Zusammenfassung und keine stilistische Neufassung.
- Entferne nur eindeutig technisch verursachte Untertitel-Dopplungen.
- Bewahre rhetorische Wiederholungen und Selbstkorrekturen.
- Ergänze keine Informationen und keine fehlenden Sätze.
- Ergänze keine Vornamen, Titel oder andere Namensteile, die nicht gesagt
wurden, auch nicht aus dem Glossar: Steht in der Abschrift nur ein
Nachname, bleibt es beim Nachnamen.
- Glätte keine Formulierungen allein aus stilistischen Gründen.
- Ändere Zahlen, Daten und Zitate nur bei eindeutigen Fehlern.
- Übernimm Geräuschhinweise in eckigen Klammern wie [applaus] unverändert.
@ -123,6 +127,9 @@ No summary and no stylistic rewrite.
speaker-change marker ">>".
- Keep rhetorical repetitions and self-corrections.
- Do not add information or missing sentences.
- Do not add first names, titles or other parts of a name that were not
said, not even from the glossary: if the transcript has only a surname,
keep only the surname.
- Do not smooth phrasing for stylistic reasons alone.
- Change numbers, dates and quotations only if they are clearly wrong.
- Keep sound tags in square brackets such as [music] unchanged.
@ -180,7 +187,10 @@ PROMPTS = {
"merge": MERGE_SYSTEM,
"glossary": "Glossar: korrekte Schreibweisen von Namen und Begriffen, die in "
"diesem Beitrag vorkommen. Falsch erkannte Varianten davon in der Abschrift "
"stillschweigend korrigieren:",
"stillschweigend korrigieren. Korrigiere nur die Schreibweise dessen, was "
"gesagt wurde: Ergänze keine Vornamen, Titel oder Namensteile aus dem Glossar, "
"die in der Abschrift fehlen (aus „Dobrind“ wird „Dobrindt“, nicht "
"„Alexander Dobrindt“).",
"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 "
@ -193,7 +203,10 @@ PROMPTS = {
"merge": MERGE_SYSTEM_EN,
"glossary": "Glossary: correct spellings of names and terms that occur in "
"this recording. Silently correct misrecognized variants of them in the "
"transcript:",
"transcript. Correct only the spelling of what was said: do not add first "
"names, titles or other parts of a name from the glossary that are missing "
"in the transcript (\"Mearshimer\" becomes \"Mearsheimer\", not "
"\"John Mearsheimer\").",
"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 "
@ -227,7 +240,7 @@ def build_system(glossary, lang=DEFAULT_LANGUAGE, excerpt=False):
if not glossary:
return system
terms = "\n".join(f"- {term}" for term in glossary)
return f"{system}\n{prompts['glossary']}\n{terms}\n"
return f"{system}\n{prompts['glossary']}\n\n{terms}\n"
def read_glossary(path):
@ -635,6 +648,11 @@ def parse_args():
"--kein-bericht", action="store_true",
help="keinen Änderungsbericht <Ausgabe>.aenderungen.html schreiben",
)
parser.add_argument(
"--ueberschreiben", action="store_true",
help="die Ausgabedatei auch dann überschreiben, wenn sie seit dem letzten Lauf "
"von Hand geändert wurde (sonst entsteht <Name>.neu.korrigiert.txt)",
)
add_time_options(parser)
args = parser.parse_args(range_argv(sys.argv[1:]))
resolve_time_ranges(parser, args)
@ -702,6 +720,35 @@ def read_source(args):
return raw, args.output, base_language(args.lang or DEFAULT_LANGUAGE)
def text_hash(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def new_output_path(target):
"""rede.korrigiert.txt -> rede.neu.korrigiert.txt, out.txt -> out.neu.txt"""
if target.name.endswith(".korrigiert.txt"):
stem = target.name.removesuffix(".korrigiert.txt")
return target.with_name(f"{stem}.neu.korrigiert.txt")
return target.with_name(f"{target.stem}.neu{target.suffix}")
def choose_output(target, content, parts_dir, overwrite=False):
"""Where to write `content`: the target, unless it was changed by hand.
A target counts as changed by hand if it differs both from what the last
run wrote there (checksum in parts_dir) and from the new content. Without
a checksum (first run, .parts deleted) any differing target counts.
"""
if overwrite or not target.exists():
return target
current = target.read_text(encoding="utf-8")
record = parts_dir / OUTPUT_RECORD
last = record.read_text(encoding="utf-8").strip() if record.exists() else None
if current == content or text_hash(current) == last:
return target
return new_output_path(target)
def main():
global MODEL
args = parse_args()
@ -794,14 +841,25 @@ def main():
corrected_blocks.append((header, paragraphs))
text = join_segments(corrected_blocks, header_gap="\n\n")
target.write_text(text + "\n", encoding="utf-8")
content = text + "\n"
output = choose_output(target, content, parts_dir, args.ueberschreiben)
output.write_text(content, encoding="utf-8")
if output == target:
(parts_dir / OUTPUT_RECORD).write_text(text_hash(content) + "\n", encoding="utf-8")
else:
print(
f"WARNUNG: {target} wurde seit dem letzten Lauf geändert (von Hand?) und "
f"bleibt erhalten. Neues Ergebnis: {output} "
"(mit --ueberschreiben wird die Datei ersetzt)"
)
target = output
print(f"Fertig: {target}")
if not args.kein_bericht:
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}")

View file

@ -10,7 +10,7 @@ import argparse
import html
import re
from vtt2txt import vtt_to_lines
from vtt2txt import split_segments, vtt_to_lines
# "[prüfen: …]" in German transcripts, "[check: …]" in English ones
REVIEW_MARK = re.compile(r"\[(?:prüfen|check):[^\]]*\]")
@ -30,13 +30,25 @@ 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"},
}
# Endings that turn one form of a word into another ("Punkten" -> "Punkte")
INFLECTIONS = {
"de": {"", "e", "n", "r", "s", "t", "em", "en", "er", "es", "et", "st", "te", "est", "ten"},
"en": {"", "d", "s", "ed", "es", "ing"},
}
UMLAUT_FOLD = str.maketrans({"ä": "e", "ö": "o", "ü": "u", "ß": "ss"})
MIN_STEM = 3 # shortest common stem of two word forms
CONTEXT_WORDS = 6
VALID_WORD = "gültiges Wort ersetzt"
CHANGED_A_LOT = "stark verändert"
INSERTED = "eingefügt"
DELETED = "gestrichen"
WORD_FORM = "Wortform geändert"
def add_token(words, token):
@ -132,10 +144,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 +155,65 @@ 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 word_form_change(old, new, lang="de"):
"""True if one word becomes another form of itself: only the ending
("Punkten" -> "Punkte", "das" -> "dass") or an umlaut ("sehe" -> "sähe")
differs. "-in" is no such ending: "Professor" -> "Professorin" counts."""
if len(old) != 1 or len(new) != 1:
return False
a, b = (NON_WORD.sub("", w).lower().translate(UMLAUT_FOLD) for w in (old[0], new[0]))
if a == b:
return True
stem = 0
while stem < min(len(a), len(b)) and a[stem] == b[stem]:
stem += 1
endings = INFLECTIONS.get(lang, set())
return stem >= MIN_STEM and a[stem:] in endings and b[stem:] in endings
def review_candidates(comparison, wordlist=None, glossary=(), lang="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.
Replaced real words that only change their form get their own reason.
"""
fillers = FILLERS.get(lang, set())
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:
reason = WORD_FORM if word_form_change(old, new, lang) else VALID_WORD
elif similarity(old, new) < MIN_SIMILARITY:
reason = CHANGED_A_LOT
else:
continue
@ -205,6 +249,9 @@ del { background: var(--del); }
.orig { color: var(--muted); font-size: 0.8em; }
.check { background: var(--check); font-size: 0.85em; }
.warn { outline: 2px solid var(--warn); outline-offset: 1px; border-radius: 2px; }
.warn.form { outline-style: dashed; outline-width: 1px; }
h3 { font-size: 1rem; margin-top: 1.5rem; }
.scroll { overflow-x: auto; }
td.ctx { color: var(--muted); } td.ctx b { color: var(--fg); }
"""
@ -221,28 +268,43 @@ def render_html(comparison, title):
for old, new, count in summary
)
suspects = comparison.suspects
suspect_rows = "\n".join(
important, forms = split_suspects(suspects)
def table(rows):
body = "\n".join(
f"<tr><td>{esc(s.reason)}</td><td><del>{esc(s.old) or EMPTY}</del></td>"
f"<td><ins>{esc(s.new) or EMPTY}</ins></td>"
f"<td class=ctx>{suspect_context(comparison, s)}</td></tr>"
for s in suspects
for s in rows
)
if suspects:
suspect_table = (
"<table><tr><th>Grund</th><th>vorher</th><th>korrigiert</th>"
f"<th>Zusammenhang</th></tr>\n{suspect_rows}\n</table>"
return (
"<div class=scroll><table><tr><th>Grund</th><th>vorher</th><th>korrigiert</th>"
f"<th>Zusammenhang</th></tr>\n{body}\n</table></div>"
)
if important:
suspect_table = table(important)
elif forms:
suspect_table = "<p class=muted>Keine sinnverändernden Änderungen gefunden.</p>"
else:
suspect_table = "<p class=muted>Keine auffälligen Änderungen gefunden.</p>"
if forms:
suspect_table += (
f"\n<h3>Wortform geändert ({len(forms)})</h3>\n"
"<p class=muted>Nur Endung oder Umlaut anders („Punkten“ → „Punkte“, "
"„das“ → „dass“), meist Grammatik; ein kurzer Blick genügt.</p>\n"
+ table(forms)
)
# Suspect words are framed; a suspect deletion shows ⚠ after the word before it
warn_words, warn_after = {}, {}
for s in suspects:
note = esc(f"{s.reason}: „{s.old or EMPTY}“ → „{s.new or EMPTY}“", quote=True)
css = "warn form" if s.reason == WORD_FORM else "warn"
if s.j1 == s.j2:
warn_after.setdefault(max(s.j1 - 1, 0), []).append(note)
for j in range(s.j1, s.j2):
warn_words[j] = note
warn_words[j] = (css, note)
# Corrected text with its paragraphs; each word knows its paragraph
parts = []
@ -254,7 +316,8 @@ def render_html(comparison, title):
current = comparison.paragraph[j]
parts.append("</p>\n<p>")
if j in warn_words:
piece = f"<span class=warn title='{warn_words[j]}'>{piece}</span>"
css, note = warn_words[j]
piece = f"<span class='{css}' title='{note}'>{piece}</span>"
parts.append(piece)
for mark in comparison.marks.get(j, []):
parts.append(f"<span class=check>{esc(mark)}</span>")
@ -307,6 +370,12 @@ gültige Wörter ersetzt, stark veränderte, eingefügte oder gestrichene Wörte
"""
def split_suspects(suspects):
"""(possible meaning changes, mere word forms), each in text order."""
forms = [s for s in suspects if s.reason == WORD_FORM]
return [s for s in suspects if s.reason != WORD_FORM], forms
def suspect_context(comparison, suspect):
"""HTML: the change in bold with a few corrected words around it."""
esc = html.escape
@ -328,15 +397,18 @@ def print_summary(comparison, limit=15, suspect_limit=10):
if len(summary) > limit:
print(f" … und {len(summary) - limit} weitere, siehe Bericht")
suspects = comparison.suspects
print(f"Zu prüfen: {len(suspects)} Stellen")
important, forms = split_suspects(comparison.suspects)
# Meaning changes first, word forms after them
suspects = important + forms
of_which = f" (davon {len(forms)} Wortformen)" if forms else ""
print(f"Zu prüfen: {len(suspects)} Stellen{of_which}")
for s in suspects[:suspect_limit]:
print(f" {s.reason}: „{s.old or EMPTY}“ → „{s.new or EMPTY}“")
if len(suspects) > suspect_limit:
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 +416,7 @@ 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, lang)
target.write_text(render_html(comparison, title), encoding="utf-8")
return comparison
@ -355,6 +427,11 @@ def report_path(corrected_path):
return corrected_path.with_name(f"{stem}.aenderungen.html")
def without_headers(text):
"""Text without time headers ("[00:04:57 - 00:06:04]") of excerpts."""
return "\n\n".join(body for _, body in split_segments(text))
def read_raw(path):
text = path.read_text(encoding="utf-8-sig")
if path.suffix.lower() == ".vtt":
@ -397,6 +474,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)
@ -409,10 +487,13 @@ def main():
model_input = apply_replacements(" ".join(raw.split()), replacements)
corrected = args.korrigiert.read_text(encoding="utf-8")
# A raw .vtt has no time headers: those of the excerpts are no insertions
if without_headers(raw) == raw.strip():
corrected = without_headers(corrected)
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}")

335
tests/daten/beispiel.de.txt Normal file
View file

@ -0,0 +1,335 @@
[applaus]
Vielen Dank. Liebe Gäste, liebe
Bürgerinnen und Bürger von Auen Tal, ich
freue mich sehr, dass Sie heute
so zahlreich gekommen sind. Wir feiern ein besonderes
Jubiläum. Vor 750 Jahren wurde unser
Ort zum ersten Mal
in einer Urkunde erwähnt. Das war im
Jahr 12 74. Damals war Auen
Tal ein kleiner Weiler mit
drei Höfen und einer Mühle am Bach.
Heute leben hier fast viertausend Menschen.
[applaus]
Ich möchte Ihnen heute keine lange Geschichtsstunde halten.
Dafür haben wir Frau Dr. Jorinde
Haber Kamp vom Kreisarchiv
eingeladen, die gleich noch zu Ihnen sprechen
wird. Ich möchte lieber erzählen, was
dieser Ort für uns bedeutet.
Und ich möchte erzählen, was wir in
den nächsten Jahren vorhaben.
Fangen wir mit der Mühle an. Die alte
Mühle am Linden Bach steht noch
heute. Sie hat viele
Besitzer gehabt. Sie ist zweimal abgebrannt und
zweimal wieder aufgebaut worden. Seit dem
3. Oktober 1990 gehört sie
der Gemeinde. Viele von Ihnen kennen sie
als Heimatmuseum. Manche kennen sie auch
als den Ort, an dem sie ihren ersten
Kuss bekommen haben. Ich nenne keine
Namen.
[lachen]
Die Mühle ist ein gutes Beispiel für
das, was Auen Tal ausmacht. Wir
bauen wieder auf, wenn etwas
kaputtgeht. Wir geben nicht auf. Und wir
machen das gemeinsam. Das klingt vielleicht
nach einer Floskel. Aber ich habe es selbst
erlebt. Als vor zwölf Jahren das
Hochwasser kam, standen am
nächsten Morgen über zweihundert Menschen mit Schaufeln
und Eimern vor dem Rathaus. Niemand
hatte sie gerufen. Sie waren
einfach da.
Ich erinnere mich an eine ältere
Dame aus der Bachstraße. Ihr Keller stand bis
zur Decke unter Wasser. Sie hat
trotzdem für alle Helfer
Kaffee gekocht. Sie hat gesagt, das Wasser
geht wieder, aber die Nachbarn bleiben.
Diesen Satz habe ich mir
gemerkt. Er hängt heute als Spruch in
meinem Büro.
Liebe Gäste, ein Jubiläum ist ein guter Anlass,
um zurückzuschauen. Es ist aber auch
ein guter Anlass, um
nach vorn zu schauen. Und da gibt
es einiges zu tun. Ich will
Ihnen drei Dinge nennen, die
uns in den nächsten Jahren beschäftigen werden.
Das erste ist die Schule. Unsere
Grundschule ist über hundert Jahre alt. Das Gebäude
ist schön, aber es ist zu
klein geworden. Wir haben
im letzten Jahr so viele Kinder eingeschult
wie seit Jahrzehnten nicht mehr. Das
ist eine gute Nachricht. Es
bedeutet aber auch, dass wir anbauen müssen.
Der Gemeinderat hat den Anbau im
Frühjahr beschlossen. Die Bauarbeiten beginnen im nächsten Sommer.
Wenn alles gut geht, ziehen die
Kinder in zwei Jahren
in die neuen Räume.
Das zweite ist der Verkehr. Viele
von Ihnen ärgern sich über
die Lastwagen, die durch die Hauptstraße fahren.
Ich ärgere mich auch. Seit Jahren
reden wir über eine Umgehungsstraße. Seit Jahren passiert
wenig. Ich will Ihnen nichts versprechen,
was ich nicht halten
kann. Aber ich kann Ihnen sagen, dass
das Land die Planung jetzt endlich
aufgenommen hat. Das ist ein
erster Schritt. Es werden noch viele Schritte
folgen müssen.
Das dritte ist die Nahversorgung. Seit der Bäcker
an der Kirche geschlossen hat, gibt
es im Ortskern kein
Geschäft mehr. Wer kein Auto hat, muss
mit dem Bus nach Linden Brunn
fahren. Das ist für viele
ältere Menschen ein Problem. Wir haben deshalb
mit einer Genossenschaft gesprochen, die Dorfläden
betreibt. Die Gespräche sind gut gelaufen. Ich hoffe,
dass wir Ihnen im Herbst mehr
dazu sagen können.
[applaus]
Ich weiß, dass das alles nicht spektakulär
klingt. Eine Schule, eine Straße, ein
Laden. Aber genau darum geht
es in einer Gemeinde wie unserer. Es
geht nicht um große Worte. Es
geht darum, dass das Leben hier gut funktioniert.
Für die Kinder, für die Familien,
für die Älteren.
Lassen Sie mich noch etwas zum Ehrenamt
sagen. Ohne die Vereine wäre Auen
Tal ein anderer Ort. Wir
haben einen Sportverein, einen Gesangverein, eine freiwillige
Feuerwehr, einen Heimatverein und einen Kleintierzuchtverein.
Wir haben eine Theatergruppe, die jedes Jahr im
Winter ein Stück aufführt. Und wir
haben seit drei Jahren
ein Repaircafé, in dem Menschen ihre kaputten
Toaster und Fahrräder reparieren lassen können.
Das alles funktioniert nur, weil
Menschen ihre freie Zeit hergeben. Sie tun
das ohne Bezahlung und oft ohne
Dank. Deshalb möchte ich heute ausdrücklich Danke sagen.
Danke an alle, die sich engagieren.
Sie sind das Herz
dieser Gemeinde.
[applaus]
Ein besonderer Dank geht an die
Freiwillige Feuerwehr. Sie feiert in
diesem Jahr ebenfalls ein Jubiläum, nämlich ihr
hundertfünfzigjähriges. Der Wehrführer Bernd Ols Hausen
hat mir erzählt, dass die erste Spritze noch
von Pferden gezogen wurde. Heute hat
die Wehr zwei moderne
Fahrzeuge. Aber eines ist gleich geblieben. Wenn
die Sirene geht, lassen die Frauen
und Männer alles stehen und
liegen und rücken aus. Das verdient unseren
Respekt.
Liebe Gäste, wir haben für das Jubiläumsjahr ein
Programm vorbereitet, das sich über den
ganzen Sommer zieht. Ich
will Ihnen nur einige Höhepunkte nennen. Im
Juni gibt es ein Mühlenfest mit
Führungen und einem Markt. Im
Juli zeigt die Theatergruppe ein Stück über
die Geschichte des Ortes. Im August
feiern wir ein Dorffest auf dem Anger mit
Musik und Tanz. Und im September
gibt es eine Ausstellung
im Rathaus mit alten Fotos aus Auen
Tal.
Für diese Ausstellung suchen wir
übrigens noch Bilder. Wenn Sie zu Hause
alte Fotos haben, z. B. von
Hochzeiten, von Festen oder einfach vom Alltag, dann
bringen Sie sie bitte ins Rathaus.
Wir scannen sie und
geben sie Ihnen sofort zurück. Frau Haber
Kamp hat mir versichert, dass solche
Bilder für das Archiv ein
großer Schatz sind.
Ich möchte auch etwas zu unseren
Partnern sagen. Seit über dreißig Jahren haben wir
eine Partnerschaft mit der Gemeinde Saint
Maur in Frankreich. Eine
Delegation aus Saint Maur ist heute hier.
Bienvenue, liebe Freunde. Wir freuen uns
sehr, dass Sie die weite
Reise auf sich genommen haben.
[applaus]
Die Partnerschaft hat mit einem Schüleraustausch
angefangen. Inzwischen gibt es Besuche der Vereine, der
Feuerwehren und der Chöre. Es gibt
sogar zwei Ehen, die
aus dieser Partnerschaft entstanden sind. Auch hier
nenne ich keine Namen.
Meine Damen und Herren, ich
habe vorhin gesagt, dass wir wieder aufbauen,
wenn etwas kaputtgeht. Das gilt nicht
nur für Gebäude. Es gilt auch für das
Miteinander. Auch bei uns gibt es
Streit. Es gibt Streit
über den Windpark, über die Parkplätze am
Friedhof und über die Frage, ob
der Weihnachtsbaum auf dem Marktplatz
echt sein muss oder aus Plastik sein
darf.
[lachen]
Streit gehört dazu. Aber wir sollten uns dabei
nicht aus den Augen verlieren. Wir
sollten miteinander reden und
nicht übereinander. Wir sollten zuhören, auch wenn
es schwerfällt. Das ist nicht immer
einfach. Ich schaffe das auch
nicht immer. Aber ich bemühe mich.
Lassen Sie mich zum Schluss noch
eine kleine Geschichte erzählen. Als ich vor sechs
Jahren zur Bürgermeisterin gewählt wurde, hat
mir mein Vorgänger einen
Schlüssel gegeben. Es war kein Schlüssel für
das Rathaus. Es war der Schlüssel
für den Kirchturm. Er hat
gesagt, wenn du einmal nicht weiterweißt, dann
steig da hoch und schau dir
den Ort von oben an.
Ich habe das ein paar Mal
gemacht. Von oben sieht
man die Dächer, die Gärten, den Bach
und die Mühle. Man sieht die
Felder und den Wald. Man
sieht, wie klein der Ort ist und
wie viel trotzdem darin steckt. Und
man merkt, dass die meisten Probleme von dort
oben ein bisschen kleiner aussehen.
Ich möchte, dass dieser
Ort auch in fünfzig Jahren noch ein
Ort ist, an dem man gern
lebt. Ein Ort, an dem
Kinder zur Schule laufen können. Ein Ort,
an dem ältere Menschen nicht allein
sind. Ein Ort, an dem man sich kennt
und sich hilft. Daran arbeiten wir.
Und dafür brauchen wir
Sie alle.
Bevor ich jetzt das Wort an
Frau Dr. Haber Kamp übergebe,
möchte ich noch einigen Menschen danken. Ich
danke dem Festausschuss, der seit über
einem Jahr dieses Jubiläum vorbereitet. Ich danke dem
Bauhof, der den Platz so schön
hergerichtet hat. Ich danke
dem Gesangverein für die Musik. Und ich
danke den Landfrauen für den Kuchen,
den Sie nachher alle probieren
sollten.
[lachen]
Ich wünsche uns allen ein schönes
Fest und ein gutes Jubiläumsjahr. Auf die nächsten
750 Jahre. Vielen Dank.
[applaus]
Ja, vielen Dank, Frau
Bürgermeisterin. Liebe Gäste, ich bin Jorinde Haber
Kamp und leite das Kreisarchiv. Ich
darf Ihnen heute ein wenig
über die Geschichte von Auen Tal erzählen.
Keine Sorge, ich halte mich kurz.
Die Urkunde aus dem Jahr 12 74 liegt
bei uns im Archiv. Sie ist
auf Pergament geschrieben, in
lateinischer Sprache, und sie ist erstaunlich gut
erhalten. Es geht darin um einen
Streit über Wasserrechte. Zwei Klöster
stritten sich darüber, wer die Mühle am
Bach nutzen darf. Der Ort selbst
wird nur nebenbei erwähnt. Aber er wird erwähnt,
und deshalb feiern wir heute.
Was wissen wir über
die Menschen, die damals hier gelebt haben?
Leider nicht viel. Sie konnten nicht
schreiben, und so haben sie
uns keine Briefe oder Tagebücher hinterlassen. Was
wir wissen, stammt aus Abgaberegistern, aus
Gerichtsakten und aus Kirchenbüchern. Daraus können wir aber
erstaunlich viel ablesen.
Wir wissen z. B.,
dass im Jahr 1580 in Auen Tal
elf Familien lebten. Wir wissen, dass
sie vor allem Roggen und
Hafer anbauten. Wir wissen, dass es im
Jahr 1636 eine schwere Pest gab,
an der fast die Hälfte der Menschen starb.
Und wir wissen, dass der Ort
sich danach nur langsam
erholt hat.
Ein besonderer Fund ist ein Gerichtsprotokoll
aus dem Jahr 1712. Darin
klagt ein Müller namens Kaspar Ols Hausen
gegen seinen Nachbarn, weil dessen Ziegen
immer wieder in seinen Garten eingedrungen seien. Das
Gericht gab dem Müller recht. Der
Nachbar musste einen Zaun
bauen und zwei Hühner als Entschädigung zahlen.
[lachen]
Ich erzähle Ihnen das nicht nur,
weil es lustig ist. Ich
erzähle es, weil es zeigt, dass die
Menschen damals ganz ähnliche Sorgen hatten
wie wir heute. Es ging um den Garten,
um den Nachbarn, um ein bisschen
Gerechtigkeit. Der Name Ols
Hausen ist übrigens bis heute im Ort
zu finden. Vielleicht ist der Wehrführer
ja ein Nachfahre des Müllers.
Im neunzehnten Jahrhundert hat sich Auen Tal
stark verändert. Im Jahr 1868 wurde
die Bahnstrecke nach Linden Brunn gebaut. Plötzlich konnten
die Bauern ihre Waren in die
Stadt bringen. Es entstanden
eine Ziegelei und eine kleine Weberei. Die
Einwohnerzahl hat sich in wenigen Jahrzehnten
verdoppelt.
Das zwanzigste Jahrhundert war auch hier eine
Zeit der Brüche. Zwei Weltkriege haben
viele Familien getroffen. Auf dem Friedhof erinnert ein
Denkmal an die Gefallenen. Nach dem
2. Weltkrieg kamen viele
Vertriebene in den Ort. Sie mussten untergebracht
werden, oft in sehr engen Verhältnissen.
Für die Alteingesessenen war das
nicht leicht, und für die Neuen noch
weniger.
Aber auch hier hat sich gezeigt, was die
Bürgermeisterin vorhin beschrieben hat. Mit der
Zeit sind die Neuen
zu Nachbarn geworden. Sie haben Häuser gebaut,
Vereine gegründet und Familien gegründet. Heute
weiß kaum noch jemand, wessen
Großeltern damals gekommen sind und wessen Großeltern
schon immer hier waren.
Liebe Gäste, Archive sind keine staubigen Orte. Sie
sind das Gedächtnis einer Gemeinschaft. Und
dieses Gedächtnis lebt davon,
dass Menschen etwas hineingeben. Deshalb unterstütze ich
den Aufruf der Bürgermeisterin ausdrücklich. Bringen
Sie Ihre Fotos, Ihre Briefe,
Ihre Erinnerungen. Was heute alltäglich erscheint, ist
in hundert Jahren ein wertvolles Zeugnis.
Ich habe Ihnen eine kleine Auswahl mitgebracht. Im
Foyer des Rathauses hängen ab heute
Kopien der Urkunde und
einiger anderer Dokumente. Schauen Sie sie sich
gern an. Und wenn Sie Fragen
haben, sprechen Sie mich an.
Ich bin den ganzen Nachmittag hier.
Ich wünsche Ihnen ein schönes Fest.
Vielen Dank für Ihre Aufmerksamkeit.
[applaus]

2683
tests/daten/beispiel.de.vtt Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
# Glossar für die Beispielrede tests/daten/beispiel.de.* (frei erfunden,
# Gemeinde und Personen gibt es nicht). Jede Ersetzung trifft die Rede.
# --- Feste Ersetzungen ---
Auen Tal => Auental
Haber Kamp => Haberkamp
Linden Brunn => Lindenbrunn
Linden Bach => Lindenbach
Ols Hausen => Olshausen
12 74 => 1274
# --- Begriffe ---
Saint-Maur
Jorinde Haberkamp

View file

@ -11,9 +11,11 @@ import pytest
import abschrift_korrigieren as ak
REPO = Path(__file__).resolve().parent.parent
SAMPLE = REPO / "testdaten" / "ramstein.de.txt"
GLOSSARY = REPO / "ramstein.glossar.txt"
# Own, made-up sample speech; real speeches are only kept locally in testdaten/
SAMPLE = REPO / "tests" / "daten" / "beispiel.de.txt"
GLOSSARY = REPO / "tests" / "daten" / "beispiel.glossar.txt"
SHARED_GLOSSARY = REPO / "krone-schmalz.glossar.txt"
SPEECHES = REPO / "testdaten"
# The autouse fixture below replaces it; tests of the real function use this
REAL_FIND_MERGE_SIDE = ak.find_merge_side
@ -117,6 +119,10 @@ def test_build_system_appends_terms():
system = ak.build_system(["Kreml", "Egon Bahr"])
assert system.startswith(ak.SYSTEM)
assert "- Kreml\n- Egon Bahr" in system
# The model must not complete names from the glossary
assert "Ergänze keine Vornamen" in system
assert "bleibt es beim Nachnamen" in ak.SYSTEM
assert "keep only the surname" in ak.SYSTEM_EN
SAMPLE_GLOSSARIES = [
@ -126,19 +132,37 @@ SAMPLE_GLOSSARIES = [
]
# Real speeches and their glossaries are not in the repository; without them these skip
needs_speeches = pytest.mark.skipif(
not all(
(SPEECHES / sample).is_file() and (REPO / glossary).is_file()
for glossary, sample in SAMPLE_GLOSSARIES
) or not SHARED_GLOSSARY.is_file(),
reason="echte Reden (testdaten/) und ihre Glossare nur lokal",
)
def test_own_sample_glossary_replacements_all_match(capsys):
text = flat(SAMPLE.read_text(encoding="utf-8"))
ak.apply_replacements(text, ak.read_glossary(GLOSSARY)[1])
assert "ohne Treffer" not in capsys.readouterr().out
@needs_speeches
@pytest.mark.parametrize("glossary, sample", 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 = flat((SPEECHES / sample).read_text(encoding="utf-8"))
text = ak.apply_replacements(text, ak.read_glossary(SHARED_GLOSSARY)[1])
capsys.readouterr()
ak.apply_replacements(text, ak.read_glossary(REPO / glossary)[1])
assert "ohne Treffer" not in capsys.readouterr().out
@needs_speeches
def test_shared_glossary_rules_match_some_speech():
texts = [
flat((REPO / "testdaten" / sample).read_text(encoding="utf-8"))
flat((SPEECHES / sample).read_text(encoding="utf-8"))
for _, sample in SAMPLE_GLOSSARIES
]
for wrong, right in ak.read_glossary(SHARED_GLOSSARY)[1]:
@ -388,7 +412,7 @@ def test_build_paragraphs_asks_where_short_paragraphs_belong(tmp_path, monkeypat
@pytest.fixture
def sample_copy(tmp_path):
path = tmp_path / "ramstein.de.txt"
path = tmp_path / "beispiel.de.txt"
shutil.copy(SAMPLE, path)
return path
@ -396,22 +420,92 @@ def sample_copy(tmp_path):
def test_main_default_output_resume_and_glossary(sample_copy, monkeypatch, capsys):
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)
run_main(monkeypatch, "-i", sample_copy, "-g", GLOSSARY)
target = sample_copy.with_name("ramstein.de.korrigiert.txt")
target = sample_copy.with_name("beispiel.de.korrigiert.txt")
result = target.read_text(encoding="utf-8")
assert "Ramstein" in result and "Rammstein" not in result
assert "Kreml" in result and "1983" in result
assert (sample_copy.parent / "ramstein.de.korrigiert.parts").is_dir()
assert "Auental" in result and "Auen Tal" not in result
assert "Haberkamp" in result and "1274" in result
assert (sample_copy.parent / "beispiel.de.korrigiert.parts").is_dir()
assert "WARNUNG" not in capsys.readouterr().out
# Second run: everything cached, 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)
run_main(monkeypatch, "-i", sample_copy, "-g", GLOSSARY)
assert target.read_text(encoding="utf-8") == result
def shouting(chunk, index, system):
"""A different model result than identity_correct."""
return chunk.strip().replace("Vielen Dank", "VIELEN DANK")
def test_main_overwrites_own_output(sample_copy, monkeypatch, capsys):
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
run_main(monkeypatch, "-i", sample_copy)
target = sample_copy.with_name("beispiel.de.korrigiert.txt")
# Unchanged output of the last run is replaced by a different result
monkeypatch.setattr(ak, "correct_chunk", shouting)
run_main(monkeypatch, "-i", sample_copy, "-m", "anderes:modell")
assert "VIELEN DANK" in target.read_text(encoding="utf-8")
assert not ak.new_output_path(target).exists()
assert "WARNUNG" not in capsys.readouterr().out
def test_main_keeps_hand_edited_output(sample_copy, monkeypatch, capsys):
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
run_main(monkeypatch, "-i", sample_copy)
target = sample_copy.with_name("beispiel.de.korrigiert.txt")
edited = target.read_text(encoding="utf-8") + "Von Hand ergänzt.\n"
target.write_text(edited, encoding="utf-8")
capsys.readouterr()
monkeypatch.setattr(ak, "correct_chunk", shouting)
run_main(monkeypatch, "-i", sample_copy, "-m", "anderes:modell")
new = sample_copy.with_name("beispiel.de.neu.korrigiert.txt")
assert target.read_text(encoding="utf-8") == edited
assert "VIELEN DANK" in new.read_text(encoding="utf-8")
assert sample_copy.with_name("beispiel.de.neu.aenderungen.html").is_file()
out = capsys.readouterr().out
assert f"WARNUNG: {target} wurde seit dem letzten Lauf geändert" in out
assert f"Fertig: {new}" in out
# --ueberschreiben replaces the edited file
run_main(monkeypatch, "-i", sample_copy, "-m", "anderes:modell", "--ueberschreiben")
assert "VIELEN DANK" in target.read_text(encoding="utf-8")
def test_main_keeps_differing_output_without_checksum(sample_copy, monkeypatch, capsys):
target = sample_copy.with_name("beispiel.de.korrigiert.txt")
target.write_text("Älteres Ergebnis, Herkunft unbekannt.\n", encoding="utf-8")
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
run_main(monkeypatch, "-i", sample_copy)
assert target.read_text(encoding="utf-8") == "Älteres Ergebnis, Herkunft unbekannt.\n"
assert sample_copy.with_name("beispiel.de.neu.korrigiert.txt").is_file()
assert "WARNUNG" in capsys.readouterr().out
@pytest.mark.parametrize("name, expected", [
("rede.korrigiert.txt", "rede.neu.korrigiert.txt"),
("out.txt", "out.neu.txt"),
])
def test_new_output_path(name, expected):
assert ak.new_output_path(Path("a") / name) == Path("a") / expected
def test_choose_output_same_content_is_no_conflict(tmp_path):
target = tmp_path / "rede.korrigiert.txt"
target.write_text("gleich\n", encoding="utf-8")
assert ak.choose_output(target, "gleich\n", tmp_path / "fehlt.parts") == target
def test_main_output_option_creates_directories(sample_copy, monkeypatch):
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
@ -424,36 +518,36 @@ def test_main_output_option_creates_directories(sample_copy, monkeypatch):
def test_main_vtt_input_matches_txt_input(sample_copy, monkeypatch):
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 = sample_copy.parent / "vtt" / "beispiel.de.vtt"
vtt.parent.mkdir()
shutil.copy(SAMPLE.with_suffix(".vtt"), vtt)
run_main(monkeypatch, "-i", sample_copy)
run_main(monkeypatch, "-i", vtt)
expected = sample_copy.with_name("ramstein.de.korrigiert.txt")
assert vtt.with_name("ramstein.de.korrigiert.txt").read_text(
expected = sample_copy.with_name("beispiel.de.korrigiert.txt")
assert vtt.with_name("beispiel.de.korrigiert.txt").read_text(
encoding="utf-8"
) == expected.read_text(encoding="utf-8")
def test_main_writes_change_report(sample_copy, monkeypatch, capsys):
def correct(chunk, index, system):
return chunk.strip().replace("Rammstein", "Ramstein")
return chunk.strip().replace("Linden Brunn", "Lindenbrunn")
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")
assert "title='Original: Rammstein" in report.read_text(encoding="utf-8")
report = sample_copy.with_name("beispiel.de.aenderungen.html")
assert "title='Original: Linden Brunn" in report.read_text(encoding="utf-8")
out = capsys.readouterr().out
assert "„Rammstein“ → „Ramstein“" in out
assert "„Linden Brunn“ → „Lindenbrunn“" in out
assert f"Bericht: {report}" in out
def test_main_report_suspects_only_model_changes(sample_copy, monkeypatch, capsys):
glossary = sample_copy.with_name("g.txt")
glossary.write_text("Rammstein => Ramstein\n", encoding="utf-8")
glossary.write_text("Linden Brunn => Lindenbrunn\n", encoding="utf-8")
def correct(chunk, index, system):
return chunk.strip().replace(" das ", " dies ", 1)
@ -462,9 +556,9 @@ def test_main_report_suspects_only_model_changes(sample_copy, monkeypatch, capsy
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
run_main(monkeypatch, "-i", sample_copy, "-g", glossary)
page = sample_copy.with_name("ramstein.de.aenderungen.html").read_text(encoding="utf-8")
page = sample_copy.with_name("beispiel.de.aenderungen.html").read_text(encoding="utf-8")
section = page.split("<h2>Zu prüfen")[1].split("<h2>Alle Änderungen")[0]
assert "Rammstein" in page and "Rammstein" not in section
assert "Linden Brunn" in page and "Linden Brunn" not in section
assert "dies" in section
out = capsys.readouterr().out
assert "gültiges Wort ersetzt: „das“ → „dies“" in out
@ -475,7 +569,7 @@ def test_main_without_change_report(sample_copy, monkeypatch):
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()
assert not sample_copy.with_name("beispiel.de.aenderungen.html").exists()
def test_main_model_option(sample_copy, monkeypatch):
@ -488,7 +582,7 @@ def test_main_model_option(sample_copy, monkeypatch):
monkeypatch.setattr(ak, "correct_chunk", correct)
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
parts = sample_copy.parent / "ramstein.de.korrigiert.parts"
parts = sample_copy.parent / "beispiel.de.korrigiert.parts"
run_main(monkeypatch, "-i", sample_copy)
default_parts = {p.name for p in parts.glob("part*")}
@ -531,9 +625,16 @@ def from_url(fake_yt_dlp, yt_dlp_installed, monkeypatch):
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
def kreml_glossary(folder):
"""The one glossary rule the fake video needs."""
path = folder / "kreml.glossar.txt"
path.write_text("Kremmel => Kreml\n", encoding="utf-8")
return path
def test_main_url_default_names(from_url, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-g", SHARED_GLOSSARY)
run_main(monkeypatch, "-u", URL, "-g", kreml_glossary(tmp_path))
raw = (tmp_path / "Kanal_Rede_2026-03-28.txt").read_text(encoding="utf-8")
assert raw == "Der Kremmel ist in Moskau.\nDas ist bekannt.\n"
@ -576,6 +677,7 @@ def test_build_system_english():
system = ak.build_system(["Mearsheimer"], "en")
assert system.startswith(ak.SYSTEM_EN)
assert "Glossary:" in system and "- Mearsheimer" in system
assert "do not add first" in system
def test_main_english_prompts(sample_copy, monkeypatch, capsys):
@ -822,7 +924,7 @@ def test_main_url_several_ranges(fake_yt_dlp, yt_dlp_installed, tmp_path, monkey
monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=MULTI_VTT))
sent = chunk_recorder(monkeypatch)
monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-g", SHARED_GLOSSARY)
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-g", kreml_glossary(tmp_path))
name = "Kanal_Rede_2026-03-28_bis_00-00-30+ab_00-02-00"
assert (tmp_path / f"{name}.txt").read_text(encoding="utf-8") == (

View file

@ -124,8 +124,8 @@ 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=(), lang="de"):
found = ae.review_candidates(ae.compare(model_input, corrected), wordlist, glossary, lang)
return [(s.reason, s.old, s.new) for s in found]
@ -153,13 +153,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.", lang="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) == []
@ -198,7 +222,7 @@ def test_report_suspects_only_against_model_input(tmp_path, capsys):
assert [s.reason for s in comparison.suspects] == [ae.VALID_WORD, ae.DELETED]
assert "Zu prüfen (2)" in page
assert "Professorin" in section and "Kremmel" not in section
assert "<span class=warn title='gültiges Wort ersetzt: „Professor“ → „Professorin“'>" in page
assert "<span class='warn' title='gültiges Wort ersetzt: „Professor“ → „Professorin“'>" in page
assert "<span class=warn title='gestrichen: „heute“ → „∅“'>⚠</span>" in page
# Framing changes nothing about the text itself
assert report_text(page) == "Der Professorin im Kreml ⚠ dort.".split()
@ -216,6 +240,20 @@ def test_report_without_suspects(tmp_path):
assert "Keine auffälligen Änderungen" in suspect_section(target.read_text(encoding="utf-8"))
def test_cli_ignores_time_headers_missing_in_raw(tmp_path, monkeypatch, capsys):
raw = tmp_path / "rede.vtt"
raw.write_text(
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\ndas Kremmel heute\n", encoding="utf-8"
)
corrected = tmp_path / "rede.korrigiert.txt"
corrected.write_text("[00:00:01 - 00:00:02]\n\nDas Kreml heute.\n", encoding="utf-8")
monkeypatch.setattr(sys, "argv", ["aenderungen.py", "-a", str(raw), "-b", str(corrected)])
ae.main()
out = capsys.readouterr().out
assert "Zu prüfen: 0 Stellen" in out
assert "00:00:01" not in out
def test_cli_glossary(tmp_path, monkeypatch, capsys):
raw = tmp_path / "rede.txt"
raw.write_text("das Kremmel und\nBar heute\n", encoding="utf-8")
@ -235,3 +273,51 @@ def test_cli_glossary(tmp_path, monkeypatch, capsys):
assert "Zu prüfen: 0 Stellen" in out
# The report still compares against the raw transcript
assert "1× „Kremmel“ → „Kreml“" in out
@pytest.mark.parametrize("old, new, expected", [
("Punkten", "Punkte", True),
("das", "dass", True),
("sehe", "sähe", True), # umlaut
("sein", "seien", True), # both endings change
("kommt,", "kommen,", True), # punctuation does not count
("Professor", "Professorin", False), # "-in" is no inflection
("geworben", "geworden", False),
("unterschreiben", "unterscheiden", False),
("ich", "sich", False),
("den", "der", False), # stem too short
("sich dazu", "zu", False), # more than one word
])
def test_word_form_change(old, new, expected):
assert ae.word_form_change(old.split(), new.split()) is expected
def test_word_form_change_english():
assert ae.word_form_change(["walk"], ["walked"], "en")
assert not ae.word_form_change(["walk"], ["walker"], "en")
def test_review_candidates_word_forms():
words = WORDS | {"punkten", "punkte"}
assert suspects("die Punkten und das", "die Punkte und dass", wordlist=words) == [
(ae.WORD_FORM, "Punkten", "Punkte"), (ae.WORD_FORM, "das", "dass"),
]
def test_report_and_summary_put_word_forms_last(tmp_path, capsys):
target = tmp_path / "bericht.html"
comparison = ae.write_report(
"das sagte der Professor", "dass sagte der Professorin", target, "rede"
)
assert [s.reason for s in comparison.suspects] == [ae.WORD_FORM, ae.VALID_WORD]
page = target.read_text(encoding="utf-8")
section = suspect_section(page)
# Meaning change in the main table, the word form in its own group after it
assert section.index("Professorin") < section.index("Wortform geändert (1)")
assert section.index("Wortform geändert (1)") < section.index("<ins>dass</ins>")
assert "<span class='warn form' title='Wortform geändert: „das“ → „dass“'>" in page
ae.print_summary(comparison)
out = capsys.readouterr().out.split("Zu prüfen: ")[1]
assert out.startswith("2 Stellen (davon 1 Wortformen)")
assert out.index("Professorin") < out.index("„das“ → „dass“")

View file

@ -8,8 +8,10 @@ import pytest
import vtt2txt
REPO = Path(__file__).resolve().parent.parent
SAMPLE_VTT = REPO / "testdaten" / "ramstein.de.vtt"
SAMPLE_TXT = REPO / "testdaten" / "ramstein.de.txt"
# Own, made-up sample speech; real speeches are only kept locally in testdaten/
SAMPLE_VTT = REPO / "tests" / "daten" / "beispiel.de.vtt"
SAMPLE_TXT = REPO / "tests" / "daten" / "beispiel.de.txt"
SPEECHES = REPO / "testdaten"
@pytest.fixture
@ -107,15 +109,27 @@ def test_is_rolling(cues, expected):
def test_main_default_output_matches_sample(sample_copy, monkeypatch, capsys):
run_main(monkeypatch, "-i", sample_copy)
target = sample_copy.with_name("ramstein.de.txt")
target = sample_copy.with_name("beispiel.de.txt")
assert target.read_text(encoding="utf-8") == SAMPLE_TXT.read_text(encoding="utf-8")
assert f"Erstellt: {target}" in capsys.readouterr().out
@pytest.mark.parametrize("name", ["ramstein.de", "magdeburg.de-orig", "Lahnstein.de-orig"])
def test_sample_vtt_gives_sample_txt(name):
vtt = (REPO / "testdaten" / f"{name}.vtt").read_text(encoding="utf-8-sig")
txt = (REPO / "testdaten" / f"{name}.txt").read_text(encoding="utf-8")
def speech(name):
"""A real speech, only kept locally (copyright): skipped without it."""
path = SPEECHES / name
return pytest.param(path, marks=pytest.mark.skipif(
not path.is_file(), reason="echte Reden nur lokal in testdaten/"
))
@pytest.mark.parametrize("vtt_path", [
SAMPLE_VTT,
speech("ramstein.de.vtt"), speech("magdeburg.de-orig.vtt"),
speech("Lahnstein.de-orig.vtt"),
])
def test_sample_vtt_gives_sample_txt(vtt_path):
vtt = vtt_path.read_text(encoding="utf-8-sig")
txt = vtt_path.with_suffix(".txt").read_text(encoding="utf-8")
assert vtt2txt.vtt_to_lines(vtt) == txt.splitlines()
@ -128,9 +142,9 @@ def test_main_multiple_inputs(sample_copy, monkeypatch):
def test_main_expands_glob_without_duplicates(sample_copy, monkeypatch, capsys):
shutil.copy(sample_copy, sample_copy.with_name("ramstein.en.vtt"))
shutil.copy(sample_copy, sample_copy.with_name("beispiel.en.vtt"))
shutil.copy(sample_copy, sample_copy.with_name("anders.de.vtt"))
run_main(monkeypatch, "-i", sample_copy.parent / "ramstein.*.vtt", sample_copy)
run_main(monkeypatch, "-i", sample_copy.parent / "beispiel.*.vtt", sample_copy)
out = capsys.readouterr().out
assert out.count("Erstellt:") == 2
assert not sample_copy.with_name("anders.de.txt").exists()
@ -144,7 +158,7 @@ def test_main_output_directory(sample_copy, monkeypatch, make_dir, suffix):
if make_dir:
out_dir.mkdir()
run_main(monkeypatch, "-i", sample_copy, second, "-o", f"{out_dir}{suffix}")
assert (out_dir / "ramstein.de.txt").is_file()
assert (out_dir / "beispiel.de.txt").is_file()
assert (out_dir / "rede.en.txt").is_file()
@ -176,7 +190,7 @@ def test_main_bad_file_continues_batch(sample_copy, monkeypatch, capsys, content
(["-i", "{txt}"], "Keine VTT-Datei"),
(["-i", "{vtt}", "{vtt2}", "-o", "{dir}/x.txt"], "muss -o/--output ein Verzeichnis sein"),
(["-i", "{dir}/*.gibtsnicht"], "Keine Datei passt zu"),
(["-i", "{vtt}", "{dir}/sub/ramstein.de.vtt", "-o", "{dir}/"], "dieselbe Ausgabedatei"),
(["-i", "{vtt}", "{dir}/sub/beispiel.de.vtt", "-o", "{dir}/"], "dieselbe Ausgabedatei"),
(["-i", "{vtt}", "-o", "{vtt}"], "nicht die Eingabedatei"),
])
def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
@ -366,10 +380,33 @@ def test_vtt_to_text_skips_empty_range(capsys):
assert text == "[00:01:40 - 00:01:41]\nErster Satz ist hier."
out = capsys.readouterr().out
assert "keine Untertitel im Bereich 00:00:00 – 00:00:50" in out
assert "keine Untertitel im Bereich 00:03:20 – Ende" in out
assert "keine Untertitel im Bereich 00:03:20 – Ende (die Untertitel enden bei" in out
assert vtt2txt.vtt_to_text(SPEECH, [(200, None)]) == ""
def test_vtt_to_text_notes_range_past_last_caption(capsys):
# "-z 0-05:00" means five hours, not five minutes
text = vtt2txt.vtt_to_text(SPEECH, [(0, 5 * 3600)])
assert text.startswith("[00:01:40 - ")
out = capsys.readouterr().out
assert "Bereich 00:00:00 – 05:00:00 reicht über das Ende der Untertitel hinaus" in out
assert "Stunden:Minuten, Minute 5 ist 00:05" in out
@pytest.mark.parametrize("start, end, expected", [
(100, 110, ""), # inside
(None, None, ""),
(100, 200, " (die Untertitel enden bei 00:02:00)"),
(150, None, " (die Untertitel enden bei 00:02:00)"),
(0, 3600, " (die Untertitel enden bei 00:02:00; zwei Zahlen wie 05:00 sind "
"Stunden:Minuten, Minute 5 ist 00:05)"),
(0, 200, ""), # no captions at all: no end known
])
def test_end_hint(start, end, expected):
captions_end = None if (start, end) == (0, 200) else 120
assert vtt2txt.end_hint(start, end, captions_end) == expected
def test_vtt_to_text_without_ranges_has_no_header():
assert vtt2txt.vtt_to_text(SPEECH).splitlines()[0] == "Erster Satz ist hier."
@ -394,7 +431,7 @@ def test_split_segments_inverts_join_segments():
def test_main_time_range_default_name(sample_copy, monkeypatch):
run_main(monkeypatch, "-i", sample_copy, "-z", "0:00:30-0:01")
target = sample_copy.with_name("ramstein.de_00-00-30_bis_00-01-00.txt")
target = sample_copy.with_name("beispiel.de_00-00-30_bis_00-01-00.txt")
full = SAMPLE_TXT.read_text(encoding="utf-8").splitlines()
part = target.read_text(encoding="utf-8").splitlines()
assert vtt2txt.HEADER.fullmatch(part[0])
@ -403,17 +440,17 @@ def test_main_time_range_default_name(sample_copy, monkeypatch):
def test_main_several_ranges(sample_copy, monkeypatch):
run_main(monkeypatch, "-i", sample_copy, "-z", "0:01-0:02 0:05")
target = sample_copy.with_name("ramstein.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
target = sample_copy.with_name("beispiel.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
segments = vtt2txt.split_segments(target.read_text(encoding="utf-8"))
assert [header for header, _ in segments] == [
"[00:00:56 - 00:02:09]", "[00:04:57 - 00:48:07]",
"[00:00:59 - 00:02:03]", "[00:04:58 - 00:13:10]",
]
@pytest.mark.parametrize("ranges", [["0:01-0:02 0:05"], ["0:01-0:02", "0:05"]])
def test_main_ranges_with_or_without_quotes(sample_copy, monkeypatch, ranges):
run_main(monkeypatch, "-i", sample_copy, "-z", *ranges)
target = sample_copy.with_name("ramstein.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
target = sample_copy.with_name("beispiel.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
assert target.is_file()
@ -433,8 +470,8 @@ def test_range_argv(argv, expected):
def test_main_range_from_beginning(sample_copy, monkeypatch, ranges):
args = ranges if ranges[0].startswith("-z") else ["-z", *ranges]
run_main(monkeypatch, "-i", sample_copy, *args)
target = sample_copy.with_name("ramstein.de_bis_00-01-00.txt")
assert target.read_text(encoding="utf-8").startswith("[00:00:09 - ")
target = sample_copy.with_name("beispiel.de_bis_00-01-00.txt")
assert target.read_text(encoding="utf-8").startswith("[00:00:00 - ")
def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys):

View file

@ -307,18 +307,42 @@ def range_label(start, end):
return f"{left} – {right}"
def end_hint(start, end, captions_end):
"""Note for a range reaching past the last caption, else ""; two numbers
are hours:minutes, so "05:00" for minute 5 is a likely slip."""
if captions_end is None:
return ""
past_end = (end is not None and end > captions_end) or (
start is not None and start >= captions_end
)
if not past_end:
return ""
hint = f" (die Untertitel enden bei {format_time(captions_end)}"
if max(start or 0, end or 0) >= 3600 > captions_end:
hint += "; zwei Zahlen wie 05:00 sind Stunden:Minuten, Minute 5 ist 00:05"
return hint + ")"
def excerpts(timed, ranges, exact=False):
"""Return (start, end in seconds, lines) of the text block of each time range.
Blocks that overlap after growing to whole sentences are merged; ranges
without captions are skipped with a note.
without captions are skipped with a note, ranges reaching past the last
caption get one.
"""
captions_end = max((end for _, end, _ in timed if end is not None), default=None)
bounds = []
for start, end in ranges:
found = excerpt_bounds(timed, start, end, exact)
hint = end_hint(start, end, captions_end)
if found is None:
print(f"Hinweis: keine Untertitel im Bereich {range_label(start, end)}")
print(f"Hinweis: keine Untertitel im Bereich {range_label(start, end)}{hint}")
continue
if hint:
print(
f"Hinweis: Bereich {range_label(start, end)} reicht über das Ende "
f"der Untertitel hinaus{hint}"
)
if bounds and found[0] < bounds[-1][1]:
bounds[-1] = (bounds[-1][0], max(bounds[-1][1], found[1]))
else: