Compare commits
12 changed files with 154 additions and 3663 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -12,11 +12,3 @@ __pycache__/
|
||||||
|
|
||||||
# Eigene Ergebnisse und Testläufe
|
# Eigene Ergebnisse und Testläufe
|
||||||
output/
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -149,16 +149,6 @@ Mit eigenem Dateinamen:
|
||||||
# → lahnstein.korrigiert.txt, lahnstein.txt (Rohtext), lahnstein.aenderungen.html
|
# → 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
|
## 4. Der Arbeitsablauf im Überblick
|
||||||
|
|
@ -223,10 +213,10 @@ versteht `abschrift_korrigieren.py`.
|
||||||
Soll die Original-Untertiteldatei (`.vtt`, mit Zeitstempeln) erhalten bleiben:
|
Soll die Original-Untertiteldatei (`.vtt`, mit Zeitstempeln) erhalten bleiben:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d untertitel
|
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten
|
||||||
# → untertitel/Lahnstein.de-orig.vtt
|
# → testdaten/Lahnstein.de-orig.vtt
|
||||||
./vtt2txt.py -i untertitel/Lahnstein.de-orig.vtt
|
./vtt2txt.py -i testdaten/Lahnstein.de-orig.vtt
|
||||||
# → untertitel/Lahnstein.de-orig.txt
|
# → testdaten/Lahnstein.de-orig.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Achtung: `yt_untertitel.py` nimmt ohne `-l` Deutsch (`de`), nicht die
|
Achtung: `yt_untertitel.py` nimmt ohne `-l` Deutsch (`de`), nicht die
|
||||||
|
|
@ -303,13 +293,9 @@ Namen und Hörfehler) plus eines **je Beitrag**:
|
||||||
```
|
```
|
||||||
|
|
||||||
Das gemeinsame Glossar zuerst angeben, damit seine Ersetzungen zuerst greifen.
|
Das gemeinsame Glossar zuerst angeben, damit seine Ersetzungen zuerst greifen.
|
||||||
|
Im Programmordner liegen Beispiele: `krone-schmalz.glossar.txt` (gemeinsam),
|
||||||
Glossare enthalten Namen realer Personen und Hörfehler realer Beiträge. Sie werden
|
`ramstein.glossar.txt`, `magdeburg.glossar.txt`, `lahnstein.glossar.txt`
|
||||||
deshalb nicht ins Git-Repository übernommen: Jede Datei `*.glossar.txt` ist über
|
(je eine Rede) und `iamfabian.glossar.txt` (Kanal über KI-Coding-Tools).
|
||||||
`.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`
|
### 6.3 Entwurf vom Modell: `glossar_vorschlag.py`
|
||||||
|
|
||||||
|
|
@ -379,9 +365,7 @@ 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
|
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.“,
|
am Satzende, und Abschnitt für Abschnitt korrigiert. Abkürzungen wie „z. B.“,
|
||||||
„Dr.“, „bzw.“ und Ordnungszahlen wie „2. Weltkrieg“ gelten dabei nicht als
|
„Dr.“, „bzw.“ und Ordnungszahlen wie „2. Weltkrieg“ gelten dabei nicht als
|
||||||
Satzende. Das Modell ist angewiesen, nichts zu ergänzen, auch keine Vornamen oder
|
Satzende.
|
||||||
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,
|
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).
|
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
|
6. **Absätze:** Das Modell nennt die Sätze, mit denen ein neuer Absatz beginnt; der
|
||||||
|
|
@ -415,11 +399,10 @@ Fertig: rede.korrigiert.txt
|
||||||
Änderungen gegenüber dem Rohtext: 82
|
Änderungen gegenüber dem Rohtext: 82
|
||||||
5× „Rammstein“ → „Ramstein“
|
5× „Rammstein“ → „Ramstein“
|
||||||
…
|
…
|
||||||
Zu prüfen: 20 Stellen (davon 2 Wortformen) ← diese Stellen ansehen!
|
Zu prüfen: 19 Stellen ← diese Stellen ansehen!
|
||||||
gültiges Wort ersetzt: „in“ → „dass“
|
gültiges Wort ersetzt: „in“ → „dass“
|
||||||
stark verändert: „OSola“ → „Ursula“
|
stark verändert: „OSola“ → „Ursula“
|
||||||
…
|
…
|
||||||
Wortform geändert: „sehe“ → „sähe“ ← Wortformen am Ende
|
|
||||||
Bericht: rede.aenderungen.html
|
Bericht: rede.aenderungen.html
|
||||||
|
|
||||||
19 Stelle(n) zu prüfen, siehe rede.aenderungen.html
|
19 Stelle(n) zu prüfen, siehe rede.aenderungen.html
|
||||||
|
|
@ -461,8 +444,7 @@ xdg-open rede.aenderungen.html
|
||||||
1. **Kopfzeile:** Wortzahlen, Anzahl der Ersetzungen, Einfügungen, Streichungen,
|
1. **Kopfzeile:** Wortzahlen, Anzahl der Ersetzungen, Einfügungen, Streichungen,
|
||||||
Prüfmarken und Stellen „zu prüfen“.
|
Prüfmarken und Stellen „zu prüfen“.
|
||||||
2. **Zu prüfen:** Tabelle der verdächtigen Änderungen mit Grund, Text vorher,
|
2. **Zu prüfen:** Tabelle der verdächtigen Änderungen mit Grund, Text vorher,
|
||||||
korrigiertem Text und Zusammenhang. **Hier beginnen.** Darunter in einer eigenen
|
korrigiertem Text und Zusammenhang. **Hier beginnen.**
|
||||||
Tabelle die bloßen Wortformen („Wortform geändert“).
|
|
||||||
3. **Alle Änderungen nach Häufigkeit:** jede Wortänderung mit Anzahl, z. B.
|
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
|
`5 Rammstein → Ramstein`. Gut, um wiederkehrende Hörfehler fürs Glossar zu
|
||||||
finden.
|
finden.
|
||||||
|
|
@ -475,7 +457,6 @@ xdg-open rede.aenderungen.html
|
||||||
| rot durchgestrichen | gestrichenes Wort |
|
| rot durchgestrichen | gestrichenes Wort |
|
||||||
| orange `[prüfen: …]` | Prüfmarke des Modells |
|
| orange `[prüfen: …]` | Prüfmarke des Modells |
|
||||||
| rot umrahmt | Stelle „zu prüfen“; beim Darüberfahren erscheint der Grund |
|
| 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 |
|
| rot umrahmtes ⚠ | hier hat das Modell eigenmächtig etwas gestrichen |
|
||||||
|
|
||||||
Groß-/Kleinschreibung, Satzzeichen und Schreibweisen wie „z.B.“ → „z. B.“ werden
|
Groß-/Kleinschreibung, Satzzeichen und Schreibweisen wie „z.B.“ → „z. B.“ werden
|
||||||
|
|
@ -489,23 +470,19 @@ Sinn verändern können. Was die Glossare ersetzt haben, erscheint hier nie.
|
||||||
| Grund | Bedeutung | Beispiel |
|
| 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“ |
|
| 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“ |
|
| stark verändert | Das neue Wort ähnelt dem alten kaum (unter 60 %). | „CT1“ → „ZDF“ |
|
||||||
| eingefügt | Das Modell hat ein Wort ergänzt. | „∅“ → „ist“ |
|
| 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“ → „∅“ |
|
| 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“ →
|
Nicht gemeldet werden ähnlich bleibende Hörfehler-Korrekturen („Gradwanderung“ →
|
||||||
„Gratwanderung“), reine Zusammen-, Getrennt- und Bindestrich-Schreibung („Clan
|
„Gratwanderung“), reine Zusammen-, Getrennt- und Bindestrich-Schreibung („Clan
|
||||||
Kriminalität“ → „Clankriminalität“) und Schreibweisen aus dem Glossar („Bar“ →
|
Kriminalität“ → „Clankriminalität“) und Wörter, die im Glossar stehen.
|
||||||
„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:**
|
**So arbeitet man die Liste ab:**
|
||||||
|
|
||||||
1. Für jede Zeile den Zusammenhang lesen.
|
1. Für jede Zeile den Zusammenhang lesen.
|
||||||
2. Ist die Änderung richtig, nichts tun. Bei den Wortformen am Ende ist das der
|
2. Ist die Änderung richtig (oft bei Grammatik: „das“ → „dass“, „sehe“ → „sähe“),
|
||||||
Normalfall; dort genügt ein kurzer Blick.
|
nichts tun.
|
||||||
3. Ist sie falsch, im korrigierten Text zurückändern. Tritt der Fehler öfter auf
|
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).
|
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
|
4. Bei „gestrichen“ prüfen, ob wirklich etwas fehlt: Oft entfernt das Modell
|
||||||
|
|
@ -544,22 +521,9 @@ neu, was sich geändert hat:
|
||||||
- Die Absatzgliederung wird für geänderte Textstellen ebenfalls neu bestimmt; das
|
- Die Absatzgliederung wird für geänderte Textstellen ebenfalls neu bestimmt; das
|
||||||
geht schnell.
|
geht schnell.
|
||||||
|
|
||||||
**Handarbeit bleibt erhalten.** Wurde `rede.korrigiert.txt` seit dem letzten Lauf
|
Von Hand nachbearbeitete `*.korrigiert.txt` werden bei einem erneuten Lauf
|
||||||
von Hand geändert, überschreibt ein erneuter Lauf sie nicht. Das neue Ergebnis kommt
|
**überschrieben**. Handarbeit deshalb erst ganz am Ende machen oder die Datei vorher
|
||||||
dann nach `rede.neu.korrigiert.txt` (Bericht: `rede.neu.aenderungen.html`), mit
|
unter anderem Namen sichern.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -579,8 +543,6 @@ Option kennen `extract_text.py` und `vtt2txt.py`.
|
||||||
|
|
||||||
> **Vorsicht:** `05:00` bedeutet 5 Stunden, nicht 5 Minuten. Für „5 Minuten“
|
> **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`.
|
> `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
|
### 10.2 Bereiche
|
||||||
|
|
||||||
|
|
@ -653,7 +615,7 @@ Beispiel für `-i rede.txt` bzw. `-o rede.korrigiert.txt`:
|
||||||
| Datei | Inhalt | Wird überschrieben? |
|
| Datei | Inhalt | Wird überschrieben? |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `rede.txt` | Rohtext (bei `-u` automatisch gespeichert) | nie, wenn sie schon mit anderem Inhalt existiert (dann Warnung) |
|
| `rede.txt` | Rohtext (bei `-u` automatisch gespeichert) | nie, wenn sie schon mit anderem Inhalt existiert (dann Warnung) |
|
||||||
| `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.korrigiert.txt` | korrigierte Abschrift | ja, bei jedem Lauf |
|
||||||
| `rede.aenderungen.html` | Änderungsbericht | ja, bei jedem Lauf |
|
| `rede.aenderungen.html` | Änderungsbericht | ja, bei jedem Lauf |
|
||||||
| `rede.korrigiert.parts/` | Zwischenstände | wird ergänzt; darf jederzeit gelöscht werden |
|
| `rede.korrigiert.parts/` | Zwischenstände | wird ergänzt; darf jederzeit gelöscht werden |
|
||||||
| `rede.glossar.entwurf.txt` | Glossar-Entwurf von `glossar_vorschlag.py` | ja |
|
| `rede.glossar.entwurf.txt` | Glossar-Entwurf von `glossar_vorschlag.py` | ja |
|
||||||
|
|
@ -728,14 +690,12 @@ Weitere Einstellungen (Abschnittsgröße, Absatzlängen, Schwellen) sind in der
|
||||||
| `Die Eingabedatei ist leer.` | Datei prüfen. |
|
| `Die Eingabedatei ist leer.` | Datei prüfen. |
|
||||||
| `Ausgabedatei darf nicht die Eingabedatei sein.` | Mit `-o` einen anderen Namen wählen. |
|
| `Ausgabedatei darf nicht die Eingabedatei sein.` | Mit `-o` einen anderen Namen wählen. |
|
||||||
| `Glossar nicht gefunden: …` | Pfad bei `-g` prüfen. |
|
| `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: 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. |
|
| `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). |
|
| `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. |
|
| `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 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. In Klammern steht, wo die Untertitel enden. |
|
| `Hinweis: keine Untertitel im Bereich …` | Einer von mehreren Bereichen ist leer und wird übersprungen. |
|
||||||
| `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. |
|
| `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). |
|
| 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). |
|
||||||
|
|
||||||
|
|
@ -800,7 +760,6 @@ einfügen.
|
||||||
| `-z BEREICH …` | nur diese Zeitbereiche (mit `.vtt` oder `-u`) |
|
| `-z BEREICH …` | nur diese Zeitbereiche (mit `.vtt` oder `-u`) |
|
||||||
| `--exakt` | genau an den Zeiten schneiden |
|
| `--exakt` | genau an den Zeiten schneiden |
|
||||||
| `--kein-bericht` | keinen Änderungsbericht schreiben |
|
| `--kein-bericht` | keinen Änderungsbericht schreiben |
|
||||||
| `--ueberschreiben` | von Hand geänderte Ausgabedatei trotzdem ersetzen |
|
|
||||||
|
|
||||||
### `extract_text.py`
|
### `extract_text.py`
|
||||||
|
|
||||||
|
|
|
||||||
128
README.md
128
README.md
|
|
@ -58,9 +58,9 @@ Oder in zwei Schritten, um den Rohtext vorher anzusehen:
|
||||||
Dasselbe in Einzelschritten, wenn die VTT-Datei erhalten bleiben soll:
|
Dasselbe in Einzelschritten, wenn die VTT-Datei erhalten bleiben soll:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d untertitel
|
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten
|
||||||
./vtt2txt.py -i untertitel/Lahnstein.de-orig.vtt
|
./vtt2txt.py -i testdaten/Lahnstein.de-orig.vtt
|
||||||
./abschrift_korrigieren.py -i untertitel/Lahnstein.de-orig.txt
|
./abschrift_korrigieren.py -i testdaten/Lahnstein.de-orig.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Schritt über `vtt2txt.py` ist optional: `abschrift_korrigieren.py` nimmt auch die
|
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
|
```bash
|
||||||
./extract_text.py -u 'https://www.youtube.com/watch?v=0LmCveTM788'
|
./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
|
# → John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30.txt
|
||||||
./extract_text.py -u URL -l de -o texte/rede.txt # Sprache und Ziel
|
./extract_text.py -u URL -l de -o testdaten/rede.txt # Sprache und Ziel
|
||||||
./extract_text.py -u URL -f -o rede.txt # lesbarer Fließtext
|
./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 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
|
./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
|
```bash
|
||||||
./yt_untertitel.py -u URL # → <Kanal>_<Titel>_<Datum>.de-orig.vtt
|
./yt_untertitel.py -u URL # → <Kanal>_<Titel>_<Datum>.de-orig.vtt
|
||||||
./yt_untertitel.py -u URL -n Lahnstein -d untertitel # → untertitel/Lahnstein.de-orig.vtt
|
./yt_untertitel.py -u URL -n Lahnstein -d testdaten # → testdaten/Lahnstein.de-orig.vtt
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Bedeutung |
|
| Option | Bedeutung |
|
||||||
|
|
@ -182,9 +182,7 @@ Gilt gleich für `vtt2txt.py`, `extract_text.py` und `abschrift_korrigieren.py`.
|
||||||
| `25` | Minute 25 |
|
| `25` | Minute 25 |
|
||||||
|
|
||||||
Achtung: Zwei Zahlen sind Stunden:Minuten, `05:00` ist also Stunde 5, nicht
|
Achtung: Zwei Zahlen sind Stunden:Minuten, `05:00` ist also Stunde 5, nicht
|
||||||
Minute 5 (dafür `00:05` oder `5`). Reicht ein Bereich über die letzte
|
Minute 5 (dafür `00:05` oder `5`).
|
||||||
Untertitelzeile hinaus, nennt ein Hinweis das Ende der Untertitel, bei Bereichen ab
|
|
||||||
einer Stunde mit diesem Tipp.
|
|
||||||
|
|
||||||
| Aufruf | Text |
|
| Aufruf | Text |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -249,12 +247,6 @@ 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) |
|
| `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen; mehrfach angeben zum Kombinieren (Ersetzungen in Reihenfolge der Angabe) |
|
||||||
| `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) |
|
| `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) |
|
||||||
| `--kein-bericht` | keinen Änderungsbericht schreiben |
|
| `--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;
|
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
|
die Überschriften `[00:04:57 - 00:06:04]` bekommt das Modell nicht zu sehen, sie
|
||||||
|
|
@ -274,9 +266,7 @@ Blöcken korrigiert.
|
||||||
3. **Korrektur:** Der Text wird in Abschnitte von ca. 1200 Wörtern geteilt (immer
|
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
|
am Satzende; Abkürzungen wie „z. B.“, „Dr.“, „bzw.“ und Ordnungszahlen wie
|
||||||
„2. Weltkrieg“ zählen nicht als Satzende) und abschnittsweise vom Modell
|
„2. Weltkrieg“ zählen nicht als Satzende) und abschnittsweise vom Modell
|
||||||
korrigiert. Fortschritt wird live angezeigt. Der Prompt verbietet Ergänzungen,
|
korrigiert. Fortschritt wird live angezeigt.
|
||||||
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,
|
4. **Wortzahl-Prüfung:** Weicht die Wortzahl eines Abschnitts um mehr als 5 % ab,
|
||||||
gibt es eine Warnung (mögliche Auslassungen oder Ergänzungen).
|
gibt es eine Warnung (mögliche Auslassungen oder Ergänzungen).
|
||||||
5. **Absätze:** Das Modell nennt nur Satznummern, an denen ein Absatz beginnt; der
|
5. **Absätze:** Das Modell nennt nur Satznummern, an denen ein Absatz beginnt; der
|
||||||
|
|
@ -321,24 +311,15 @@ Ersetzungen des Glossars), mit seinem Ergebnis. Gemeldet werden:
|
||||||
| Grund | Beispiel |
|
| Grund | Beispiel |
|
||||||
|---|---|
|
|---|---|
|
||||||
| gültiges Wort ersetzt | „Professor“ → „Professorin“, „unterschreiben“ → „unterscheiden“ (das alte Wort steht in der Wortliste, war also kein Hörfehler) |
|
| 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 %) |
|
| stark verändert | „CT1“ → „ZDF“ (Ähnlichkeit unter 60 %) |
|
||||||
| eingefügt | „∅“ → „ist“ |
|
| eingefügt | „∅“ → „ist“ |
|
||||||
| gestrichen | „Sicherungskonzepte geht“ → „∅“ (Füllwörter wie „äh“ nicht) |
|
| gestrichen | „Sicherungskonzepte geht“ → „∅“ (Füllwörter wie „äh“ nicht) |
|
||||||
|
|
||||||
Nicht gemeldet werden Hörfehler-Korrekturen, die ähnlich bleiben
|
Nicht gemeldet werden Hörfehler-Korrekturen, die ähnlich bleiben
|
||||||
(„Gradwanderung“ → „Gratwanderung“), reine Zusammen-, Getrennt- und
|
(„Gradwanderung“ → „Gratwanderung“), reine Zusammen-, Getrennt- und
|
||||||
Bindestrich-Schreibung („Clan Kriminalität“ → „Clankriminalität“) und Schreibweisen
|
Bindestrich-Schreibung („Clan Kriminalität“ → „Clankriminalität“) und neue Wörter,
|
||||||
aus dem Glossar („Bar“ → „Bahr“ bei „Egon Bahr“ im Glossar): Die neuen Wörter müssen
|
die im Glossar stehen. Die Liste erscheint auch in der Konsole. Kleine
|
||||||
zusammenhängend in einem Glossareintrag stehen und dem alten Text ähneln. Einzelne
|
Grammatik-Korrekturen wie „das“ → „dass“ tauchen ebenfalls auf; ein Blick genügt.
|
||||||
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
|
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:
|
der Korrektur gelten deren Ersetzungen und Begriffe nicht als verdächtig:
|
||||||
|
|
@ -369,14 +350,11 @@ einzelnen Beitrag deutet das auf einen Tippfehler hin, bei einem gemeinsamen Glo
|
||||||
es normal.
|
es normal.
|
||||||
|
|
||||||
Mehrere Glossare lassen sich kombinieren: ein gemeinsames für wiederkehrende Namen
|
Mehrere Glossare lassen sich kombinieren: ein gemeinsames für wiederkehrende Namen
|
||||||
und Hörfehler eines Redners oder Kanals und eines je Beitrag, z. B.
|
und Hörfehler eines Redners oder Kanals und eines je Beitrag. Beispiel zu den Testdaten:
|
||||||
`krone-schmalz.glossar.txt` (gemeinsam) plus `ramstein.glossar.txt`. Das gemeinsame
|
`krone-schmalz.glossar.txt` (gemeinsam) plus `ramstein.glossar.txt`,
|
||||||
Glossar zuerst angeben, damit seine Ersetzungen vor den speziellen greifen.
|
`magdeburg.glossar.txt` bzw. `lahnstein.glossar.txt`. Das gemeinsame Glossar zuerst angeben, damit seine
|
||||||
|
Ersetzungen vor den speziellen greifen. Ein weiteres Kanal-Glossar:
|
||||||
Glossare gehören zu realen Beiträgen und Personen und werden deshalb nicht
|
`iamfabian.glossar.txt` für Videos über KI-Coding-Tools (Claude Code, Codex, OpenCode …).
|
||||||
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`
|
### Glossar-Entwurf: `glossar_vorschlag.py`
|
||||||
|
|
||||||
|
|
@ -401,9 +379,9 @@ erneut vorgeschlagen. Zeit-Überschriften von Ausschnitten
|
||||||
Roh- und korrigiertem Text entfernt; das Modell bekommt sie nicht zu sehen.
|
Roh- und korrigiertem Text entfernt; das Modell bekommt sie nicht zu sehen.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./glossar_vorschlag.py -i rede.txt -k rede.korrigiert.txt -g krone-schmalz.glossar.txt
|
./glossar_vorschlag.py -i testdaten/Lahnstein.de-orig.txt \
|
||||||
# → rede.glossar.entwurf.txt
|
-k testdaten/Lahnstein.de-orig.korrigiert.txt -g krone-schmalz.glossar.txt
|
||||||
./glossar_vorschlag.py -i tests/daten/beispiel.de.txt # an der Beispielrede ausprobieren
|
# → testdaten/Lahnstein.de-orig.glossar.entwurf.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Bedeutung |
|
| Option | Bedeutung |
|
||||||
|
|
@ -420,31 +398,24 @@ werden in `<Entwurf>.parts/` zwischengespeichert.
|
||||||
|
|
||||||
## Testdaten
|
## Testdaten
|
||||||
|
|
||||||
**Im Repository:** `tests/daten/` enthält eine frei erfundene Beispielrede
|
`testdaten/` enthält drei Reden, jeweils als automatische YouTube-Untertitel
|
||||||
(Festrede zum Jubiläum der Gemeinde „Auental“; Ort und Personen gibt es nicht), als
|
(`.vtt`, Original) und daraus mit `vtt2txt.py` gewonnener Rohtext (`.txt`, Eingabe
|
||||||
Untertitel im Format der YouTube-Auto-Untertitel (`beispiel.de.vtt`, rollendes
|
für das Programm):
|
||||||
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`)
|
- `ramstein.de.*`: Rede über die US Air Base Ramstein (Glossar `ramstein.glossar.txt`)
|
||||||
- `magdeburg.de-orig.*`: Rede in Magdeburg (Glossar `magdeburg.glossar.txt`)
|
- `magdeburg.de-orig.*`: Rede in Magdeburg (Glossar `magdeburg.glossar.txt`)
|
||||||
- `Lahnstein.de-orig.*`: Rede „Respekt geht anders“ in Lahnstein (Glossar
|
- `Lahnstein.de-orig.*`: Rede „Respekt geht anders“ in Lahnstein (Glossar
|
||||||
`lahnstein.glossar.txt`, erstellt mit Hilfe von `glossar_vorschlag.py`), zu laden mit
|
`lahnstein.glossar.txt`, erstellt mit Hilfe von `glossar_vorschlag.py`)
|
||||||
`./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. Ein Lauf über eine
|
Alle drei mit dem gemeinsamen `krone-schmalz.glossar.txt` davor.
|
||||||
Rede dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa 6–8 Minuten.
|
|
||||||
|
```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.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|
@ -452,36 +423,27 @@ Rede dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa 6–8 Minuten.
|
||||||
python3 -m pytest
|
python3 -m pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Tests brauchen weder Ollama noch Netz: Modellaufrufe und `yt-dlp` werden durch
|
Die Tests (291) brauchen weder Ollama noch Netz: Modellaufrufe und `yt-dlp` werden
|
||||||
Fakes ersetzt. Als Text dient die erfundene Beispielrede in `tests/daten/` (siehe
|
durch Fakes ersetzt. `tests/test_abschrift_korrigieren.py` prüft Abschnittsbildung,
|
||||||
[Testdaten](#testdaten)). In einem frischen Klon laufen 318 Tests, 7 werden
|
Satzgrenzen (Abkürzungen und Ordnungszahlen sind kein Satzende), Glossar (inkl.
|
||||||
übersprungen; sie prüfen die Glossare der echten Reden und laufen nur, wenn diese
|
„jede Ersetzung im Glossar einer Rede trifft diese Rede, jede im gemeinsamen Glossar
|
||||||
lokal in `testdaten/` liegen (dann 325).
|
trifft mindestens eine“), Streaming-Auswertung, Absatzgliederung (Text bleibt
|
||||||
|
unverändert, 3–10 Sätze), Verbinden kurzer Absätze (Modell wird nur gefragt, wenn
|
||||||
`tests/test_abschrift_korrigieren.py` prüft Abschnittsbildung, Satzgrenzen
|
beide Nachbarn passen), Zwischenspeicher/Fortsetzen, Zeitauswahl, Sprachen und die
|
||||||
(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.
|
Kommandozeile.
|
||||||
`tests/test_glossar_vorschlag.py` prüft Filter (nur Funde im Text, keine bekannten),
|
`tests/test_glossar_vorschlag.py` prüft Filter (nur Funde im Text, keine bekannten),
|
||||||
Ausgabeformat (gültiges Glossar, Unsicheres auskommentiert) und Zwischenspeicher.
|
Ausgabeformat (gültiges Glossar, Unsicheres auskommentiert) und Zwischenspeicher.
|
||||||
`tests/test_aenderungen.py` prüft den Wortvergleich (Satzzeichen, Groß-/Kleinschreibung
|
`tests/test_aenderungen.py` prüft den Wortvergleich (Satzzeichen, Groß-/Kleinschreibung
|
||||||
und „z. B.“ zählen nicht), die Liste „Zu prüfen“ (Gründe, Wortformen, Füllwörter je
|
und „z. B.“ zählen nicht), die Liste „Zu prüfen“ (Gründe, Ausnahmen, Glossar,
|
||||||
Sprache, Glossar-Schreibweisen, fehlende Wortliste) und den HTML-Bericht
|
fehlende Wortliste) und den HTML-Bericht (vollständiger Text, Absätze, Escaping).
|
||||||
(vollständiger Text, Absätze, Escaping, Reihenfolge).
|
|
||||||
Die Tests nutzen eine kleine eigene Wortliste statt der des Systems.
|
Die Tests nutzen eine kleine eigene Wortliste statt der des Systems.
|
||||||
`tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl
|
`tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl
|
||||||
(Original-Audio, manuell vor automatisch) und Fehlerfälle.
|
(Original-Audio, manuell vor automatisch) und Fehlerfälle.
|
||||||
`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und
|
`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und
|
||||||
Fehlerfälle (ohne Netz, `yt-dlp` wird durch Fakes ersetzt).
|
Fehlerfälle (ohne Netz, `yt-dlp` wird durch Fakes ersetzt).
|
||||||
`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: die Beispiel-`.vtt` (und lokal
|
`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: jede `.vtt` in `testdaten/`
|
||||||
jede Rede in `testdaten/`) ergibt genau die zugehörige `.txt`, echte Wiederholungen
|
ergibt genau die zugehörige `.txt`, echte Wiederholungen bleiben erhalten, auch bei
|
||||||
bleiben erhalten, auch bei Untertiteln ohne rollendes Fenster, dazu Zeitauswahl
|
Untertiteln ohne rollendes Fenster, dazu Zeitauswahl, Muster, Ausgabeverzeichnis und
|
||||||
(mit Hinweis bei Bereichen über das Ende hinaus), Muster, Ausgabeverzeichnis und
|
|
||||||
Fehlerfälle.
|
Fehlerfälle.
|
||||||
|
|
||||||
## Zwischenstände
|
## Zwischenstände
|
||||||
|
|
@ -516,9 +478,7 @@ In `aenderungen.py`:
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `WORDLISTS` | `/usr/share/dict/ngerman`, `…/american-english` | Wortlisten je Sprache |
|
| `WORDLISTS` | `/usr/share/dict/ngerman`, `…/american-english` | Wortlisten je Sprache |
|
||||||
| `MIN_SIMILARITY` | 0.6 | darunter gilt eine Ersetzung als „stark verändert“ |
|
| `MIN_SIMILARITY` | 0.6 | darunter gilt eine Ersetzung als „stark verändert“ |
|
||||||
| `FILLERS` | je Sprache: äh, ähm, …; uh, um, er, … | Füllwörter, deren Streichung nicht gemeldet wird |
|
| `FILLERS` | äh, ähm, … | 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`:
|
In `vtt2txt.py`:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from aenderungen import (
|
from aenderungen import (
|
||||||
REVIEW_MARK, glossary_entries, print_summary, report_path, write_report,
|
REVIEW_MARK, glossary_words, print_summary, report_path, write_report,
|
||||||
)
|
)
|
||||||
from extract_text import fetch_text
|
from extract_text import fetch_text
|
||||||
from vtt2txt import (
|
from vtt2txt import (
|
||||||
|
|
@ -33,7 +33,6 @@ MAX_WORD_DEVIATION = 0.05 # ab dieser Abweichung der Wortzahl wird gewarnt
|
||||||
PARAGRAPH_WINDOW = 150 # Sätze pro Anfrage bei der Absatzgliederung
|
PARAGRAPH_WINDOW = 150 # Sätze pro Anfrage bei der Absatzgliederung
|
||||||
MAX_PARAGRAPH_SENTENCES = 10 # längere Absätze werden nachträglich geteilt
|
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
|
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_END = re.compile(r"[.!?…][\"'»«“”)\]]*$")
|
||||||
SENTENCE_SPLIT = re.compile(r"(?:(?<=[.!?…])|(?<=[.!?…][\"'»«“”)\]]))\s+")
|
SENTENCE_SPLIT = re.compile(r"(?:(?<=[.!?…])|(?<=[.!?…][\"'»«“”)\]]))\s+")
|
||||||
|
|
@ -58,9 +57,6 @@ Keine Zusammenfassung und keine stilistische Neufassung.
|
||||||
- Entferne nur eindeutig technisch verursachte Untertitel-Dopplungen.
|
- Entferne nur eindeutig technisch verursachte Untertitel-Dopplungen.
|
||||||
- Bewahre rhetorische Wiederholungen und Selbstkorrekturen.
|
- Bewahre rhetorische Wiederholungen und Selbstkorrekturen.
|
||||||
- Ergänze keine Informationen und keine fehlenden Sätze.
|
- 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.
|
- Glätte keine Formulierungen allein aus stilistischen Gründen.
|
||||||
- Ändere Zahlen, Daten und Zitate nur bei eindeutigen Fehlern.
|
- Ändere Zahlen, Daten und Zitate nur bei eindeutigen Fehlern.
|
||||||
- Übernimm Geräuschhinweise in eckigen Klammern wie [applaus] unverändert.
|
- Übernimm Geräuschhinweise in eckigen Klammern wie [applaus] unverändert.
|
||||||
|
|
@ -127,9 +123,6 @@ No summary and no stylistic rewrite.
|
||||||
speaker-change marker ">>".
|
speaker-change marker ">>".
|
||||||
- Keep rhetorical repetitions and self-corrections.
|
- Keep rhetorical repetitions and self-corrections.
|
||||||
- Do not add information or missing sentences.
|
- 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.
|
- Do not smooth phrasing for stylistic reasons alone.
|
||||||
- Change numbers, dates and quotations only if they are clearly wrong.
|
- Change numbers, dates and quotations only if they are clearly wrong.
|
||||||
- Keep sound tags in square brackets such as [music] unchanged.
|
- Keep sound tags in square brackets such as [music] unchanged.
|
||||||
|
|
@ -187,10 +180,7 @@ PROMPTS = {
|
||||||
"merge": MERGE_SYSTEM,
|
"merge": MERGE_SYSTEM,
|
||||||
"glossary": "Glossar: korrekte Schreibweisen von Namen und Begriffen, die in "
|
"glossary": "Glossar: korrekte Schreibweisen von Namen und Begriffen, die in "
|
||||||
"diesem Beitrag vorkommen. Falsch erkannte Varianten davon in der Abschrift "
|
"diesem Beitrag vorkommen. Falsch erkannte Varianten davon in der Abschrift "
|
||||||
"stillschweigend korrigieren. Korrigiere nur die Schreibweise dessen, was "
|
"stillschweigend korrigieren:",
|
||||||
"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 "
|
"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 "
|
"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 "
|
"ergänze kein Wort, auch nicht in einer Markierung; lass einen unvollständigen "
|
||||||
|
|
@ -203,10 +193,7 @@ PROMPTS = {
|
||||||
"merge": MERGE_SYSTEM_EN,
|
"merge": MERGE_SYSTEM_EN,
|
||||||
"glossary": "Glossary: correct spellings of names and terms that occur in "
|
"glossary": "Glossary: correct spellings of names and terms that occur in "
|
||||||
"this recording. Silently correct misrecognized variants of them in the "
|
"this recording. Silently correct misrecognized variants of them in the "
|
||||||
"transcript. Correct only the spelling of what was said: do not add first "
|
"transcript:",
|
||||||
"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 "
|
"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 "
|
"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 "
|
"add any word, not even in a mark; leave an incomplete beginning or ending "
|
||||||
|
|
@ -240,7 +227,7 @@ def build_system(glossary, lang=DEFAULT_LANGUAGE, excerpt=False):
|
||||||
if not glossary:
|
if not glossary:
|
||||||
return system
|
return system
|
||||||
terms = "\n".join(f"- {term}" for term in glossary)
|
terms = "\n".join(f"- {term}" for term in glossary)
|
||||||
return f"{system}\n{prompts['glossary']}\n\n{terms}\n"
|
return f"{system}\n{prompts['glossary']}\n{terms}\n"
|
||||||
|
|
||||||
|
|
||||||
def read_glossary(path):
|
def read_glossary(path):
|
||||||
|
|
@ -648,11 +635,6 @@ def parse_args():
|
||||||
"--kein-bericht", action="store_true",
|
"--kein-bericht", action="store_true",
|
||||||
help="keinen Änderungsbericht <Ausgabe>.aenderungen.html schreiben",
|
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)
|
add_time_options(parser)
|
||||||
args = parser.parse_args(range_argv(sys.argv[1:]))
|
args = parser.parse_args(range_argv(sys.argv[1:]))
|
||||||
resolve_time_ranges(parser, args)
|
resolve_time_ranges(parser, args)
|
||||||
|
|
@ -720,35 +702,6 @@ def read_source(args):
|
||||||
return raw, args.output, base_language(args.lang or DEFAULT_LANGUAGE)
|
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():
|
def main():
|
||||||
global MODEL
|
global MODEL
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
@ -841,25 +794,14 @@ def main():
|
||||||
corrected_blocks.append((header, paragraphs))
|
corrected_blocks.append((header, paragraphs))
|
||||||
|
|
||||||
text = join_segments(corrected_blocks, header_gap="\n\n")
|
text = join_segments(corrected_blocks, header_gap="\n\n")
|
||||||
content = text + "\n"
|
target.write_text(text + "\n", encoding="utf-8")
|
||||||
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}")
|
print(f"Fertig: {target}")
|
||||||
|
|
||||||
if not args.kein_bericht:
|
if not args.kein_bericht:
|
||||||
report = report_path(target)
|
report = report_path(target)
|
||||||
comparison = write_report(
|
comparison = write_report(
|
||||||
raw_text, text, report, target.name, model_input=model_text,
|
raw_text, text, report, target.name, model_input=model_text,
|
||||||
glossary=glossary_entries(terms, replacements), lang=lang,
|
known=glossary_words(terms, replacements), lang=lang,
|
||||||
)
|
)
|
||||||
print_summary(comparison)
|
print_summary(comparison)
|
||||||
print(f"Bericht: {report}")
|
print(f"Bericht: {report}")
|
||||||
|
|
|
||||||
141
aenderungen.py
141
aenderungen.py
|
|
@ -10,7 +10,7 @@ import argparse
|
||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from vtt2txt import split_segments, vtt_to_lines
|
from vtt2txt import vtt_to_lines
|
||||||
|
|
||||||
# "[prüfen: …]" in German transcripts, "[check: …]" in English ones
|
# "[prüfen: …]" in German transcripts, "[check: …]" in English ones
|
||||||
REVIEW_MARK = re.compile(r"\[(?:prüfen|check):[^\]]*\]")
|
REVIEW_MARK = re.compile(r"\[(?:prüfen|check):[^\]]*\]")
|
||||||
|
|
@ -30,25 +30,13 @@ WORDLISTS = {
|
||||||
}
|
}
|
||||||
WORDLIST_PACKAGES = {"de": "wngerman", "en": "wamerican"}
|
WORDLIST_PACKAGES = {"de": "wngerman", "en": "wamerican"}
|
||||||
MIN_SIMILARITY = 0.6 # below this a replacement counts as heavily changed
|
MIN_SIMILARITY = 0.6 # below this a replacement counts as heavily changed
|
||||||
# Per language: "er" and "um" are fillers in English, real words in German
|
FILLERS = {"äh", "ähm", "öh", "öhm", "hm", "hmm", "uh", "uhm", "um", "er", "erm"}
|
||||||
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
|
CONTEXT_WORDS = 6
|
||||||
|
|
||||||
VALID_WORD = "gültiges Wort ersetzt"
|
VALID_WORD = "gültiges Wort ersetzt"
|
||||||
CHANGED_A_LOT = "stark verändert"
|
CHANGED_A_LOT = "stark verändert"
|
||||||
INSERTED = "eingefügt"
|
INSERTED = "eingefügt"
|
||||||
DELETED = "gestrichen"
|
DELETED = "gestrichen"
|
||||||
WORD_FORM = "Wortform geändert"
|
|
||||||
|
|
||||||
|
|
||||||
def add_token(words, token):
|
def add_token(words, token):
|
||||||
|
|
@ -144,10 +132,10 @@ def load_wordlist(lang):
|
||||||
return {line.strip().lower() for line in words if line.strip()}
|
return {line.strip().lower() for line in words if line.strip()}
|
||||||
|
|
||||||
|
|
||||||
def glossary_entries(terms, replacements):
|
def glossary_words(terms, replacements):
|
||||||
"""Glossary terms and replacement targets as tuples of normalized words."""
|
"""Normalized words of glossary terms and replacement targets."""
|
||||||
texts = list(terms) + [right for _, right in replacements]
|
texts = list(terms) + [right for _, right in replacements]
|
||||||
return {tuple(normalize(word) for word in text.split()) for text in texts} - {()}
|
return {normalize(word) for text in texts for word in text.split()}
|
||||||
|
|
||||||
|
|
||||||
def spelling_key(words):
|
def spelling_key(words):
|
||||||
|
|
@ -155,65 +143,33 @@ def spelling_key(words):
|
||||||
return re.sub(r"[\W_]+", "", "".join(words).lower())
|
return re.sub(r"[\W_]+", "", "".join(words).lower())
|
||||||
|
|
||||||
|
|
||||||
def similarity(old, new):
|
def review_candidates(comparison, wordlist=None, known=()):
|
||||||
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.
|
"""Changes the model made on its own that may alter the meaning.
|
||||||
|
|
||||||
Not reported: changes of spacing, hyphens and punctuation only, glossary
|
Not reported: changes of spacing, hyphens and punctuation only, new words
|
||||||
spellings of the old words, deleted filler words, and replacements of
|
from the glossary, deleted filler words, and replacements of words not in
|
||||||
words not in the word list (recognition errors) that stay similar.
|
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 = []
|
suspects = []
|
||||||
for tag, i1, i2, j1, j2 in comparison.opcodes:
|
for tag, i1, i2, j1, j2 in comparison.opcodes:
|
||||||
if tag == "equal":
|
if tag == "equal":
|
||||||
continue
|
continue
|
||||||
old, new = comparison.raw[i1:i2], comparison.words[j1:j2]
|
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 tag == "delete":
|
||||||
if all(normalize(word) in fillers for word in old):
|
if all(normalize(word) in FILLERS for word in old):
|
||||||
continue
|
continue
|
||||||
reason = DELETED
|
reason = DELETED
|
||||||
elif tag == "insert":
|
elif tag == "insert":
|
||||||
reason = INSERTED
|
reason = INSERTED
|
||||||
else:
|
else:
|
||||||
if spelling_key(old) == spelling_key(new) or glossary_spelling(old, new, glossary):
|
old_key, new_key = spelling_key(old), spelling_key(new)
|
||||||
|
if old_key == new_key:
|
||||||
continue
|
continue
|
||||||
if wordlist is not None and all(normalize(w) in wordlist for w in old):
|
if wordlist is not None and all(normalize(w) in wordlist for w in old):
|
||||||
reason = WORD_FORM if word_form_change(old, new, lang) else VALID_WORD
|
reason = VALID_WORD
|
||||||
elif similarity(old, new) < MIN_SIMILARITY:
|
elif SequenceMatcher(None, old_key, new_key).ratio() < MIN_SIMILARITY:
|
||||||
reason = CHANGED_A_LOT
|
reason = CHANGED_A_LOT
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
@ -249,9 +205,6 @@ del { background: var(--del); }
|
||||||
.orig { color: var(--muted); font-size: 0.8em; }
|
.orig { color: var(--muted); font-size: 0.8em; }
|
||||||
.check { background: var(--check); font-size: 0.85em; }
|
.check { background: var(--check); font-size: 0.85em; }
|
||||||
.warn { outline: 2px solid var(--warn); outline-offset: 1px; border-radius: 2px; }
|
.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); }
|
td.ctx { color: var(--muted); } td.ctx b { color: var(--fg); }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -268,43 +221,28 @@ def render_html(comparison, title):
|
||||||
for old, new, count in summary
|
for old, new, count in summary
|
||||||
)
|
)
|
||||||
suspects = comparison.suspects
|
suspects = comparison.suspects
|
||||||
important, forms = split_suspects(suspects)
|
suspect_rows = "\n".join(
|
||||||
|
|
||||||
def table(rows):
|
|
||||||
body = "\n".join(
|
|
||||||
f"<tr><td>{esc(s.reason)}</td><td><del>{esc(s.old) or EMPTY}</del></td>"
|
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><ins>{esc(s.new) or EMPTY}</ins></td>"
|
||||||
f"<td class=ctx>{suspect_context(comparison, s)}</td></tr>"
|
f"<td class=ctx>{suspect_context(comparison, s)}</td></tr>"
|
||||||
for s in rows
|
for s in suspects
|
||||||
)
|
)
|
||||||
return (
|
if suspects:
|
||||||
"<div class=scroll><table><tr><th>Grund</th><th>vorher</th><th>korrigiert</th>"
|
suspect_table = (
|
||||||
f"<th>Zusammenhang</th></tr>\n{body}\n</table></div>"
|
"<table><tr><th>Grund</th><th>vorher</th><th>korrigiert</th>"
|
||||||
|
f"<th>Zusammenhang</th></tr>\n{suspect_rows}\n</table>"
|
||||||
)
|
)
|
||||||
|
|
||||||
if important:
|
|
||||||
suspect_table = table(important)
|
|
||||||
elif forms:
|
|
||||||
suspect_table = "<p class=muted>Keine sinnverändernden Änderungen gefunden.</p>"
|
|
||||||
else:
|
else:
|
||||||
suspect_table = "<p class=muted>Keine auffälligen Änderungen gefunden.</p>"
|
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
|
# Suspect words are framed; a suspect deletion shows ⚠ after the word before it
|
||||||
warn_words, warn_after = {}, {}
|
warn_words, warn_after = {}, {}
|
||||||
for s in suspects:
|
for s in suspects:
|
||||||
note = esc(f"{s.reason}: „{s.old or EMPTY}“ → „{s.new or EMPTY}“", quote=True)
|
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:
|
if s.j1 == s.j2:
|
||||||
warn_after.setdefault(max(s.j1 - 1, 0), []).append(note)
|
warn_after.setdefault(max(s.j1 - 1, 0), []).append(note)
|
||||||
for j in range(s.j1, s.j2):
|
for j in range(s.j1, s.j2):
|
||||||
warn_words[j] = (css, note)
|
warn_words[j] = note
|
||||||
|
|
||||||
# Corrected text with its paragraphs; each word knows its paragraph
|
# Corrected text with its paragraphs; each word knows its paragraph
|
||||||
parts = []
|
parts = []
|
||||||
|
|
@ -316,8 +254,7 @@ def render_html(comparison, title):
|
||||||
current = comparison.paragraph[j]
|
current = comparison.paragraph[j]
|
||||||
parts.append("</p>\n<p>")
|
parts.append("</p>\n<p>")
|
||||||
if j in warn_words:
|
if j in warn_words:
|
||||||
css, note = warn_words[j]
|
piece = f"<span class=warn title='{warn_words[j]}'>{piece}</span>"
|
||||||
piece = f"<span class='{css}' title='{note}'>{piece}</span>"
|
|
||||||
parts.append(piece)
|
parts.append(piece)
|
||||||
for mark in comparison.marks.get(j, []):
|
for mark in comparison.marks.get(j, []):
|
||||||
parts.append(f"<span class=check>{esc(mark)}</span>")
|
parts.append(f"<span class=check>{esc(mark)}</span>")
|
||||||
|
|
@ -370,12 +307,6 @@ 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):
|
def suspect_context(comparison, suspect):
|
||||||
"""HTML: the change in bold with a few corrected words around it."""
|
"""HTML: the change in bold with a few corrected words around it."""
|
||||||
esc = html.escape
|
esc = html.escape
|
||||||
|
|
@ -397,18 +328,15 @@ def print_summary(comparison, limit=15, suspect_limit=10):
|
||||||
if len(summary) > limit:
|
if len(summary) > limit:
|
||||||
print(f" … und {len(summary) - limit} weitere, siehe Bericht")
|
print(f" … und {len(summary) - limit} weitere, siehe Bericht")
|
||||||
|
|
||||||
important, forms = split_suspects(comparison.suspects)
|
suspects = comparison.suspects
|
||||||
# Meaning changes first, word forms after them
|
print(f"Zu prüfen: {len(suspects)} Stellen")
|
||||||
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]:
|
for s in suspects[:suspect_limit]:
|
||||||
print(f" {s.reason}: „{s.old or EMPTY}“ → „{s.new or EMPTY}“")
|
print(f" {s.reason}: „{s.old or EMPTY}“ → „{s.new or EMPTY}“")
|
||||||
if len(suspects) > suspect_limit:
|
if len(suspects) > suspect_limit:
|
||||||
print(f" … und {len(suspects) - suspect_limit} weitere, siehe Bericht")
|
print(f" … und {len(suspects) - suspect_limit} weitere, siehe Bericht")
|
||||||
|
|
||||||
|
|
||||||
def write_report(raw, corrected, target, title, model_input=None, glossary=(), lang="de"):
|
def write_report(raw, corrected, target, title, model_input=None, known=(), lang="de"):
|
||||||
"""Write the HTML report; return the comparison with the suspect changes.
|
"""Write the HTML report; return the comparison with the suspect changes.
|
||||||
|
|
||||||
model_input is the text the model got (raw text after glossary
|
model_input is the text the model got (raw text after glossary
|
||||||
|
|
@ -416,7 +344,7 @@ def write_report(raw, corrected, target, title, model_input=None, glossary=(), l
|
||||||
"""
|
"""
|
||||||
comparison = compare(raw, corrected)
|
comparison = compare(raw, corrected)
|
||||||
by_model = compare(raw if model_input is None else model_input, corrected)
|
by_model = compare(raw if model_input is None else model_input, corrected)
|
||||||
comparison.suspects = review_candidates(by_model, load_wordlist(lang), glossary, lang)
|
comparison.suspects = review_candidates(by_model, load_wordlist(lang), set(known))
|
||||||
target.write_text(render_html(comparison, title), encoding="utf-8")
|
target.write_text(render_html(comparison, title), encoding="utf-8")
|
||||||
return comparison
|
return comparison
|
||||||
|
|
||||||
|
|
@ -427,11 +355,6 @@ def report_path(corrected_path):
|
||||||
return corrected_path.with_name(f"{stem}.aenderungen.html")
|
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):
|
def read_raw(path):
|
||||||
text = path.read_text(encoding="utf-8-sig")
|
text = path.read_text(encoding="utf-8-sig")
|
||||||
if path.suffix.lower() == ".vtt":
|
if path.suffix.lower() == ".vtt":
|
||||||
|
|
@ -474,7 +397,6 @@ def parse_args():
|
||||||
def main():
|
def main():
|
||||||
# abschrift_korrigieren imports this module: import it only when needed
|
# abschrift_korrigieren imports this module: import it only when needed
|
||||||
from abschrift_korrigieren import apply_replacements, read_glossary
|
from abschrift_korrigieren import apply_replacements, read_glossary
|
||||||
from yt_untertitel import base_language
|
|
||||||
|
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
raw = read_raw(args.roh)
|
raw = read_raw(args.roh)
|
||||||
|
|
@ -487,13 +409,10 @@ def main():
|
||||||
model_input = apply_replacements(" ".join(raw.split()), replacements)
|
model_input = apply_replacements(" ".join(raw.split()), replacements)
|
||||||
|
|
||||||
corrected = args.korrigiert.read_text(encoding="utf-8")
|
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(
|
comparison = write_report(
|
||||||
raw, corrected, args.output, args.korrigiert.name,
|
raw, corrected, args.output, args.korrigiert.name,
|
||||||
model_input=model_input, glossary=glossary_entries(terms, replacements),
|
model_input=model_input, known=glossary_words(terms, replacements),
|
||||||
lang=base_language(args.lang),
|
lang=args.lang,
|
||||||
)
|
)
|
||||||
print_summary(comparison)
|
print_summary(comparison)
|
||||||
print(f"Bericht: {args.output}")
|
print(f"Bericht: {args.output}")
|
||||||
|
|
|
||||||
|
|
@ -1,335 +0,0 @@
|
||||||
[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]
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -11,11 +11,9 @@ import pytest
|
||||||
import abschrift_korrigieren as ak
|
import abschrift_korrigieren as ak
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
# Own, made-up sample speech; real speeches are only kept locally in testdaten/
|
SAMPLE = REPO / "testdaten" / "ramstein.de.txt"
|
||||||
SAMPLE = REPO / "tests" / "daten" / "beispiel.de.txt"
|
GLOSSARY = REPO / "ramstein.glossar.txt"
|
||||||
GLOSSARY = REPO / "tests" / "daten" / "beispiel.glossar.txt"
|
|
||||||
SHARED_GLOSSARY = REPO / "krone-schmalz.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
|
# The autouse fixture below replaces it; tests of the real function use this
|
||||||
REAL_FIND_MERGE_SIDE = ak.find_merge_side
|
REAL_FIND_MERGE_SIDE = ak.find_merge_side
|
||||||
|
|
||||||
|
|
@ -119,10 +117,6 @@ def test_build_system_appends_terms():
|
||||||
system = ak.build_system(["Kreml", "Egon Bahr"])
|
system = ak.build_system(["Kreml", "Egon Bahr"])
|
||||||
assert system.startswith(ak.SYSTEM)
|
assert system.startswith(ak.SYSTEM)
|
||||||
assert "- Kreml\n- Egon Bahr" in 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 = [
|
SAMPLE_GLOSSARIES = [
|
||||||
|
|
@ -132,37 +126,19 @@ 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)
|
@pytest.mark.parametrize("glossary, sample", SAMPLE_GLOSSARIES)
|
||||||
def test_sample_glossary_replacements_all_match(capsys, glossary, sample):
|
def test_sample_glossary_replacements_all_match(capsys, glossary, sample):
|
||||||
"""Every rule in a speech glossary must hit its speech, after the shared one."""
|
"""Every rule in a speech glossary must hit its speech, after the shared one."""
|
||||||
text = flat((SPEECHES / sample).read_text(encoding="utf-8"))
|
text = flat((REPO / "testdaten" / sample).read_text(encoding="utf-8"))
|
||||||
text = ak.apply_replacements(text, ak.read_glossary(SHARED_GLOSSARY)[1])
|
text = ak.apply_replacements(text, ak.read_glossary(SHARED_GLOSSARY)[1])
|
||||||
capsys.readouterr()
|
capsys.readouterr()
|
||||||
ak.apply_replacements(text, ak.read_glossary(REPO / glossary)[1])
|
ak.apply_replacements(text, ak.read_glossary(REPO / glossary)[1])
|
||||||
assert "ohne Treffer" not in capsys.readouterr().out
|
assert "ohne Treffer" not in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
@needs_speeches
|
|
||||||
def test_shared_glossary_rules_match_some_speech():
|
def test_shared_glossary_rules_match_some_speech():
|
||||||
texts = [
|
texts = [
|
||||||
flat((SPEECHES / sample).read_text(encoding="utf-8"))
|
flat((REPO / "testdaten" / sample).read_text(encoding="utf-8"))
|
||||||
for _, sample in SAMPLE_GLOSSARIES
|
for _, sample in SAMPLE_GLOSSARIES
|
||||||
]
|
]
|
||||||
for wrong, right in ak.read_glossary(SHARED_GLOSSARY)[1]:
|
for wrong, right in ak.read_glossary(SHARED_GLOSSARY)[1]:
|
||||||
|
|
@ -412,7 +388,7 @@ def test_build_paragraphs_asks_where_short_paragraphs_belong(tmp_path, monkeypat
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_copy(tmp_path):
|
def sample_copy(tmp_path):
|
||||||
path = tmp_path / "beispiel.de.txt"
|
path = tmp_path / "ramstein.de.txt"
|
||||||
shutil.copy(SAMPLE, path)
|
shutil.copy(SAMPLE, path)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
@ -420,92 +396,22 @@ def sample_copy(tmp_path):
|
||||||
def test_main_default_output_resume_and_glossary(sample_copy, monkeypatch, capsys):
|
def test_main_default_output_resume_and_glossary(sample_copy, monkeypatch, capsys):
|
||||||
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||||
run_main(monkeypatch, "-i", sample_copy, "-g", GLOSSARY)
|
run_main(monkeypatch, "-i", sample_copy, "-g", SHARED_GLOSSARY, "-g", GLOSSARY)
|
||||||
|
|
||||||
target = sample_copy.with_name("beispiel.de.korrigiert.txt")
|
target = sample_copy.with_name("ramstein.de.korrigiert.txt")
|
||||||
result = target.read_text(encoding="utf-8")
|
result = target.read_text(encoding="utf-8")
|
||||||
assert "Auental" in result and "Auen Tal" not in result
|
assert "Ramstein" in result and "Rammstein" not in result
|
||||||
assert "Haberkamp" in result and "1274" in result
|
assert "Kreml" in result and "1983" in result
|
||||||
assert (sample_copy.parent / "beispiel.de.korrigiert.parts").is_dir()
|
assert (sample_copy.parent / "ramstein.de.korrigiert.parts").is_dir()
|
||||||
assert "WARNUNG" not in capsys.readouterr().out
|
assert "WARNUNG" not in capsys.readouterr().out
|
||||||
|
|
||||||
# Second run: everything cached, no model calls
|
# Second run: everything cached, no model calls
|
||||||
monkeypatch.setattr(ak, "correct_chunk", no_model_calls)
|
monkeypatch.setattr(ak, "correct_chunk", no_model_calls)
|
||||||
monkeypatch.setattr(ak, "find_paragraph_starts", no_model_calls)
|
monkeypatch.setattr(ak, "find_paragraph_starts", no_model_calls)
|
||||||
run_main(monkeypatch, "-i", sample_copy, "-g", GLOSSARY)
|
run_main(monkeypatch, "-i", sample_copy, "-g", SHARED_GLOSSARY, "-g", GLOSSARY)
|
||||||
assert target.read_text(encoding="utf-8") == result
|
assert target.read_text(encoding="utf-8") == result
|
||||||
|
|
||||||
|
|
||||||
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):
|
def test_main_output_option_creates_directories(sample_copy, monkeypatch):
|
||||||
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||||
|
|
@ -518,36 +424,36 @@ def test_main_output_option_creates_directories(sample_copy, monkeypatch):
|
||||||
def test_main_vtt_input_matches_txt_input(sample_copy, monkeypatch):
|
def test_main_vtt_input_matches_txt_input(sample_copy, monkeypatch):
|
||||||
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||||
vtt = sample_copy.parent / "vtt" / "beispiel.de.vtt"
|
vtt = sample_copy.parent / "vtt" / "ramstein.de.vtt"
|
||||||
vtt.parent.mkdir()
|
vtt.parent.mkdir()
|
||||||
shutil.copy(SAMPLE.with_suffix(".vtt"), vtt)
|
shutil.copy(SAMPLE.with_suffix(".vtt"), vtt)
|
||||||
|
|
||||||
run_main(monkeypatch, "-i", sample_copy)
|
run_main(monkeypatch, "-i", sample_copy)
|
||||||
run_main(monkeypatch, "-i", vtt)
|
run_main(monkeypatch, "-i", vtt)
|
||||||
expected = sample_copy.with_name("beispiel.de.korrigiert.txt")
|
expected = sample_copy.with_name("ramstein.de.korrigiert.txt")
|
||||||
assert vtt.with_name("beispiel.de.korrigiert.txt").read_text(
|
assert vtt.with_name("ramstein.de.korrigiert.txt").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
) == expected.read_text(encoding="utf-8")
|
) == expected.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_main_writes_change_report(sample_copy, monkeypatch, capsys):
|
def test_main_writes_change_report(sample_copy, monkeypatch, capsys):
|
||||||
def correct(chunk, index, system):
|
def correct(chunk, index, system):
|
||||||
return chunk.strip().replace("Linden Brunn", "Lindenbrunn")
|
return chunk.strip().replace("Rammstein", "Ramstein")
|
||||||
|
|
||||||
monkeypatch.setattr(ak, "correct_chunk", correct)
|
monkeypatch.setattr(ak, "correct_chunk", correct)
|
||||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||||
run_main(monkeypatch, "-i", sample_copy)
|
run_main(monkeypatch, "-i", sample_copy)
|
||||||
|
|
||||||
report = sample_copy.with_name("beispiel.de.aenderungen.html")
|
report = sample_copy.with_name("ramstein.de.aenderungen.html")
|
||||||
assert "title='Original: Linden Brunn" in report.read_text(encoding="utf-8")
|
assert "title='Original: Rammstein" in report.read_text(encoding="utf-8")
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "„Linden Brunn“ → „Lindenbrunn“" in out
|
assert "„Rammstein“ → „Ramstein“" in out
|
||||||
assert f"Bericht: {report}" in out
|
assert f"Bericht: {report}" in out
|
||||||
|
|
||||||
|
|
||||||
def test_main_report_suspects_only_model_changes(sample_copy, monkeypatch, capsys):
|
def test_main_report_suspects_only_model_changes(sample_copy, monkeypatch, capsys):
|
||||||
glossary = sample_copy.with_name("g.txt")
|
glossary = sample_copy.with_name("g.txt")
|
||||||
glossary.write_text("Linden Brunn => Lindenbrunn\n", encoding="utf-8")
|
glossary.write_text("Rammstein => Ramstein\n", encoding="utf-8")
|
||||||
|
|
||||||
def correct(chunk, index, system):
|
def correct(chunk, index, system):
|
||||||
return chunk.strip().replace(" das ", " dies ", 1)
|
return chunk.strip().replace(" das ", " dies ", 1)
|
||||||
|
|
@ -556,9 +462,9 @@ def test_main_report_suspects_only_model_changes(sample_copy, monkeypatch, capsy
|
||||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||||
run_main(monkeypatch, "-i", sample_copy, "-g", glossary)
|
run_main(monkeypatch, "-i", sample_copy, "-g", glossary)
|
||||||
|
|
||||||
page = sample_copy.with_name("beispiel.de.aenderungen.html").read_text(encoding="utf-8")
|
page = sample_copy.with_name("ramstein.de.aenderungen.html").read_text(encoding="utf-8")
|
||||||
section = page.split("<h2>Zu prüfen")[1].split("<h2>Alle Änderungen")[0]
|
section = page.split("<h2>Zu prüfen")[1].split("<h2>Alle Änderungen")[0]
|
||||||
assert "Linden Brunn" in page and "Linden Brunn" not in section
|
assert "Rammstein" in page and "Rammstein" not in section
|
||||||
assert "dies" in section
|
assert "dies" in section
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "gültiges Wort ersetzt: „das“ → „dies“" in out
|
assert "gültiges Wort ersetzt: „das“ → „dies“" in out
|
||||||
|
|
@ -569,7 +475,7 @@ def test_main_without_change_report(sample_copy, monkeypatch):
|
||||||
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||||
run_main(monkeypatch, "-i", sample_copy, "--kein-bericht")
|
run_main(monkeypatch, "-i", sample_copy, "--kein-bericht")
|
||||||
assert not sample_copy.with_name("beispiel.de.aenderungen.html").exists()
|
assert not sample_copy.with_name("ramstein.de.aenderungen.html").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_main_model_option(sample_copy, monkeypatch):
|
def test_main_model_option(sample_copy, monkeypatch):
|
||||||
|
|
@ -582,7 +488,7 @@ def test_main_model_option(sample_copy, monkeypatch):
|
||||||
|
|
||||||
monkeypatch.setattr(ak, "correct_chunk", correct)
|
monkeypatch.setattr(ak, "correct_chunk", correct)
|
||||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||||
parts = sample_copy.parent / "beispiel.de.korrigiert.parts"
|
parts = sample_copy.parent / "ramstein.de.korrigiert.parts"
|
||||||
|
|
||||||
run_main(monkeypatch, "-i", sample_copy)
|
run_main(monkeypatch, "-i", sample_copy)
|
||||||
default_parts = {p.name for p in parts.glob("part*")}
|
default_parts = {p.name for p in parts.glob("part*")}
|
||||||
|
|
@ -625,16 +531,9 @@ def from_url(fake_yt_dlp, yt_dlp_installed, monkeypatch):
|
||||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
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):
|
def test_main_url_default_names(from_url, tmp_path, monkeypatch):
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
run_main(monkeypatch, "-u", URL, "-g", kreml_glossary(tmp_path))
|
run_main(monkeypatch, "-u", URL, "-g", SHARED_GLOSSARY)
|
||||||
|
|
||||||
raw = (tmp_path / "Kanal_Rede_2026-03-28.txt").read_text(encoding="utf-8")
|
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"
|
assert raw == "Der Kremmel ist in Moskau.\nDas ist bekannt.\n"
|
||||||
|
|
@ -677,7 +576,6 @@ def test_build_system_english():
|
||||||
system = ak.build_system(["Mearsheimer"], "en")
|
system = ak.build_system(["Mearsheimer"], "en")
|
||||||
assert system.startswith(ak.SYSTEM_EN)
|
assert system.startswith(ak.SYSTEM_EN)
|
||||||
assert "Glossary:" in system and "- Mearsheimer" in system
|
assert "Glossary:" in system and "- Mearsheimer" in system
|
||||||
assert "do not add first" in system
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_english_prompts(sample_copy, monkeypatch, capsys):
|
def test_main_english_prompts(sample_copy, monkeypatch, capsys):
|
||||||
|
|
@ -924,7 +822,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))
|
monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=MULTI_VTT))
|
||||||
sent = chunk_recorder(monkeypatch)
|
sent = chunk_recorder(monkeypatch)
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-g", kreml_glossary(tmp_path))
|
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-g", SHARED_GLOSSARY)
|
||||||
|
|
||||||
name = "Kanal_Rede_2026-03-28_bis_00-00-30+ab_00-02-00"
|
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") == (
|
assert (tmp_path / f"{name}.txt").read_text(encoding="utf-8") == (
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,8 @@ def test_cli_missing_file(tmp_path, monkeypatch, capsys):
|
||||||
WORDS = {"professor", "das", "dass", "ist", "haus", "unterschreiben", "bar"}
|
WORDS = {"professor", "das", "dass", "ist", "haus", "unterschreiben", "bar"}
|
||||||
|
|
||||||
|
|
||||||
def suspects(model_input, corrected, wordlist=WORDS, glossary=(), lang="de"):
|
def suspects(model_input, corrected, wordlist=WORDS, known=()):
|
||||||
found = ae.review_candidates(ae.compare(model_input, corrected), wordlist, glossary, lang)
|
found = ae.review_candidates(ae.compare(model_input, corrected), wordlist, set(known))
|
||||||
return [(s.reason, s.old, s.new) for s in found]
|
return [(s.reason, s.old, s.new) for s in found]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -153,37 +153,13 @@ def test_review_candidates_ignores(model_input, corrected):
|
||||||
assert suspects(model_input, corrected) == []
|
assert suspects(model_input, corrected) == []
|
||||||
|
|
||||||
|
|
||||||
def test_review_candidates_glossary_spellings_are_not_reported():
|
def test_review_candidates_glossary_words_are_known():
|
||||||
glossary = ae.glossary_entries(["Egon Bahr", ""], [("Bars", "Bahrs")])
|
known = ae.glossary_words(["Egon Bahr"], [("Bars", "Bahrs")])
|
||||||
assert glossary == {("egon", "bahr"), ("bahrs",)}
|
assert known == {"egon", "bahr", "bahrs"}
|
||||||
assert suspects("Bar sagte", "Bahr sagte", glossary=glossary) == []
|
assert suspects("Bar sagte", "Bahr sagte", known=known) == []
|
||||||
assert suspects("Bar sagte", "Bahr sagte") == [(ae.VALID_WORD, "Bar", "Bahr")]
|
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():
|
def test_review_candidates_without_wordlist():
|
||||||
# Similar replacements of real words go unnoticed, the rest still counts
|
# Similar replacements of real words go unnoticed, the rest still counts
|
||||||
assert suspects("Der Professor kam", "Der Professorin kam", wordlist=None) == []
|
assert suspects("Der Professor kam", "Der Professorin kam", wordlist=None) == []
|
||||||
|
|
@ -222,7 +198,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 [s.reason for s in comparison.suspects] == [ae.VALID_WORD, ae.DELETED]
|
||||||
assert "Zu prüfen (2)" in page
|
assert "Zu prüfen (2)" in page
|
||||||
assert "Professorin" in section and "Kremmel" not in section
|
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
|
assert "<span class=warn title='gestrichen: „heute“ → „∅“'>⚠</span>" in page
|
||||||
# Framing changes nothing about the text itself
|
# Framing changes nothing about the text itself
|
||||||
assert report_text(page) == "Der Professorin im Kreml ⚠ dort.".split()
|
assert report_text(page) == "Der Professorin im Kreml ⚠ dort.".split()
|
||||||
|
|
@ -240,20 +216,6 @@ def test_report_without_suspects(tmp_path):
|
||||||
assert "Keine auffälligen Änderungen" in suspect_section(target.read_text(encoding="utf-8"))
|
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):
|
def test_cli_glossary(tmp_path, monkeypatch, capsys):
|
||||||
raw = tmp_path / "rede.txt"
|
raw = tmp_path / "rede.txt"
|
||||||
raw.write_text("das Kremmel und\nBar heute\n", encoding="utf-8")
|
raw.write_text("das Kremmel und\nBar heute\n", encoding="utf-8")
|
||||||
|
|
@ -273,51 +235,3 @@ def test_cli_glossary(tmp_path, monkeypatch, capsys):
|
||||||
assert "Zu prüfen: 0 Stellen" in out
|
assert "Zu prüfen: 0 Stellen" in out
|
||||||
# The report still compares against the raw transcript
|
# The report still compares against the raw transcript
|
||||||
assert "1× „Kremmel“ → „Kreml“" in out
|
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“")
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,8 @@ import pytest
|
||||||
import vtt2txt
|
import vtt2txt
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
# Own, made-up sample speech; real speeches are only kept locally in testdaten/
|
SAMPLE_VTT = REPO / "testdaten" / "ramstein.de.vtt"
|
||||||
SAMPLE_VTT = REPO / "tests" / "daten" / "beispiel.de.vtt"
|
SAMPLE_TXT = REPO / "testdaten" / "ramstein.de.txt"
|
||||||
SAMPLE_TXT = REPO / "tests" / "daten" / "beispiel.de.txt"
|
|
||||||
SPEECHES = REPO / "testdaten"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -109,27 +107,15 @@ def test_is_rolling(cues, expected):
|
||||||
|
|
||||||
def test_main_default_output_matches_sample(sample_copy, monkeypatch, capsys):
|
def test_main_default_output_matches_sample(sample_copy, monkeypatch, capsys):
|
||||||
run_main(monkeypatch, "-i", sample_copy)
|
run_main(monkeypatch, "-i", sample_copy)
|
||||||
target = sample_copy.with_name("beispiel.de.txt")
|
target = sample_copy.with_name("ramstein.de.txt")
|
||||||
assert target.read_text(encoding="utf-8") == SAMPLE_TXT.read_text(encoding="utf-8")
|
assert target.read_text(encoding="utf-8") == SAMPLE_TXT.read_text(encoding="utf-8")
|
||||||
assert f"Erstellt: {target}" in capsys.readouterr().out
|
assert f"Erstellt: {target}" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
def speech(name):
|
@pytest.mark.parametrize("name", ["ramstein.de", "magdeburg.de-orig", "Lahnstein.de-orig"])
|
||||||
"""A real speech, only kept locally (copyright): skipped without it."""
|
def test_sample_vtt_gives_sample_txt(name):
|
||||||
path = SPEECHES / name
|
vtt = (REPO / "testdaten" / f"{name}.vtt").read_text(encoding="utf-8-sig")
|
||||||
return pytest.param(path, marks=pytest.mark.skipif(
|
txt = (REPO / "testdaten" / f"{name}.txt").read_text(encoding="utf-8")
|
||||||
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()
|
assert vtt2txt.vtt_to_lines(vtt) == txt.splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -142,9 +128,9 @@ def test_main_multiple_inputs(sample_copy, monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_main_expands_glob_without_duplicates(sample_copy, monkeypatch, capsys):
|
def test_main_expands_glob_without_duplicates(sample_copy, monkeypatch, capsys):
|
||||||
shutil.copy(sample_copy, sample_copy.with_name("beispiel.en.vtt"))
|
shutil.copy(sample_copy, sample_copy.with_name("ramstein.en.vtt"))
|
||||||
shutil.copy(sample_copy, sample_copy.with_name("anders.de.vtt"))
|
shutil.copy(sample_copy, sample_copy.with_name("anders.de.vtt"))
|
||||||
run_main(monkeypatch, "-i", sample_copy.parent / "beispiel.*.vtt", sample_copy)
|
run_main(monkeypatch, "-i", sample_copy.parent / "ramstein.*.vtt", sample_copy)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert out.count("Erstellt:") == 2
|
assert out.count("Erstellt:") == 2
|
||||||
assert not sample_copy.with_name("anders.de.txt").exists()
|
assert not sample_copy.with_name("anders.de.txt").exists()
|
||||||
|
|
@ -158,7 +144,7 @@ def test_main_output_directory(sample_copy, monkeypatch, make_dir, suffix):
|
||||||
if make_dir:
|
if make_dir:
|
||||||
out_dir.mkdir()
|
out_dir.mkdir()
|
||||||
run_main(monkeypatch, "-i", sample_copy, second, "-o", f"{out_dir}{suffix}")
|
run_main(monkeypatch, "-i", sample_copy, second, "-o", f"{out_dir}{suffix}")
|
||||||
assert (out_dir / "beispiel.de.txt").is_file()
|
assert (out_dir / "ramstein.de.txt").is_file()
|
||||||
assert (out_dir / "rede.en.txt").is_file()
|
assert (out_dir / "rede.en.txt").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -190,7 +176,7 @@ def test_main_bad_file_continues_batch(sample_copy, monkeypatch, capsys, content
|
||||||
(["-i", "{txt}"], "Keine VTT-Datei"),
|
(["-i", "{txt}"], "Keine VTT-Datei"),
|
||||||
(["-i", "{vtt}", "{vtt2}", "-o", "{dir}/x.txt"], "muss -o/--output ein Verzeichnis sein"),
|
(["-i", "{vtt}", "{vtt2}", "-o", "{dir}/x.txt"], "muss -o/--output ein Verzeichnis sein"),
|
||||||
(["-i", "{dir}/*.gibtsnicht"], "Keine Datei passt zu"),
|
(["-i", "{dir}/*.gibtsnicht"], "Keine Datei passt zu"),
|
||||||
(["-i", "{vtt}", "{dir}/sub/beispiel.de.vtt", "-o", "{dir}/"], "dieselbe Ausgabedatei"),
|
(["-i", "{vtt}", "{dir}/sub/ramstein.de.vtt", "-o", "{dir}/"], "dieselbe Ausgabedatei"),
|
||||||
(["-i", "{vtt}", "-o", "{vtt}"], "nicht die Eingabedatei"),
|
(["-i", "{vtt}", "-o", "{vtt}"], "nicht die Eingabedatei"),
|
||||||
])
|
])
|
||||||
def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
|
def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
|
||||||
|
|
@ -380,33 +366,10 @@ def test_vtt_to_text_skips_empty_range(capsys):
|
||||||
assert text == "[00:01:40 - 00:01:41]\nErster Satz ist hier."
|
assert text == "[00:01:40 - 00:01:41]\nErster Satz ist hier."
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "keine Untertitel im Bereich 00:00:00 – 00:00:50" in out
|
assert "keine Untertitel im Bereich 00:00:00 – 00:00:50" in out
|
||||||
assert "keine Untertitel im Bereich 00:03:20 – Ende (die Untertitel enden bei" in out
|
assert "keine Untertitel im Bereich 00:03:20 – Ende" in out
|
||||||
assert vtt2txt.vtt_to_text(SPEECH, [(200, None)]) == ""
|
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():
|
def test_vtt_to_text_without_ranges_has_no_header():
|
||||||
assert vtt2txt.vtt_to_text(SPEECH).splitlines()[0] == "Erster Satz ist hier."
|
assert vtt2txt.vtt_to_text(SPEECH).splitlines()[0] == "Erster Satz ist hier."
|
||||||
|
|
||||||
|
|
@ -431,7 +394,7 @@ def test_split_segments_inverts_join_segments():
|
||||||
|
|
||||||
def test_main_time_range_default_name(sample_copy, monkeypatch):
|
def test_main_time_range_default_name(sample_copy, monkeypatch):
|
||||||
run_main(monkeypatch, "-i", sample_copy, "-z", "0:00:30-0:01")
|
run_main(monkeypatch, "-i", sample_copy, "-z", "0:00:30-0:01")
|
||||||
target = sample_copy.with_name("beispiel.de_00-00-30_bis_00-01-00.txt")
|
target = sample_copy.with_name("ramstein.de_00-00-30_bis_00-01-00.txt")
|
||||||
full = SAMPLE_TXT.read_text(encoding="utf-8").splitlines()
|
full = SAMPLE_TXT.read_text(encoding="utf-8").splitlines()
|
||||||
part = target.read_text(encoding="utf-8").splitlines()
|
part = target.read_text(encoding="utf-8").splitlines()
|
||||||
assert vtt2txt.HEADER.fullmatch(part[0])
|
assert vtt2txt.HEADER.fullmatch(part[0])
|
||||||
|
|
@ -440,17 +403,17 @@ def test_main_time_range_default_name(sample_copy, monkeypatch):
|
||||||
|
|
||||||
def test_main_several_ranges(sample_copy, monkeypatch):
|
def test_main_several_ranges(sample_copy, monkeypatch):
|
||||||
run_main(monkeypatch, "-i", sample_copy, "-z", "0:01-0:02 0:05")
|
run_main(monkeypatch, "-i", sample_copy, "-z", "0:01-0:02 0:05")
|
||||||
target = sample_copy.with_name("beispiel.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
|
target = sample_copy.with_name("ramstein.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
|
||||||
segments = vtt2txt.split_segments(target.read_text(encoding="utf-8"))
|
segments = vtt2txt.split_segments(target.read_text(encoding="utf-8"))
|
||||||
assert [header for header, _ in segments] == [
|
assert [header for header, _ in segments] == [
|
||||||
"[00:00:59 - 00:02:03]", "[00:04:58 - 00:13:10]",
|
"[00:00:56 - 00:02:09]", "[00:04:57 - 00:48:07]",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("ranges", [["0:01-0:02 0:05"], ["0:01-0:02", "0:05"]])
|
@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):
|
def test_main_ranges_with_or_without_quotes(sample_copy, monkeypatch, ranges):
|
||||||
run_main(monkeypatch, "-i", sample_copy, "-z", *ranges)
|
run_main(monkeypatch, "-i", sample_copy, "-z", *ranges)
|
||||||
target = sample_copy.with_name("beispiel.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
|
target = sample_copy.with_name("ramstein.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
|
||||||
assert target.is_file()
|
assert target.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -470,8 +433,8 @@ def test_range_argv(argv, expected):
|
||||||
def test_main_range_from_beginning(sample_copy, monkeypatch, ranges):
|
def test_main_range_from_beginning(sample_copy, monkeypatch, ranges):
|
||||||
args = ranges if ranges[0].startswith("-z") else ["-z", *ranges]
|
args = ranges if ranges[0].startswith("-z") else ["-z", *ranges]
|
||||||
run_main(monkeypatch, "-i", sample_copy, *args)
|
run_main(monkeypatch, "-i", sample_copy, *args)
|
||||||
target = sample_copy.with_name("beispiel.de_bis_00-01-00.txt")
|
target = sample_copy.with_name("ramstein.de_bis_00-01-00.txt")
|
||||||
assert target.read_text(encoding="utf-8").startswith("[00:00:00 - ")
|
assert target.read_text(encoding="utf-8").startswith("[00:00:09 - ")
|
||||||
|
|
||||||
|
|
||||||
def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys):
|
def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys):
|
||||||
|
|
|
||||||
28
vtt2txt.py
28
vtt2txt.py
|
|
@ -307,42 +307,18 @@ def range_label(start, end):
|
||||||
return f"{left} – {right}"
|
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):
|
def excerpts(timed, ranges, exact=False):
|
||||||
"""Return (start, end in seconds, lines) of the text block of each time range.
|
"""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
|
Blocks that overlap after growing to whole sentences are merged; ranges
|
||||||
without captions are skipped with a note, ranges reaching past the last
|
without captions are skipped with a note.
|
||||||
caption get one.
|
|
||||||
"""
|
"""
|
||||||
captions_end = max((end for _, end, _ in timed if end is not None), default=None)
|
|
||||||
bounds = []
|
bounds = []
|
||||||
for start, end in ranges:
|
for start, end in ranges:
|
||||||
found = excerpt_bounds(timed, start, end, exact)
|
found = excerpt_bounds(timed, start, end, exact)
|
||||||
hint = end_hint(start, end, captions_end)
|
|
||||||
if found is None:
|
if found is None:
|
||||||
print(f"Hinweis: keine Untertitel im Bereich {range_label(start, end)}{hint}")
|
print(f"Hinweis: keine Untertitel im Bereich {range_label(start, end)}")
|
||||||
continue
|
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]:
|
if bounds and found[0] < bounds[-1][1]:
|
||||||
bounds[-1] = (bounds[-1][0], max(bounds[-1][1], found[1]))
|
bounds[-1] = (bounds[-1][0], max(bounds[-1][1], found[1]))
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue