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
|
||||
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
|
||||
```
|
||||
|
||||
**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
|
||||
|
|
@ -223,10 +213,10 @@ versteht `abschrift_korrigieren.py`.
|
|||
Soll die Original-Untertiteldatei (`.vtt`, mit Zeitstempeln) erhalten bleiben:
|
||||
|
||||
```bash
|
||||
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d untertitel
|
||||
# → untertitel/Lahnstein.de-orig.vtt
|
||||
./vtt2txt.py -i untertitel/Lahnstein.de-orig.vtt
|
||||
# → untertitel/Lahnstein.de-orig.txt
|
||||
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten
|
||||
# → testdaten/Lahnstein.de-orig.vtt
|
||||
./vtt2txt.py -i testdaten/Lahnstein.de-orig.vtt
|
||||
# → testdaten/Lahnstein.de-orig.txt
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Glossare enthalten Namen realer Personen und Hörfehler realer Beiträge. Sie werden
|
||||
deshalb nicht ins Git-Repository übernommen: Jede Datei `*.glossar.txt` ist über
|
||||
`.gitignore` ausgeschlossen und bleibt nur auf dem eigenen Rechner. Das ist
|
||||
gewollt; Glossare lieber gesondert sichern. Einzige Ausnahme ist das kleine,
|
||||
leicht nachvollziehbare Glossar zur erfundenen Beispielrede in
|
||||
`tests/daten/beispiel.glossar.txt`, das sich gut als Vorlage eignet.
|
||||
Im Programmordner liegen Beispiele: `krone-schmalz.glossar.txt` (gemeinsam),
|
||||
`ramstein.glossar.txt`, `magdeburg.glossar.txt`, `lahnstein.glossar.txt`
|
||||
(je eine Rede) und `iamfabian.glossar.txt` (Kanal über KI-Coding-Tools).
|
||||
|
||||
### 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
|
||||
am Satzende, und Abschnitt für Abschnitt korrigiert. Abkürzungen wie „z. B.“,
|
||||
„Dr.“, „bzw.“ und Ordnungszahlen wie „2. Weltkrieg“ gelten dabei nicht als
|
||||
Satzende. Das Modell ist angewiesen, nichts zu ergänzen, auch keine Vornamen oder
|
||||
Titel aus dem Glossar: Steht dort „Alexander Dobrindt“, bleibt ein gesagtes
|
||||
„Dobrindt“ trotzdem „Dobrindt“.
|
||||
Satzende.
|
||||
5. **Wortzahl-Prüfung:** Weicht die Wortzahl eines Abschnitts um mehr als 5 % ab,
|
||||
warnt das Programm (das Modell könnte etwas ausgelassen oder ergänzt haben).
|
||||
6. **Absätze:** Das Modell nennt die Sätze, mit denen ein neuer Absatz beginnt; der
|
||||
|
|
@ -415,11 +399,10 @@ Fertig: rede.korrigiert.txt
|
|||
Änderungen gegenüber dem Rohtext: 82
|
||||
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“
|
||||
stark verändert: „OSola“ → „Ursula“
|
||||
…
|
||||
Wortform geändert: „sehe“ → „sähe“ ← Wortformen am Ende
|
||||
Bericht: rede.aenderungen.html
|
||||
|
||||
19 Stelle(n) zu prüfen, siehe rede.aenderungen.html
|
||||
|
|
@ -461,8 +444,7 @@ xdg-open rede.aenderungen.html
|
|||
1. **Kopfzeile:** Wortzahlen, Anzahl der Ersetzungen, Einfügungen, Streichungen,
|
||||
Prüfmarken und Stellen „zu prüfen“.
|
||||
2. **Zu prüfen:** Tabelle der verdächtigen Änderungen mit Grund, Text vorher,
|
||||
korrigiertem Text und Zusammenhang. **Hier beginnen.** Darunter in einer eigenen
|
||||
Tabelle die bloßen Wortformen („Wortform geändert“).
|
||||
korrigiertem Text und Zusammenhang. **Hier beginnen.**
|
||||
3. **Alle Änderungen nach Häufigkeit:** jede Wortänderung mit Anzahl, z. B.
|
||||
`5 Rammstein → Ramstein`. Gut, um wiederkehrende Hörfehler fürs Glossar zu
|
||||
finden.
|
||||
|
|
@ -475,7 +457,6 @@ xdg-open rede.aenderungen.html
|
|||
| rot durchgestrichen | gestrichenes Wort |
|
||||
| orange `[prüfen: …]` | Prüfmarke des Modells |
|
||||
| rot umrahmt | Stelle „zu prüfen“; beim Darüberfahren erscheint der Grund |
|
||||
| rot gestrichelt umrahmt | geänderte Wortform (meist Grammatik) |
|
||||
| rot umrahmtes ⚠ | hier hat das Modell eigenmächtig etwas gestrichen |
|
||||
|
||||
Groß-/Kleinschreibung, Satzzeichen und Schreibweisen wie „z.B.“ → „z. B.“ werden
|
||||
|
|
@ -489,23 +470,19 @@ Sinn verändern können. Was die Glossare ersetzt haben, erscheint hier nie.
|
|||
| Grund | Bedeutung | Beispiel |
|
||||
|---|---|---|
|
||||
| gültiges Wort ersetzt | Das alte Wort ist ein echtes Wort (steht in der Wortliste), also vermutlich kein Hörfehler. | „Professor“ → „Professorin“, „unterschreiben“ → „unterscheiden“ |
|
||||
| Wortform geändert | Ein echtes Wort hat nur eine andere Endung oder einen Umlaut bekommen. Meist eine richtige Grammatik-Korrektur; steht in einer eigenen Gruppe am Ende. | „Punkten“ → „Punkte“, „das“ → „dass“, „sehe“ → „sähe“ |
|
||||
| stark verändert | Das neue Wort ähnelt dem alten kaum (unter 60 %). | „CT1“ → „ZDF“ |
|
||||
| eingefügt | Das Modell hat ein Wort ergänzt. | „∅“ → „ist“ |
|
||||
| gestrichen | Das Modell hat ein Wort weggelassen (Füllwörter wie „äh“ zählen nicht). | „Sicherungskonzepte geht“ → „∅“ |
|
||||
|
||||
Nicht gemeldet werden ähnlich bleibende Hörfehler-Korrekturen („Gradwanderung“ →
|
||||
„Gratwanderung“), reine Zusammen-, Getrennt- und Bindestrich-Schreibung („Clan
|
||||
Kriminalität“ → „Clankriminalität“) und Schreibweisen aus dem Glossar („Bar“ →
|
||||
„Bahr“, wenn „Egon Bahr“ im Glossar steht). Ergänzt das Modell dagegen Wörter, etwa
|
||||
einen Vornamen („Dobrind“ → „Alexander Dobrindt“), erscheint das auch dann, wenn der
|
||||
volle Name im Glossar steht: Gesagt wurde er so nicht.
|
||||
Kriminalität“ → „Clankriminalität“) und Wörter, die im Glossar stehen.
|
||||
|
||||
**So arbeitet man die Liste ab:**
|
||||
|
||||
1. Für jede Zeile den Zusammenhang lesen.
|
||||
2. Ist die Änderung richtig, nichts tun. Bei den Wortformen am Ende ist das der
|
||||
Normalfall; dort genügt ein kurzer Blick.
|
||||
2. Ist die Änderung richtig (oft bei Grammatik: „das“ → „dass“, „sehe“ → „sähe“),
|
||||
nichts tun.
|
||||
3. Ist sie falsch, im korrigierten Text zurückändern. Tritt der Fehler öfter auf
|
||||
oder geht es um einen Namen, besser das Glossar ergänzen (nächster Schritt).
|
||||
4. Bei „gestrichen“ prüfen, ob wirklich etwas fehlt: Oft entfernt das Modell
|
||||
|
|
@ -544,22 +521,9 @@ neu, was sich geändert hat:
|
|||
- Die Absatzgliederung wird für geänderte Textstellen ebenfalls neu bestimmt; das
|
||||
geht schnell.
|
||||
|
||||
**Handarbeit bleibt erhalten.** Wurde `rede.korrigiert.txt` seit dem letzten Lauf
|
||||
von Hand geändert, überschreibt ein erneuter Lauf sie nicht. Das neue Ergebnis kommt
|
||||
dann nach `rede.neu.korrigiert.txt` (Bericht: `rede.neu.aenderungen.html`), mit
|
||||
einer Warnung:
|
||||
|
||||
```text
|
||||
WARNUNG: rede.korrigiert.txt wurde seit dem letzten Lauf geändert (von Hand?) und bleibt erhalten. Neues Ergebnis: rede.neu.korrigiert.txt (mit --ueberschreiben wird die Datei ersetzt)
|
||||
```
|
||||
|
||||
Die eigenen Änderungen lassen sich dann mit einem Vergleichsprogramm übertragen,
|
||||
z. B. `meld rede.korrigiert.txt rede.neu.korrigiert.txt`. Soll die Handarbeit
|
||||
verworfen werden: `--ueberschreiben` angeben.
|
||||
|
||||
Das Programm erkennt Handarbeit an einer Prüfsumme in `rede.korrigiert.parts/`.
|
||||
Fehlt sie (erster Lauf mit dieser Version, Ordner gelöscht), gilt jede abweichende
|
||||
vorhandene Datei als von Hand geändert.
|
||||
Von Hand nachbearbeitete `*.korrigiert.txt` werden bei einem erneuten Lauf
|
||||
**überschrieben**. Handarbeit deshalb erst ganz am Ende machen oder die Datei vorher
|
||||
unter anderem Namen sichern.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -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“
|
||||
> `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
|
||||
|
||||
|
|
@ -653,7 +615,7 @@ Beispiel für `-i rede.txt` bzw. `-o rede.korrigiert.txt`:
|
|||
| Datei | Inhalt | Wird überschrieben? |
|
||||
|---|---|---|
|
||||
| `rede.txt` | Rohtext (bei `-u` automatisch gespeichert) | nie, wenn sie schon mit anderem Inhalt existiert (dann Warnung) |
|
||||
| `rede.korrigiert.txt` | korrigierte Abschrift | ja, 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.korrigiert.parts/` | Zwischenstände | wird ergänzt; darf jederzeit gelöscht werden |
|
||||
| `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. |
|
||||
| `Ausgabedatei darf nicht die Eingabedatei sein.` | Mit `-o` einen anderen Namen wählen. |
|
||||
| `Glossar nicht gefunden: …` | Pfad bei `-g` prüfen. |
|
||||
| `WARNUNG: rede.korrigiert.txt wurde seit dem letzten Lauf geändert (von Hand?) …` | Die Datei wurde nachbearbeitet und bleibt erhalten; das neue Ergebnis steht in `rede.neu.korrigiert.txt`. Änderungen übertragen oder mit `--ueberschreiben` ersetzen, siehe [Abschnitt 9](#9-schritt-5-nachbessern-und-erneut-laufen-lassen). |
|
||||
| `WARNUNG: rede.txt existiert mit anderem Inhalt und bleibt unverändert.` | Bei `-u` wird ein vorhandener, z. B. von Hand bearbeiteter Rohtext nie überschrieben. Korrigiert wird trotzdem der frisch geladene Text. Wer den neuen Rohtext braucht, die alte Datei umbenennen. |
|
||||
| `WARNUNG: Abschnitt 3: 1180 → 1090 Wörter (8% Abweichung) …` | Das Modell hat möglicherweise etwas ausgelassen oder ergänzt. Abschnitt im Bericht prüfen. Bei Bedarf die genannte Datei in `.parts/` löschen und erneut laufen lassen. |
|
||||
| `Abschnitt 3 wurde abgeschnitten. CHUNK_SIZE verkleinern und erneut starten.` | Die Antwort des Modells war zu lang. `CHUNK_SIZE` oben in `abschrift_korrigieren.py` verkleinern (z. B. 900). |
|
||||
| `Absatzgliederung: unbrauchbare Antwort: …` / `Absätze verbinden: unbrauchbare Antwort: …` | Das Modell hat kein gültiges JSON geliefert; selten. Erneut starten, die Korrektur selbst ist zwischengespeichert. Tritt es wiederholt auf, ein anderes Modell versuchen. |
|
||||
| `Hinweis: keine Wortliste für „de“ (Paket wngerman); …` | `sudo apt install wngerman` (für Englisch `wamerican`). Ohne Wortliste fehlt im Bericht nur der Grund „gültiges Wort ersetzt“. |
|
||||
| `Hinweis: keine Untertitel im Bereich …` | Einer von mehreren Bereichen ist leer und wird übersprungen. In Klammern steht, wo die Untertitel enden. |
|
||||
| `Hinweis: Bereich Anfang – 05:00:00 reicht über das Ende der Untertitel hinaus (…)` | Der Bereich endet nach dem Video. Meist war Minute statt Stunde gemeint: `05:00` ist Stunde 5, Minute 5 ist `00:05`. Bearbeitet wird trotzdem, bis zum Ende des Videos. |
|
||||
| `Hinweis: keine Untertitel im Bereich …` | Einer von mehreren Bereichen ist leer und wird übersprungen. |
|
||||
| `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). |
|
||||
|
||||
|
|
@ -800,7 +760,6 @@ einfügen.
|
|||
| `-z BEREICH …` | nur diese Zeitbereiche (mit `.vtt` oder `-u`) |
|
||||
| `--exakt` | genau an den Zeiten schneiden |
|
||||
| `--kein-bericht` | keinen Änderungsbericht schreiben |
|
||||
| `--ueberschreiben` | von Hand geänderte Ausgabedatei trotzdem ersetzen |
|
||||
|
||||
### `extract_text.py`
|
||||
|
||||
|
|
|
|||
128
README.md
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:
|
||||
|
||||
```bash
|
||||
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d untertitel
|
||||
./vtt2txt.py -i untertitel/Lahnstein.de-orig.vtt
|
||||
./abschrift_korrigieren.py -i untertitel/Lahnstein.de-orig.txt
|
||||
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten
|
||||
./vtt2txt.py -i testdaten/Lahnstein.de-orig.vtt
|
||||
./abschrift_korrigieren.py -i testdaten/Lahnstein.de-orig.txt
|
||||
```
|
||||
|
||||
Der Schritt über `vtt2txt.py` ist optional: `abschrift_korrigieren.py` nimmt auch die
|
||||
|
|
@ -76,7 +76,7 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
|
|||
```bash
|
||||
./extract_text.py -u 'https://www.youtube.com/watch?v=0LmCveTM788'
|
||||
# → John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30.txt
|
||||
./extract_text.py -u URL -l de -o 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 -z 0:03:17-0:05:25 # nur ein Ausschnitt
|
||||
./extract_text.py -u URL -z 00:05-00:06 00:10-00:12 00:25 # mehrere Bereiche
|
||||
|
|
@ -131,7 +131,7 @@ Region gefunden: `-l en` findet manuelle Untertitel `en-US`.
|
|||
|
||||
```bash
|
||||
./yt_untertitel.py -u URL # → <Kanal>_<Titel>_<Datum>.de-orig.vtt
|
||||
./yt_untertitel.py -u URL -n Lahnstein -d untertitel # → untertitel/Lahnstein.de-orig.vtt
|
||||
./yt_untertitel.py -u URL -n Lahnstein -d testdaten # → testdaten/Lahnstein.de-orig.vtt
|
||||
```
|
||||
|
||||
| Option | Bedeutung |
|
||||
|
|
@ -182,9 +182,7 @@ Gilt gleich für `vtt2txt.py`, `extract_text.py` und `abschrift_korrigieren.py`.
|
|||
| `25` | Minute 25 |
|
||||
|
||||
Achtung: Zwei Zahlen sind Stunden:Minuten, `05:00` ist also Stunde 5, nicht
|
||||
Minute 5 (dafür `00:05` oder `5`). Reicht ein Bereich über die letzte
|
||||
Untertitelzeile hinaus, nennt ein Hinweis das Ende der Untertitel, bei Bereichen ab
|
||||
einer Stunde mit diesem Tipp.
|
||||
Minute 5 (dafür `00:05` oder `5`).
|
||||
|
||||
| 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) |
|
||||
| `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) |
|
||||
| `--kein-bericht` | keinen Änderungsbericht schreiben |
|
||||
| `--ueberschreiben` | Ausgabedatei auch ersetzen, wenn sie seit dem letzten Lauf von Hand geändert wurde |
|
||||
|
||||
Eine von Hand nachbearbeitete Ausgabedatei überschreibt ein erneuter Lauf nicht: Das
|
||||
Ergebnis kommt dann nach `<Name>.neu.korrigiert.txt`, mit Warnung. Erkannt wird das
|
||||
an der Prüfsumme der zuletzt geschriebenen Ausgabe in `<Ausgabe>.parts/ausgabe.sha256`;
|
||||
ohne Prüfsumme gilt jede abweichende vorhandene Datei als von Hand geändert.
|
||||
|
||||
Mit Zeitauswahl wird jeder Bereich als eigener Block korrigiert und gegliedert;
|
||||
die Überschriften `[00:04:57 - 00:06:04]` bekommt das Modell nicht zu sehen, sie
|
||||
|
|
@ -274,9 +266,7 @@ Blöcken korrigiert.
|
|||
3. **Korrektur:** Der Text wird in Abschnitte von ca. 1200 Wörtern geteilt (immer
|
||||
am Satzende; Abkürzungen wie „z. B.“, „Dr.“, „bzw.“ und Ordnungszahlen wie
|
||||
„2. Weltkrieg“ zählen nicht als Satzende) und abschnittsweise vom Modell
|
||||
korrigiert. Fortschritt wird live angezeigt. Der Prompt verbietet Ergänzungen,
|
||||
ausdrücklich auch Vornamen, Titel oder Namensteile aus dem Glossar, die nicht
|
||||
gesagt wurden (sonst wurde aus „Dobrindt“ gern „Alexander Dobrindt“).
|
||||
korrigiert. Fortschritt wird live angezeigt.
|
||||
4. **Wortzahl-Prüfung:** Weicht die Wortzahl eines Abschnitts um mehr als 5 % ab,
|
||||
gibt es eine Warnung (mögliche Auslassungen oder Ergänzungen).
|
||||
5. **Absätze:** Das Modell nennt nur Satznummern, an denen ein Absatz beginnt; der
|
||||
|
|
@ -321,24 +311,15 @@ Ersetzungen des Glossars), mit seinem Ergebnis. Gemeldet werden:
|
|||
| Grund | Beispiel |
|
||||
|---|---|
|
||||
| gültiges Wort ersetzt | „Professor“ → „Professorin“, „unterschreiben“ → „unterscheiden“ (das alte Wort steht in der Wortliste, war also kein Hörfehler) |
|
||||
| Wortform geändert | „Punkten“ → „Punkte“, „das“ → „dass“, „sehe“ → „sähe“: ein gültiges Wort, nur Endung oder Umlaut anders (meist Grammatik); eigene Gruppe am Ende der Liste |
|
||||
| stark verändert | „CT1“ → „ZDF“ (Ähnlichkeit unter 60 %) |
|
||||
| eingefügt | „∅“ → „ist“ |
|
||||
| gestrichen | „Sicherungskonzepte geht“ → „∅“ (Füllwörter wie „äh“ nicht) |
|
||||
|
||||
Nicht gemeldet werden Hörfehler-Korrekturen, die ähnlich bleiben
|
||||
(„Gradwanderung“ → „Gratwanderung“), reine Zusammen-, Getrennt- und
|
||||
Bindestrich-Schreibung („Clan Kriminalität“ → „Clankriminalität“) und Schreibweisen
|
||||
aus dem Glossar („Bar“ → „Bahr“ bei „Egon Bahr“ im Glossar): Die neuen Wörter müssen
|
||||
zusammenhängend in einem Glossareintrag stehen und dem alten Text ähneln. Einzelne
|
||||
Allerweltswörter aus längeren Einträgen („Sie“ aus „finden Sie“) und eingefügte
|
||||
Wörter (ein ergänzter Vorname „Alexander“) erklärt das Glossar nicht. Die Liste
|
||||
erscheint auch in der Konsole, sinnverändernde Fälle zuerst, Wortformen danach.
|
||||
|
||||
Als Wortform gilt ein einzelnes Wort, das bis auf die Endung gleich bleibt (Stamm
|
||||
mindestens 3 Buchstaben, beide Endungen aus `INFLECTIONS`, z. B. -e, -n, -s, -en,
|
||||
-er, -es, -st, -t) oder sich nur im Umlaut unterscheidet. „-in“ gehört bewusst nicht
|
||||
dazu: „Professor“ → „Professorin“ bleibt in der Hauptliste.
|
||||
Bindestrich-Schreibung („Clan Kriminalität“ → „Clankriminalität“) und neue Wörter,
|
||||
die im Glossar stehen. Die Liste erscheint auch in der Konsole. Kleine
|
||||
Grammatik-Korrekturen wie „das“ → „dass“ tauchen ebenfalls auf; ein Blick genügt.
|
||||
|
||||
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:
|
||||
|
|
@ -369,14 +350,11 @@ einzelnen Beitrag deutet das auf einen Tippfehler hin, bei einem gemeinsamen Glo
|
|||
es normal.
|
||||
|
||||
Mehrere Glossare lassen sich kombinieren: ein gemeinsames für wiederkehrende Namen
|
||||
und Hörfehler eines Redners oder Kanals und eines je Beitrag, z. B.
|
||||
`krone-schmalz.glossar.txt` (gemeinsam) plus `ramstein.glossar.txt`. Das gemeinsame
|
||||
Glossar zuerst angeben, damit seine Ersetzungen vor den speziellen greifen.
|
||||
|
||||
Glossare gehören zu realen Beiträgen und Personen und werden deshalb nicht
|
||||
versioniert: `*.glossar.txt` steht in `.gitignore`, eigene Glossare liegen nur lokal.
|
||||
Im Repository ist als Beispiel nur `tests/daten/beispiel.glossar.txt` zur erfundenen
|
||||
Beispielrede.
|
||||
und Hörfehler eines Redners oder Kanals und eines je Beitrag. Beispiel zu den Testdaten:
|
||||
`krone-schmalz.glossar.txt` (gemeinsam) plus `ramstein.glossar.txt`,
|
||||
`magdeburg.glossar.txt` bzw. `lahnstein.glossar.txt`. Das gemeinsame Glossar zuerst angeben, damit seine
|
||||
Ersetzungen vor den speziellen greifen. Ein weiteres Kanal-Glossar:
|
||||
`iamfabian.glossar.txt` für Videos über KI-Coding-Tools (Claude Code, Codex, OpenCode …).
|
||||
|
||||
### 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.
|
||||
|
||||
```bash
|
||||
./glossar_vorschlag.py -i rede.txt -k rede.korrigiert.txt -g krone-schmalz.glossar.txt
|
||||
# → rede.glossar.entwurf.txt
|
||||
./glossar_vorschlag.py -i tests/daten/beispiel.de.txt # an der Beispielrede ausprobieren
|
||||
./glossar_vorschlag.py -i testdaten/Lahnstein.de-orig.txt \
|
||||
-k testdaten/Lahnstein.de-orig.korrigiert.txt -g krone-schmalz.glossar.txt
|
||||
# → testdaten/Lahnstein.de-orig.glossar.entwurf.txt
|
||||
```
|
||||
|
||||
| Option | Bedeutung |
|
||||
|
|
@ -420,31 +398,24 @@ werden in `<Entwurf>.parts/` zwischengespeichert.
|
|||
|
||||
## Testdaten
|
||||
|
||||
**Im Repository:** `tests/daten/` enthält eine frei erfundene Beispielrede
|
||||
(Festrede zum Jubiläum der Gemeinde „Auental“; Ort und Personen gibt es nicht), als
|
||||
Untertitel im Format der YouTube-Auto-Untertitel (`beispiel.de.vtt`, rollendes
|
||||
Fenster, Wort-Zeitstempel), daraus gewonnener Rohtext (`beispiel.de.txt`) und ein
|
||||
passendes Glossar (`beispiel.glossar.txt`) für die eingebauten Hörfehler wie
|
||||
„Auen Tal“ oder „Haber Kamp“. Die Tests nutzen nur diese Daten.
|
||||
|
||||
```bash
|
||||
./abschrift_korrigieren.py -i tests/daten/beispiel.de.txt -g tests/daten/beispiel.glossar.txt -o /tmp/beispiel.korrigiert.txt
|
||||
```
|
||||
|
||||
**Nur lokal:** Echte Reden gehören wegen des Urheberrechts nicht ins Repository;
|
||||
`testdaten/` ist deshalb in `.gitignore`, ebenso die Glossare (`*.glossar.txt`).
|
||||
Liegen die drei Reden, mit denen das Programm entwickelt wurde, in `testdaten/` und
|
||||
ihre Glossare im Programmordner, laufen zusätzlich die Tests, die diese Glossare
|
||||
prüfen; sonst werden sie übersprungen:
|
||||
`testdaten/` enthält drei Reden, jeweils als automatische YouTube-Untertitel
|
||||
(`.vtt`, Original) und daraus mit `vtt2txt.py` gewonnener Rohtext (`.txt`, Eingabe
|
||||
für das Programm):
|
||||
|
||||
- `ramstein.de.*`: Rede über die US Air Base Ramstein (Glossar `ramstein.glossar.txt`)
|
||||
- `magdeburg.de-orig.*`: Rede in Magdeburg (Glossar `magdeburg.glossar.txt`)
|
||||
- `Lahnstein.de-orig.*`: Rede „Respekt geht anders“ in Lahnstein (Glossar
|
||||
`lahnstein.glossar.txt`, erstellt mit Hilfe von `glossar_vorschlag.py`), zu laden mit
|
||||
`./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten`
|
||||
`lahnstein.glossar.txt`, erstellt mit Hilfe von `glossar_vorschlag.py`)
|
||||
|
||||
Alle drei mit dem gemeinsamen `krone-schmalz.glossar.txt` davor. Ein Lauf über eine
|
||||
Rede dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa 6–8 Minuten.
|
||||
Alle drei mit dem gemeinsamen `krone-schmalz.glossar.txt` davor.
|
||||
|
||||
```bash
|
||||
./vtt2txt.py -i "testdaten/*.vtt" # erzeugt alle .txt neu
|
||||
./abschrift_korrigieren.py -i testdaten/ramstein.de.txt -g krone-schmalz.glossar.txt -g ramstein.glossar.txt
|
||||
# → testdaten/ramstein.de.korrigiert.txt (nicht versioniert)
|
||||
```
|
||||
|
||||
Ein Lauf dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa 6 Minuten.
|
||||
|
||||
## Tests
|
||||
|
||||
|
|
@ -452,36 +423,27 @@ Rede dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa 6–8 Minuten.
|
|||
python3 -m pytest
|
||||
```
|
||||
|
||||
Die Tests brauchen weder Ollama noch Netz: Modellaufrufe und `yt-dlp` werden durch
|
||||
Fakes ersetzt. Als Text dient die erfundene Beispielrede in `tests/daten/` (siehe
|
||||
[Testdaten](#testdaten)). In einem frischen Klon laufen 318 Tests, 7 werden
|
||||
übersprungen; sie prüfen die Glossare der echten Reden und laufen nur, wenn diese
|
||||
lokal in `testdaten/` liegen (dann 325).
|
||||
|
||||
`tests/test_abschrift_korrigieren.py` prüft Abschnittsbildung, Satzgrenzen
|
||||
(Abkürzungen und Ordnungszahlen sind kein Satzende), Glossar (jede Ersetzung des
|
||||
Beispielglossars trifft die Beispielrede; lokal zusätzlich: jede Ersetzung im
|
||||
Glossar einer Rede trifft diese Rede, jede im gemeinsamen Glossar mindestens eine),
|
||||
Prompts (keine Namensteile ergänzen), Streaming-Auswertung, Absatzgliederung (Text
|
||||
bleibt unverändert, 3–10 Sätze), Verbinden kurzer Absätze (Modell wird nur gefragt,
|
||||
wenn beide Nachbarn passen), Zwischenspeicher/Fortsetzen, Schutz von Handarbeit
|
||||
(`.neu.korrigiert.txt`, `--ueberschreiben`), Zeitauswahl, Sprachen und die
|
||||
Die Tests (291) brauchen weder Ollama noch Netz: Modellaufrufe und `yt-dlp` werden
|
||||
durch Fakes ersetzt. `tests/test_abschrift_korrigieren.py` prüft Abschnittsbildung,
|
||||
Satzgrenzen (Abkürzungen und Ordnungszahlen sind kein Satzende), Glossar (inkl.
|
||||
„jede Ersetzung im Glossar einer Rede trifft diese Rede, jede im gemeinsamen Glossar
|
||||
trifft mindestens eine“), Streaming-Auswertung, Absatzgliederung (Text bleibt
|
||||
unverändert, 3–10 Sätze), Verbinden kurzer Absätze (Modell wird nur gefragt, wenn
|
||||
beide Nachbarn passen), Zwischenspeicher/Fortsetzen, Zeitauswahl, Sprachen und die
|
||||
Kommandozeile.
|
||||
`tests/test_glossar_vorschlag.py` prüft Filter (nur Funde im Text, keine bekannten),
|
||||
Ausgabeformat (gültiges Glossar, Unsicheres auskommentiert) und Zwischenspeicher.
|
||||
`tests/test_aenderungen.py` prüft den Wortvergleich (Satzzeichen, Groß-/Kleinschreibung
|
||||
und „z. B.“ zählen nicht), die Liste „Zu prüfen“ (Gründe, Wortformen, Füllwörter je
|
||||
Sprache, Glossar-Schreibweisen, fehlende Wortliste) und den HTML-Bericht
|
||||
(vollständiger Text, Absätze, Escaping, Reihenfolge).
|
||||
und „z. B.“ zählen nicht), die Liste „Zu prüfen“ (Gründe, Ausnahmen, Glossar,
|
||||
fehlende Wortliste) und den HTML-Bericht (vollständiger Text, Absätze, Escaping).
|
||||
Die Tests nutzen eine kleine eigene Wortliste statt der des Systems.
|
||||
`tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl
|
||||
(Original-Audio, manuell vor automatisch) und Fehlerfälle.
|
||||
`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und
|
||||
Fehlerfälle (ohne Netz, `yt-dlp` wird durch Fakes ersetzt).
|
||||
`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: die Beispiel-`.vtt` (und lokal
|
||||
jede Rede in `testdaten/`) ergibt genau die zugehörige `.txt`, echte Wiederholungen
|
||||
bleiben erhalten, auch bei Untertiteln ohne rollendes Fenster, dazu Zeitauswahl
|
||||
(mit Hinweis bei Bereichen über das Ende hinaus), Muster, Ausgabeverzeichnis und
|
||||
`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: jede `.vtt` in `testdaten/`
|
||||
ergibt genau die zugehörige `.txt`, echte Wiederholungen bleiben erhalten, auch bei
|
||||
Untertiteln ohne rollendes Fenster, dazu Zeitauswahl, Muster, Ausgabeverzeichnis und
|
||||
Fehlerfälle.
|
||||
|
||||
## Zwischenstände
|
||||
|
|
@ -516,9 +478,7 @@ In `aenderungen.py`:
|
|||
|---|---|---|
|
||||
| `WORDLISTS` | `/usr/share/dict/ngerman`, `…/american-english` | Wortlisten je Sprache |
|
||||
| `MIN_SIMILARITY` | 0.6 | darunter gilt eine Ersetzung als „stark verändert“ |
|
||||
| `FILLERS` | je Sprache: äh, ähm, …; uh, um, er, … | Füllwörter, deren Streichung nicht gemeldet wird |
|
||||
| `INFLECTIONS` | je Sprache: -e, -n, -s, -en, …; -s, -ed, -ing, … | Endungen, die nur die Wortform ändern |
|
||||
| `MIN_STEM` | 3 | kürzester gemeinsamer Wortstamm einer Wortform-Änderung |
|
||||
| `FILLERS` | äh, ähm, … | Füllwörter, deren Streichung nicht gemeldet wird |
|
||||
|
||||
In `vtt2txt.py`:
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import urllib.error
|
|||
import urllib.request
|
||||
|
||||
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 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
|
||||
MAX_PARAGRAPH_SENTENCES = 10 # längere Absätze werden nachträglich geteilt
|
||||
MIN_PARAGRAPH_SENTENCES = 3 # kürzere Absätze werden mit dem Nachbarn verbunden
|
||||
OUTPUT_RECORD = "ausgabe.sha256" # in <Ausgabe>.parts/: Prüfsumme der zuletzt geschriebenen Ausgabe
|
||||
|
||||
SENTENCE_END = re.compile(r"[.!?…][\"'»«“”)\]]*$")
|
||||
SENTENCE_SPLIT = re.compile(r"(?:(?<=[.!?…])|(?<=[.!?…][\"'»«“”)\]]))\s+")
|
||||
|
|
@ -58,9 +57,6 @@ Keine Zusammenfassung und keine stilistische Neufassung.
|
|||
- Entferne nur eindeutig technisch verursachte Untertitel-Dopplungen.
|
||||
- Bewahre rhetorische Wiederholungen und Selbstkorrekturen.
|
||||
- Ergänze keine Informationen und keine fehlenden Sätze.
|
||||
- Ergänze keine Vornamen, Titel oder andere Namensteile, die nicht gesagt
|
||||
wurden, auch nicht aus dem Glossar: Steht in der Abschrift nur ein
|
||||
Nachname, bleibt es beim Nachnamen.
|
||||
- Glätte keine Formulierungen allein aus stilistischen Gründen.
|
||||
- Ändere Zahlen, Daten und Zitate nur bei eindeutigen Fehlern.
|
||||
- Übernimm Geräuschhinweise in eckigen Klammern wie [applaus] unverändert.
|
||||
|
|
@ -127,9 +123,6 @@ No summary and no stylistic rewrite.
|
|||
speaker-change marker ">>".
|
||||
- Keep rhetorical repetitions and self-corrections.
|
||||
- Do not add information or missing sentences.
|
||||
- Do not add first names, titles or other parts of a name that were not
|
||||
said, not even from the glossary: if the transcript has only a surname,
|
||||
keep only the surname.
|
||||
- Do not smooth phrasing for stylistic reasons alone.
|
||||
- Change numbers, dates and quotations only if they are clearly wrong.
|
||||
- Keep sound tags in square brackets such as [music] unchanged.
|
||||
|
|
@ -187,10 +180,7 @@ PROMPTS = {
|
|||
"merge": MERGE_SYSTEM,
|
||||
"glossary": "Glossar: korrekte Schreibweisen von Namen und Begriffen, die in "
|
||||
"diesem Beitrag vorkommen. Falsch erkannte Varianten davon in der Abschrift "
|
||||
"stillschweigend korrigieren. Korrigiere nur die Schreibweise dessen, was "
|
||||
"gesagt wurde: Ergänze keine Vornamen, Titel oder Namensteile aus dem Glossar, "
|
||||
"die in der Abschrift fehlen (aus „Dobrind“ wird „Dobrindt“, nicht "
|
||||
"„Alexander Dobrindt“).",
|
||||
"stillschweigend korrigieren:",
|
||||
"excerpt": "Der Text ist ein Ausschnitt aus einem längeren Beitrag und kann mitten "
|
||||
"im Satz beginnen oder enden. Vervollständige angeschnittene Sätze nicht und "
|
||||
"ergänze kein Wort, auch nicht in einer Markierung; lass einen unvollständigen "
|
||||
|
|
@ -203,10 +193,7 @@ PROMPTS = {
|
|||
"merge": MERGE_SYSTEM_EN,
|
||||
"glossary": "Glossary: correct spellings of names and terms that occur in "
|
||||
"this recording. Silently correct misrecognized variants of them in the "
|
||||
"transcript. Correct only the spelling of what was said: do not add first "
|
||||
"names, titles or other parts of a name from the glossary that are missing "
|
||||
"in the transcript (\"Mearshimer\" becomes \"Mearsheimer\", not "
|
||||
"\"John Mearsheimer\").",
|
||||
"transcript:",
|
||||
"excerpt": "The text is an excerpt from a longer recording and may begin or end "
|
||||
"in the middle of a sentence. Do not complete cut-off sentences and do not "
|
||||
"add any word, not even in a mark; leave an incomplete beginning or ending "
|
||||
|
|
@ -240,7 +227,7 @@ def build_system(glossary, lang=DEFAULT_LANGUAGE, excerpt=False):
|
|||
if not glossary:
|
||||
return system
|
||||
terms = "\n".join(f"- {term}" for term in glossary)
|
||||
return f"{system}\n{prompts['glossary']}\n\n{terms}\n"
|
||||
return f"{system}\n{prompts['glossary']}\n{terms}\n"
|
||||
|
||||
|
||||
def read_glossary(path):
|
||||
|
|
@ -648,11 +635,6 @@ def parse_args():
|
|||
"--kein-bericht", action="store_true",
|
||||
help="keinen Änderungsbericht <Ausgabe>.aenderungen.html schreiben",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ueberschreiben", action="store_true",
|
||||
help="die Ausgabedatei auch dann überschreiben, wenn sie seit dem letzten Lauf "
|
||||
"von Hand geändert wurde (sonst entsteht <Name>.neu.korrigiert.txt)",
|
||||
)
|
||||
add_time_options(parser)
|
||||
args = parser.parse_args(range_argv(sys.argv[1:]))
|
||||
resolve_time_ranges(parser, args)
|
||||
|
|
@ -720,35 +702,6 @@ def read_source(args):
|
|||
return raw, args.output, base_language(args.lang or DEFAULT_LANGUAGE)
|
||||
|
||||
|
||||
def text_hash(text):
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def new_output_path(target):
|
||||
"""rede.korrigiert.txt -> rede.neu.korrigiert.txt, out.txt -> out.neu.txt"""
|
||||
if target.name.endswith(".korrigiert.txt"):
|
||||
stem = target.name.removesuffix(".korrigiert.txt")
|
||||
return target.with_name(f"{stem}.neu.korrigiert.txt")
|
||||
return target.with_name(f"{target.stem}.neu{target.suffix}")
|
||||
|
||||
|
||||
def choose_output(target, content, parts_dir, overwrite=False):
|
||||
"""Where to write `content`: the target, unless it was changed by hand.
|
||||
|
||||
A target counts as changed by hand if it differs both from what the last
|
||||
run wrote there (checksum in parts_dir) and from the new content. Without
|
||||
a checksum (first run, .parts deleted) any differing target counts.
|
||||
"""
|
||||
if overwrite or not target.exists():
|
||||
return target
|
||||
current = target.read_text(encoding="utf-8")
|
||||
record = parts_dir / OUTPUT_RECORD
|
||||
last = record.read_text(encoding="utf-8").strip() if record.exists() else None
|
||||
if current == content or text_hash(current) == last:
|
||||
return target
|
||||
return new_output_path(target)
|
||||
|
||||
|
||||
def main():
|
||||
global MODEL
|
||||
args = parse_args()
|
||||
|
|
@ -841,25 +794,14 @@ def main():
|
|||
corrected_blocks.append((header, paragraphs))
|
||||
|
||||
text = join_segments(corrected_blocks, header_gap="\n\n")
|
||||
content = text + "\n"
|
||||
output = choose_output(target, content, parts_dir, args.ueberschreiben)
|
||||
output.write_text(content, encoding="utf-8")
|
||||
if output == target:
|
||||
(parts_dir / OUTPUT_RECORD).write_text(text_hash(content) + "\n", encoding="utf-8")
|
||||
else:
|
||||
print(
|
||||
f"WARNUNG: {target} wurde seit dem letzten Lauf geändert (von Hand?) und "
|
||||
f"bleibt erhalten. Neues Ergebnis: {output} "
|
||||
"(mit --ueberschreiben wird die Datei ersetzt)"
|
||||
)
|
||||
target = output
|
||||
target.write_text(text + "\n", encoding="utf-8")
|
||||
print(f"Fertig: {target}")
|
||||
|
||||
if not args.kein_bericht:
|
||||
report = report_path(target)
|
||||
comparison = write_report(
|
||||
raw_text, text, report, target.name, model_input=model_text,
|
||||
glossary=glossary_entries(terms, replacements), lang=lang,
|
||||
known=glossary_words(terms, replacements), lang=lang,
|
||||
)
|
||||
print_summary(comparison)
|
||||
print(f"Bericht: {report}")
|
||||
|
|
|
|||
149
aenderungen.py
149
aenderungen.py
|
|
@ -10,7 +10,7 @@ import argparse
|
|||
import html
|
||||
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
|
||||
REVIEW_MARK = re.compile(r"\[(?:prüfen|check):[^\]]*\]")
|
||||
|
|
@ -30,25 +30,13 @@ WORDLISTS = {
|
|||
}
|
||||
WORDLIST_PACKAGES = {"de": "wngerman", "en": "wamerican"}
|
||||
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 = {
|
||||
"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
|
||||
FILLERS = {"äh", "ähm", "öh", "öhm", "hm", "hmm", "uh", "uhm", "um", "er", "erm"}
|
||||
CONTEXT_WORDS = 6
|
||||
|
||||
VALID_WORD = "gültiges Wort ersetzt"
|
||||
CHANGED_A_LOT = "stark verändert"
|
||||
INSERTED = "eingefügt"
|
||||
DELETED = "gestrichen"
|
||||
WORD_FORM = "Wortform geändert"
|
||||
|
||||
|
||||
def add_token(words, token):
|
||||
|
|
@ -144,10 +132,10 @@ def load_wordlist(lang):
|
|||
return {line.strip().lower() for line in words if line.strip()}
|
||||
|
||||
|
||||
def glossary_entries(terms, replacements):
|
||||
"""Glossary terms and replacement targets as tuples of normalized words."""
|
||||
def glossary_words(terms, replacements):
|
||||
"""Normalized words of glossary terms and replacement targets."""
|
||||
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):
|
||||
|
|
@ -155,65 +143,33 @@ def spelling_key(words):
|
|||
return re.sub(r"[\W_]+", "", "".join(words).lower())
|
||||
|
||||
|
||||
def similarity(old, new):
|
||||
return SequenceMatcher(None, spelling_key(old), spelling_key(new)).ratio()
|
||||
|
||||
|
||||
def glossary_spelling(old, new, glossary):
|
||||
"""True if `new` is a glossary spelling of `old`: the new words stand
|
||||
together in one glossary entry and resemble the old ones. Common words of
|
||||
longer entries ("finden Sie") explain nothing on their own."""
|
||||
words = tuple(normalize(word) for word in new)
|
||||
size = len(words)
|
||||
in_entry = any(
|
||||
entry[start:start + size] == words
|
||||
for entry in glossary for start in range(len(entry) - size + 1)
|
||||
)
|
||||
return in_entry and similarity(old, new) >= MIN_SIMILARITY
|
||||
|
||||
|
||||
def word_form_change(old, new, lang="de"):
|
||||
"""True if one word becomes another form of itself: only the ending
|
||||
("Punkten" -> "Punkte", "das" -> "dass") or an umlaut ("sehe" -> "sähe")
|
||||
differs. "-in" is no such ending: "Professor" -> "Professorin" counts."""
|
||||
if len(old) != 1 or len(new) != 1:
|
||||
return False
|
||||
a, b = (NON_WORD.sub("", w).lower().translate(UMLAUT_FOLD) for w in (old[0], new[0]))
|
||||
if a == b:
|
||||
return True
|
||||
stem = 0
|
||||
while stem < min(len(a), len(b)) and a[stem] == b[stem]:
|
||||
stem += 1
|
||||
endings = INFLECTIONS.get(lang, set())
|
||||
return stem >= MIN_STEM and a[stem:] in endings and b[stem:] in endings
|
||||
|
||||
|
||||
def review_candidates(comparison, wordlist=None, glossary=(), lang="de"):
|
||||
def review_candidates(comparison, wordlist=None, known=()):
|
||||
"""Changes the model made on its own that may alter the meaning.
|
||||
|
||||
Not reported: changes of spacing, hyphens and punctuation only, glossary
|
||||
spellings of the old words, deleted filler words, and replacements of
|
||||
words not in the word list (recognition errors) that stay similar.
|
||||
Replaced real words that only change their form get their own reason.
|
||||
Not reported: changes of spacing, hyphens and punctuation only, new words
|
||||
from the glossary, deleted filler words, and replacements of words not in
|
||||
the word list (recognition errors) that stay similar.
|
||||
"""
|
||||
fillers = FILLERS.get(lang, set())
|
||||
suspects = []
|
||||
for tag, i1, i2, j1, j2 in comparison.opcodes:
|
||||
if tag == "equal":
|
||||
continue
|
||||
old, new = comparison.raw[i1:i2], comparison.words[j1:j2]
|
||||
if new and all(normalize(word) in known for word in new):
|
||||
continue
|
||||
if tag == "delete":
|
||||
if all(normalize(word) in fillers for word in old):
|
||||
if all(normalize(word) in FILLERS for word in old):
|
||||
continue
|
||||
reason = DELETED
|
||||
elif tag == "insert":
|
||||
reason = INSERTED
|
||||
else:
|
||||
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
|
||||
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
|
||||
elif similarity(old, new) < MIN_SIMILARITY:
|
||||
reason = VALID_WORD
|
||||
elif SequenceMatcher(None, old_key, new_key).ratio() < MIN_SIMILARITY:
|
||||
reason = CHANGED_A_LOT
|
||||
else:
|
||||
continue
|
||||
|
|
@ -249,9 +205,6 @@ del { background: var(--del); }
|
|||
.orig { color: var(--muted); font-size: 0.8em; }
|
||||
.check { background: var(--check); font-size: 0.85em; }
|
||||
.warn { outline: 2px solid var(--warn); outline-offset: 1px; border-radius: 2px; }
|
||||
.warn.form { outline-style: dashed; outline-width: 1px; }
|
||||
h3 { font-size: 1rem; margin-top: 1.5rem; }
|
||||
.scroll { overflow-x: auto; }
|
||||
td.ctx { color: var(--muted); } td.ctx b { color: var(--fg); }
|
||||
"""
|
||||
|
||||
|
|
@ -268,43 +221,28 @@ def render_html(comparison, title):
|
|||
for old, new, count in summary
|
||||
)
|
||||
suspects = comparison.suspects
|
||||
important, forms = split_suspects(suspects)
|
||||
|
||||
def table(rows):
|
||||
body = "\n".join(
|
||||
f"<tr><td>{esc(s.reason)}</td><td><del>{esc(s.old) or EMPTY}</del></td>"
|
||||
f"<td><ins>{esc(s.new) or EMPTY}</ins></td>"
|
||||
f"<td class=ctx>{suspect_context(comparison, s)}</td></tr>"
|
||||
for s in rows
|
||||
suspect_rows = "\n".join(
|
||||
f"<tr><td>{esc(s.reason)}</td><td><del>{esc(s.old) or EMPTY}</del></td>"
|
||||
f"<td><ins>{esc(s.new) or EMPTY}</ins></td>"
|
||||
f"<td class=ctx>{suspect_context(comparison, s)}</td></tr>"
|
||||
for s in suspects
|
||||
)
|
||||
if suspects:
|
||||
suspect_table = (
|
||||
"<table><tr><th>Grund</th><th>vorher</th><th>korrigiert</th>"
|
||||
f"<th>Zusammenhang</th></tr>\n{suspect_rows}\n</table>"
|
||||
)
|
||||
return (
|
||||
"<div class=scroll><table><tr><th>Grund</th><th>vorher</th><th>korrigiert</th>"
|
||||
f"<th>Zusammenhang</th></tr>\n{body}\n</table></div>"
|
||||
)
|
||||
|
||||
if important:
|
||||
suspect_table = table(important)
|
||||
elif forms:
|
||||
suspect_table = "<p class=muted>Keine sinnverändernden Änderungen gefunden.</p>"
|
||||
else:
|
||||
suspect_table = "<p class=muted>Keine auffälligen Änderungen gefunden.</p>"
|
||||
if forms:
|
||||
suspect_table += (
|
||||
f"\n<h3>Wortform geändert ({len(forms)})</h3>\n"
|
||||
"<p class=muted>Nur Endung oder Umlaut anders („Punkten“ → „Punkte“, "
|
||||
"„das“ → „dass“), meist Grammatik; ein kurzer Blick genügt.</p>\n"
|
||||
+ table(forms)
|
||||
)
|
||||
|
||||
# Suspect words are framed; a suspect deletion shows ⚠ after the word before it
|
||||
warn_words, warn_after = {}, {}
|
||||
for s in suspects:
|
||||
note = esc(f"{s.reason}: „{s.old or EMPTY}“ → „{s.new or EMPTY}“", quote=True)
|
||||
css = "warn form" if s.reason == WORD_FORM else "warn"
|
||||
if s.j1 == s.j2:
|
||||
warn_after.setdefault(max(s.j1 - 1, 0), []).append(note)
|
||||
for j in range(s.j1, s.j2):
|
||||
warn_words[j] = (css, note)
|
||||
warn_words[j] = note
|
||||
|
||||
# Corrected text with its paragraphs; each word knows its paragraph
|
||||
parts = []
|
||||
|
|
@ -316,8 +254,7 @@ def render_html(comparison, title):
|
|||
current = comparison.paragraph[j]
|
||||
parts.append("</p>\n<p>")
|
||||
if j in warn_words:
|
||||
css, note = warn_words[j]
|
||||
piece = f"<span class='{css}' title='{note}'>{piece}</span>"
|
||||
piece = f"<span class=warn title='{warn_words[j]}'>{piece}</span>"
|
||||
parts.append(piece)
|
||||
for mark in comparison.marks.get(j, []):
|
||||
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):
|
||||
"""HTML: the change in bold with a few corrected words around it."""
|
||||
esc = html.escape
|
||||
|
|
@ -397,18 +328,15 @@ def print_summary(comparison, limit=15, suspect_limit=10):
|
|||
if len(summary) > limit:
|
||||
print(f" … und {len(summary) - limit} weitere, siehe Bericht")
|
||||
|
||||
important, forms = split_suspects(comparison.suspects)
|
||||
# Meaning changes first, word forms after them
|
||||
suspects = important + forms
|
||||
of_which = f" (davon {len(forms)} Wortformen)" if forms else ""
|
||||
print(f"Zu prüfen: {len(suspects)} Stellen{of_which}")
|
||||
suspects = comparison.suspects
|
||||
print(f"Zu prüfen: {len(suspects)} Stellen")
|
||||
for s in suspects[:suspect_limit]:
|
||||
print(f" {s.reason}: „{s.old or EMPTY}“ → „{s.new or EMPTY}“")
|
||||
if len(suspects) > suspect_limit:
|
||||
print(f" … und {len(suspects) - suspect_limit} weitere, siehe Bericht")
|
||||
|
||||
|
||||
def write_report(raw, corrected, target, title, model_input=None, 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.
|
||||
|
||||
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)
|
||||
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")
|
||||
return comparison
|
||||
|
||||
|
|
@ -427,11 +355,6 @@ def report_path(corrected_path):
|
|||
return corrected_path.with_name(f"{stem}.aenderungen.html")
|
||||
|
||||
|
||||
def without_headers(text):
|
||||
"""Text without time headers ("[00:04:57 - 00:06:04]") of excerpts."""
|
||||
return "\n\n".join(body for _, body in split_segments(text))
|
||||
|
||||
|
||||
def read_raw(path):
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
if path.suffix.lower() == ".vtt":
|
||||
|
|
@ -474,7 +397,6 @@ def parse_args():
|
|||
def main():
|
||||
# abschrift_korrigieren imports this module: import it only when needed
|
||||
from abschrift_korrigieren import apply_replacements, read_glossary
|
||||
from yt_untertitel import base_language
|
||||
|
||||
args = parse_args()
|
||||
raw = read_raw(args.roh)
|
||||
|
|
@ -487,13 +409,10 @@ def main():
|
|||
model_input = apply_replacements(" ".join(raw.split()), replacements)
|
||||
|
||||
corrected = args.korrigiert.read_text(encoding="utf-8")
|
||||
# A raw .vtt has no time headers: those of the excerpts are no insertions
|
||||
if without_headers(raw) == raw.strip():
|
||||
corrected = without_headers(corrected)
|
||||
comparison = write_report(
|
||||
raw, corrected, args.output, args.korrigiert.name,
|
||||
model_input=model_input, glossary=glossary_entries(terms, replacements),
|
||||
lang=base_language(args.lang),
|
||||
model_input=model_input, known=glossary_words(terms, replacements),
|
||||
lang=args.lang,
|
||||
)
|
||||
print_summary(comparison)
|
||||
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
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
# Own, made-up sample speech; real speeches are only kept locally in testdaten/
|
||||
SAMPLE = REPO / "tests" / "daten" / "beispiel.de.txt"
|
||||
GLOSSARY = REPO / "tests" / "daten" / "beispiel.glossar.txt"
|
||||
SAMPLE = REPO / "testdaten" / "ramstein.de.txt"
|
||||
GLOSSARY = REPO / "ramstein.glossar.txt"
|
||||
SHARED_GLOSSARY = REPO / "krone-schmalz.glossar.txt"
|
||||
SPEECHES = REPO / "testdaten"
|
||||
# The autouse fixture below replaces it; tests of the real function use this
|
||||
REAL_FIND_MERGE_SIDE = ak.find_merge_side
|
||||
|
||||
|
|
@ -119,10 +117,6 @@ def test_build_system_appends_terms():
|
|||
system = ak.build_system(["Kreml", "Egon Bahr"])
|
||||
assert system.startswith(ak.SYSTEM)
|
||||
assert "- Kreml\n- Egon Bahr" in system
|
||||
# The model must not complete names from the glossary
|
||||
assert "Ergänze keine Vornamen" in system
|
||||
assert "bleibt es beim Nachnamen" in ak.SYSTEM
|
||||
assert "keep only the surname" in ak.SYSTEM_EN
|
||||
|
||||
|
||||
SAMPLE_GLOSSARIES = [
|
||||
|
|
@ -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)
|
||||
def test_sample_glossary_replacements_all_match(capsys, glossary, sample):
|
||||
"""Every rule in a speech glossary must hit its speech, after the shared one."""
|
||||
text = flat((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])
|
||||
capsys.readouterr()
|
||||
ak.apply_replacements(text, ak.read_glossary(REPO / glossary)[1])
|
||||
assert "ohne Treffer" not in capsys.readouterr().out
|
||||
|
||||
|
||||
@needs_speeches
|
||||
def test_shared_glossary_rules_match_some_speech():
|
||||
texts = [
|
||||
flat((SPEECHES / sample).read_text(encoding="utf-8"))
|
||||
flat((REPO / "testdaten" / sample).read_text(encoding="utf-8"))
|
||||
for _, sample in SAMPLE_GLOSSARIES
|
||||
]
|
||||
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
|
||||
def sample_copy(tmp_path):
|
||||
path = tmp_path / "beispiel.de.txt"
|
||||
path = tmp_path / "ramstein.de.txt"
|
||||
shutil.copy(SAMPLE, path)
|
||||
return path
|
||||
|
||||
|
|
@ -420,92 +396,22 @@ def sample_copy(tmp_path):
|
|||
def test_main_default_output_resume_and_glossary(sample_copy, monkeypatch, capsys):
|
||||
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||
run_main(monkeypatch, "-i", sample_copy, "-g", 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")
|
||||
assert "Auental" in result and "Auen Tal" not in result
|
||||
assert "Haberkamp" in result and "1274" in result
|
||||
assert (sample_copy.parent / "beispiel.de.korrigiert.parts").is_dir()
|
||||
assert "Ramstein" in result and "Rammstein" not in result
|
||||
assert "Kreml" in result and "1983" in result
|
||||
assert (sample_copy.parent / "ramstein.de.korrigiert.parts").is_dir()
|
||||
assert "WARNUNG" not in capsys.readouterr().out
|
||||
|
||||
# Second run: everything cached, no model calls
|
||||
monkeypatch.setattr(ak, "correct_chunk", no_model_calls)
|
||||
monkeypatch.setattr(ak, "find_paragraph_starts", no_model_calls)
|
||||
run_main(monkeypatch, "-i", sample_copy, "-g", GLOSSARY)
|
||||
run_main(monkeypatch, "-i", sample_copy, "-g", SHARED_GLOSSARY, "-g", GLOSSARY)
|
||||
assert target.read_text(encoding="utf-8") == result
|
||||
|
||||
|
||||
def shouting(chunk, index, system):
|
||||
"""A different model result than identity_correct."""
|
||||
return chunk.strip().replace("Vielen Dank", "VIELEN DANK")
|
||||
|
||||
|
||||
def test_main_overwrites_own_output(sample_copy, monkeypatch, capsys):
|
||||
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||
run_main(monkeypatch, "-i", sample_copy)
|
||||
target = sample_copy.with_name("beispiel.de.korrigiert.txt")
|
||||
|
||||
# Unchanged output of the last run is replaced by a different result
|
||||
monkeypatch.setattr(ak, "correct_chunk", shouting)
|
||||
run_main(monkeypatch, "-i", sample_copy, "-m", "anderes:modell")
|
||||
assert "VIELEN DANK" in target.read_text(encoding="utf-8")
|
||||
assert not ak.new_output_path(target).exists()
|
||||
assert "WARNUNG" not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_main_keeps_hand_edited_output(sample_copy, monkeypatch, capsys):
|
||||
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||
run_main(monkeypatch, "-i", sample_copy)
|
||||
target = sample_copy.with_name("beispiel.de.korrigiert.txt")
|
||||
edited = target.read_text(encoding="utf-8") + "Von Hand ergänzt.\n"
|
||||
target.write_text(edited, encoding="utf-8")
|
||||
capsys.readouterr()
|
||||
|
||||
monkeypatch.setattr(ak, "correct_chunk", shouting)
|
||||
run_main(monkeypatch, "-i", sample_copy, "-m", "anderes:modell")
|
||||
|
||||
new = sample_copy.with_name("beispiel.de.neu.korrigiert.txt")
|
||||
assert target.read_text(encoding="utf-8") == edited
|
||||
assert "VIELEN DANK" in new.read_text(encoding="utf-8")
|
||||
assert sample_copy.with_name("beispiel.de.neu.aenderungen.html").is_file()
|
||||
out = capsys.readouterr().out
|
||||
assert f"WARNUNG: {target} wurde seit dem letzten Lauf geändert" in out
|
||||
assert f"Fertig: {new}" in out
|
||||
|
||||
# --ueberschreiben replaces the edited file
|
||||
run_main(monkeypatch, "-i", sample_copy, "-m", "anderes:modell", "--ueberschreiben")
|
||||
assert "VIELEN DANK" in target.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_main_keeps_differing_output_without_checksum(sample_copy, monkeypatch, capsys):
|
||||
target = sample_copy.with_name("beispiel.de.korrigiert.txt")
|
||||
target.write_text("Älteres Ergebnis, Herkunft unbekannt.\n", encoding="utf-8")
|
||||
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||
run_main(monkeypatch, "-i", sample_copy)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "Älteres Ergebnis, Herkunft unbekannt.\n"
|
||||
assert sample_copy.with_name("beispiel.de.neu.korrigiert.txt").is_file()
|
||||
assert "WARNUNG" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name, expected", [
|
||||
("rede.korrigiert.txt", "rede.neu.korrigiert.txt"),
|
||||
("out.txt", "out.neu.txt"),
|
||||
])
|
||||
def test_new_output_path(name, expected):
|
||||
assert ak.new_output_path(Path("a") / name) == Path("a") / expected
|
||||
|
||||
|
||||
def test_choose_output_same_content_is_no_conflict(tmp_path):
|
||||
target = tmp_path / "rede.korrigiert.txt"
|
||||
target.write_text("gleich\n", encoding="utf-8")
|
||||
assert ak.choose_output(target, "gleich\n", tmp_path / "fehlt.parts") == target
|
||||
|
||||
|
||||
def test_main_output_option_creates_directories(sample_copy, monkeypatch):
|
||||
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||
|
|
@ -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):
|
||||
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||
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()
|
||||
shutil.copy(SAMPLE.with_suffix(".vtt"), vtt)
|
||||
|
||||
run_main(monkeypatch, "-i", sample_copy)
|
||||
run_main(monkeypatch, "-i", vtt)
|
||||
expected = sample_copy.with_name("beispiel.de.korrigiert.txt")
|
||||
assert vtt.with_name("beispiel.de.korrigiert.txt").read_text(
|
||||
expected = sample_copy.with_name("ramstein.de.korrigiert.txt")
|
||||
assert vtt.with_name("ramstein.de.korrigiert.txt").read_text(
|
||||
encoding="utf-8"
|
||||
) == expected.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_main_writes_change_report(sample_copy, monkeypatch, capsys):
|
||||
def correct(chunk, index, system):
|
||||
return chunk.strip().replace("Linden Brunn", "Lindenbrunn")
|
||||
return chunk.strip().replace("Rammstein", "Ramstein")
|
||||
|
||||
monkeypatch.setattr(ak, "correct_chunk", correct)
|
||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||
run_main(monkeypatch, "-i", sample_copy)
|
||||
|
||||
report = sample_copy.with_name("beispiel.de.aenderungen.html")
|
||||
assert "title='Original: Linden Brunn" in report.read_text(encoding="utf-8")
|
||||
report = sample_copy.with_name("ramstein.de.aenderungen.html")
|
||||
assert "title='Original: Rammstein" in report.read_text(encoding="utf-8")
|
||||
out = capsys.readouterr().out
|
||||
assert "„Linden Brunn“ → „Lindenbrunn“" in out
|
||||
assert "„Rammstein“ → „Ramstein“" in out
|
||||
assert f"Bericht: {report}" in out
|
||||
|
||||
|
||||
def test_main_report_suspects_only_model_changes(sample_copy, monkeypatch, capsys):
|
||||
glossary = sample_copy.with_name("g.txt")
|
||||
glossary.write_text("Linden Brunn => Lindenbrunn\n", encoding="utf-8")
|
||||
glossary.write_text("Rammstein => Ramstein\n", encoding="utf-8")
|
||||
|
||||
def correct(chunk, index, system):
|
||||
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))
|
||||
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]
|
||||
assert "Linden Brunn" in page and "Linden Brunn" not in section
|
||||
assert "Rammstein" in page and "Rammstein" not in section
|
||||
assert "dies" in section
|
||||
out = capsys.readouterr().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, "find_paragraph_starts", every_nth_start(5))
|
||||
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):
|
||||
|
|
@ -582,7 +488,7 @@ def test_main_model_option(sample_copy, monkeypatch):
|
|||
|
||||
monkeypatch.setattr(ak, "correct_chunk", correct)
|
||||
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||
parts = sample_copy.parent / "beispiel.de.korrigiert.parts"
|
||||
parts = sample_copy.parent / "ramstein.de.korrigiert.parts"
|
||||
|
||||
run_main(monkeypatch, "-i", sample_copy)
|
||||
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))
|
||||
|
||||
|
||||
def kreml_glossary(folder):
|
||||
"""The one glossary rule the fake video needs."""
|
||||
path = folder / "kreml.glossar.txt"
|
||||
path.write_text("Kremmel => Kreml\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_main_url_default_names(from_url, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
run_main(monkeypatch, "-u", URL, "-g", 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")
|
||||
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")
|
||||
assert system.startswith(ak.SYSTEM_EN)
|
||||
assert "Glossary:" in system and "- Mearsheimer" in system
|
||||
assert "do not add first" in system
|
||||
|
||||
|
||||
def test_main_english_prompts(sample_copy, monkeypatch, capsys):
|
||||
|
|
@ -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))
|
||||
sent = chunk_recorder(monkeypatch)
|
||||
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"
|
||||
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"}
|
||||
|
||||
|
||||
def suspects(model_input, corrected, wordlist=WORDS, glossary=(), lang="de"):
|
||||
found = ae.review_candidates(ae.compare(model_input, corrected), wordlist, glossary, lang)
|
||||
def suspects(model_input, corrected, wordlist=WORDS, known=()):
|
||||
found = ae.review_candidates(ae.compare(model_input, corrected), wordlist, set(known))
|
||||
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) == []
|
||||
|
||||
|
||||
def test_review_candidates_glossary_spellings_are_not_reported():
|
||||
glossary = ae.glossary_entries(["Egon Bahr", ""], [("Bars", "Bahrs")])
|
||||
assert glossary == {("egon", "bahr"), ("bahrs",)}
|
||||
assert suspects("Bar sagte", "Bahr sagte", glossary=glossary) == []
|
||||
def test_review_candidates_glossary_words_are_known():
|
||||
known = ae.glossary_words(["Egon Bahr"], [("Bars", "Bahrs")])
|
||||
assert known == {"egon", "bahr", "bahrs"}
|
||||
assert suspects("Bar sagte", "Bahr sagte", known=known) == []
|
||||
assert suspects("Bar sagte", "Bahr sagte") == [(ae.VALID_WORD, "Bar", "Bahr")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_input, corrected, expected", [
|
||||
# A common word of a longer entry explains nothing on its own
|
||||
("das sagt ihn nicht", "Das sagt Sie nicht.", [(ae.CHANGED_A_LOT, "ihn", "Sie")]),
|
||||
# Inserted words stay suspect even if they are in the glossary
|
||||
("er kam heute", "er ist kam heute", [(ae.INSERTED, "", "ist")]),
|
||||
("Dobrind sagte", "Alexander Dobrindt sagte",
|
||||
[(ae.CHANGED_A_LOT, "Dobrind", "Alexander Dobrindt")]),
|
||||
])
|
||||
def test_review_candidates_glossary_explains_only_spellings(model_input, corrected, expected):
|
||||
glossary = ae.glossary_entries(
|
||||
["Alexander Dobrindt"], [("befinden Sie", "finden Sie"), ("is", "ist")]
|
||||
)
|
||||
assert suspects(model_input, corrected, glossary=glossary) == expected
|
||||
|
||||
|
||||
def test_review_candidates_fillers_per_language():
|
||||
# "er" and "um" are real German words; in English they are fillers
|
||||
assert suspects("dann kam er um zu helfen", "Dann kam, zu helfen.") == [
|
||||
(ae.DELETED, "er um", "")
|
||||
]
|
||||
english = suspects("so er we um go", "So we go.", lang="en")
|
||||
assert english == []
|
||||
|
||||
|
||||
def test_review_candidates_without_wordlist():
|
||||
# Similar replacements of real words go unnoticed, the rest still counts
|
||||
assert suspects("Der Professor kam", "Der Professorin kam", wordlist=None) == []
|
||||
|
|
@ -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 "Zu prüfen (2)" in page
|
||||
assert "Professorin" in section and "Kremmel" not in section
|
||||
assert "<span class='warn' title='gültiges Wort ersetzt: „Professor“ → „Professorin“'>" in page
|
||||
assert "<span class=warn title='gültiges Wort ersetzt: „Professor“ → „Professorin“'>" in page
|
||||
assert "<span class=warn title='gestrichen: „heute“ → „∅“'>⚠</span>" in page
|
||||
# Framing changes nothing about the text itself
|
||||
assert report_text(page) == "Der Professorin im Kreml ⚠ dort.".split()
|
||||
|
|
@ -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"))
|
||||
|
||||
|
||||
def test_cli_ignores_time_headers_missing_in_raw(tmp_path, monkeypatch, capsys):
|
||||
raw = tmp_path / "rede.vtt"
|
||||
raw.write_text(
|
||||
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\ndas Kremmel heute\n", encoding="utf-8"
|
||||
)
|
||||
corrected = tmp_path / "rede.korrigiert.txt"
|
||||
corrected.write_text("[00:00:01 - 00:00:02]\n\nDas Kreml heute.\n", encoding="utf-8")
|
||||
monkeypatch.setattr(sys, "argv", ["aenderungen.py", "-a", str(raw), "-b", str(corrected)])
|
||||
ae.main()
|
||||
out = capsys.readouterr().out
|
||||
assert "Zu prüfen: 0 Stellen" in out
|
||||
assert "00:00:01" not in out
|
||||
|
||||
|
||||
def test_cli_glossary(tmp_path, monkeypatch, capsys):
|
||||
raw = tmp_path / "rede.txt"
|
||||
raw.write_text("das Kremmel und\nBar heute\n", encoding="utf-8")
|
||||
|
|
@ -273,51 +235,3 @@ def test_cli_glossary(tmp_path, monkeypatch, capsys):
|
|||
assert "Zu prüfen: 0 Stellen" in out
|
||||
# The report still compares against the raw transcript
|
||||
assert "1× „Kremmel“ → „Kreml“" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("old, new, expected", [
|
||||
("Punkten", "Punkte", True),
|
||||
("das", "dass", True),
|
||||
("sehe", "sähe", True), # umlaut
|
||||
("sein", "seien", True), # both endings change
|
||||
("kommt,", "kommen,", True), # punctuation does not count
|
||||
("Professor", "Professorin", False), # "-in" is no inflection
|
||||
("geworben", "geworden", False),
|
||||
("unterschreiben", "unterscheiden", False),
|
||||
("ich", "sich", False),
|
||||
("den", "der", False), # stem too short
|
||||
("sich dazu", "zu", False), # more than one word
|
||||
])
|
||||
def test_word_form_change(old, new, expected):
|
||||
assert ae.word_form_change(old.split(), new.split()) is expected
|
||||
|
||||
|
||||
def test_word_form_change_english():
|
||||
assert ae.word_form_change(["walk"], ["walked"], "en")
|
||||
assert not ae.word_form_change(["walk"], ["walker"], "en")
|
||||
|
||||
|
||||
def test_review_candidates_word_forms():
|
||||
words = WORDS | {"punkten", "punkte"}
|
||||
assert suspects("die Punkten und das", "die Punkte und dass", wordlist=words) == [
|
||||
(ae.WORD_FORM, "Punkten", "Punkte"), (ae.WORD_FORM, "das", "dass"),
|
||||
]
|
||||
|
||||
|
||||
def test_report_and_summary_put_word_forms_last(tmp_path, capsys):
|
||||
target = tmp_path / "bericht.html"
|
||||
comparison = ae.write_report(
|
||||
"das sagte der Professor", "dass sagte der Professorin", target, "rede"
|
||||
)
|
||||
assert [s.reason for s in comparison.suspects] == [ae.WORD_FORM, ae.VALID_WORD]
|
||||
page = target.read_text(encoding="utf-8")
|
||||
section = suspect_section(page)
|
||||
# Meaning change in the main table, the word form in its own group after it
|
||||
assert section.index("Professorin") < section.index("Wortform geändert (1)")
|
||||
assert section.index("Wortform geändert (1)") < section.index("<ins>dass</ins>")
|
||||
assert "<span class='warn form' title='Wortform geändert: „das“ → „dass“'>" in page
|
||||
|
||||
ae.print_summary(comparison)
|
||||
out = capsys.readouterr().out.split("Zu prüfen: ")[1]
|
||||
assert out.startswith("2 Stellen (davon 1 Wortformen)")
|
||||
assert out.index("Professorin") < out.index("„das“ → „dass“")
|
||||
|
|
|
|||
|
|
@ -8,10 +8,8 @@ import pytest
|
|||
import vtt2txt
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
# Own, made-up sample speech; real speeches are only kept locally in testdaten/
|
||||
SAMPLE_VTT = REPO / "tests" / "daten" / "beispiel.de.vtt"
|
||||
SAMPLE_TXT = REPO / "tests" / "daten" / "beispiel.de.txt"
|
||||
SPEECHES = REPO / "testdaten"
|
||||
SAMPLE_VTT = REPO / "testdaten" / "ramstein.de.vtt"
|
||||
SAMPLE_TXT = REPO / "testdaten" / "ramstein.de.txt"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -109,27 +107,15 @@ def test_is_rolling(cues, expected):
|
|||
|
||||
def test_main_default_output_matches_sample(sample_copy, monkeypatch, capsys):
|
||||
run_main(monkeypatch, "-i", sample_copy)
|
||||
target = sample_copy.with_name("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 f"Erstellt: {target}" in capsys.readouterr().out
|
||||
|
||||
|
||||
def speech(name):
|
||||
"""A real speech, only kept locally (copyright): skipped without it."""
|
||||
path = SPEECHES / name
|
||||
return pytest.param(path, marks=pytest.mark.skipif(
|
||||
not path.is_file(), reason="echte Reden nur lokal in testdaten/"
|
||||
))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vtt_path", [
|
||||
SAMPLE_VTT,
|
||||
speech("ramstein.de.vtt"), speech("magdeburg.de-orig.vtt"),
|
||||
speech("Lahnstein.de-orig.vtt"),
|
||||
])
|
||||
def test_sample_vtt_gives_sample_txt(vtt_path):
|
||||
vtt = vtt_path.read_text(encoding="utf-8-sig")
|
||||
txt = vtt_path.with_suffix(".txt").read_text(encoding="utf-8")
|
||||
@pytest.mark.parametrize("name", ["ramstein.de", "magdeburg.de-orig", "Lahnstein.de-orig"])
|
||||
def test_sample_vtt_gives_sample_txt(name):
|
||||
vtt = (REPO / "testdaten" / f"{name}.vtt").read_text(encoding="utf-8-sig")
|
||||
txt = (REPO / "testdaten" / f"{name}.txt").read_text(encoding="utf-8")
|
||||
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):
|
||||
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"))
|
||||
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
|
||||
assert out.count("Erstellt:") == 2
|
||||
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:
|
||||
out_dir.mkdir()
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -190,7 +176,7 @@ def test_main_bad_file_continues_batch(sample_copy, monkeypatch, capsys, content
|
|||
(["-i", "{txt}"], "Keine VTT-Datei"),
|
||||
(["-i", "{vtt}", "{vtt2}", "-o", "{dir}/x.txt"], "muss -o/--output ein Verzeichnis sein"),
|
||||
(["-i", "{dir}/*.gibtsnicht"], "Keine Datei passt zu"),
|
||||
(["-i", "{vtt}", "{dir}/sub/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"),
|
||||
])
|
||||
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."
|
||||
out = capsys.readouterr().out
|
||||
assert "keine Untertitel im Bereich 00:00:00 – 00:00:50" in out
|
||||
assert "keine Untertitel im Bereich 00:03:20 – Ende (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)]) == ""
|
||||
|
||||
|
||||
def test_vtt_to_text_notes_range_past_last_caption(capsys):
|
||||
# "-z 0-05:00" means five hours, not five minutes
|
||||
text = vtt2txt.vtt_to_text(SPEECH, [(0, 5 * 3600)])
|
||||
assert text.startswith("[00:01:40 - ")
|
||||
out = capsys.readouterr().out
|
||||
assert "Bereich 00:00:00 – 05:00:00 reicht über das Ende der Untertitel hinaus" in out
|
||||
assert "Stunden:Minuten, Minute 5 ist 00:05" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start, end, expected", [
|
||||
(100, 110, ""), # inside
|
||||
(None, None, ""),
|
||||
(100, 200, " (die Untertitel enden bei 00:02:00)"),
|
||||
(150, None, " (die Untertitel enden bei 00:02:00)"),
|
||||
(0, 3600, " (die Untertitel enden bei 00:02:00; zwei Zahlen wie 05:00 sind "
|
||||
"Stunden:Minuten, Minute 5 ist 00:05)"),
|
||||
(0, 200, ""), # no captions at all: no end known
|
||||
])
|
||||
def test_end_hint(start, end, expected):
|
||||
captions_end = None if (start, end) == (0, 200) else 120
|
||||
assert vtt2txt.end_hint(start, end, captions_end) == expected
|
||||
|
||||
|
||||
def test_vtt_to_text_without_ranges_has_no_header():
|
||||
assert vtt2txt.vtt_to_text(SPEECH).splitlines()[0] == "Erster Satz ist hier."
|
||||
|
||||
|
|
@ -431,7 +394,7 @@ def test_split_segments_inverts_join_segments():
|
|||
|
||||
def test_main_time_range_default_name(sample_copy, monkeypatch):
|
||||
run_main(monkeypatch, "-i", sample_copy, "-z", "0:00:30-0:01")
|
||||
target = sample_copy.with_name("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()
|
||||
part = target.read_text(encoding="utf-8").splitlines()
|
||||
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):
|
||||
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"))
|
||||
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"]])
|
||||
def test_main_ranges_with_or_without_quotes(sample_copy, monkeypatch, ranges):
|
||||
run_main(monkeypatch, "-i", sample_copy, "-z", *ranges)
|
||||
target = sample_copy.with_name("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()
|
||||
|
||||
|
||||
|
|
@ -470,8 +433,8 @@ def test_range_argv(argv, expected):
|
|||
def test_main_range_from_beginning(sample_copy, monkeypatch, ranges):
|
||||
args = ranges if ranges[0].startswith("-z") else ["-z", *ranges]
|
||||
run_main(monkeypatch, "-i", sample_copy, *args)
|
||||
target = sample_copy.with_name("beispiel.de_bis_00-01-00.txt")
|
||||
assert target.read_text(encoding="utf-8").startswith("[00:00:00 - ")
|
||||
target = sample_copy.with_name("ramstein.de_bis_00-01-00.txt")
|
||||
assert target.read_text(encoding="utf-8").startswith("[00:00:09 - ")
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
def end_hint(start, end, captions_end):
|
||||
"""Note for a range reaching past the last caption, else ""; two numbers
|
||||
are hours:minutes, so "05:00" for minute 5 is a likely slip."""
|
||||
if captions_end is None:
|
||||
return ""
|
||||
past_end = (end is not None and end > captions_end) or (
|
||||
start is not None and start >= captions_end
|
||||
)
|
||||
if not past_end:
|
||||
return ""
|
||||
hint = f" (die Untertitel enden bei {format_time(captions_end)}"
|
||||
if max(start or 0, end or 0) >= 3600 > captions_end:
|
||||
hint += "; zwei Zahlen wie 05:00 sind Stunden:Minuten, Minute 5 ist 00:05"
|
||||
return hint + ")"
|
||||
|
||||
|
||||
def excerpts(timed, ranges, exact=False):
|
||||
"""Return (start, end in seconds, lines) of the text block of each time range.
|
||||
|
||||
Blocks that overlap after growing to whole sentences are merged; ranges
|
||||
without captions are skipped with a note, ranges reaching past the last
|
||||
caption get one.
|
||||
without captions are skipped with a note.
|
||||
"""
|
||||
captions_end = max((end for _, end, _ in timed if end is not None), default=None)
|
||||
bounds = []
|
||||
for start, end in ranges:
|
||||
found = excerpt_bounds(timed, start, end, exact)
|
||||
hint = end_hint(start, end, captions_end)
|
||||
if found is None:
|
||||
print(f"Hinweis: keine Untertitel im Bereich {range_label(start, end)}{hint}")
|
||||
print(f"Hinweis: keine Untertitel im Bereich {range_label(start, end)}")
|
||||
continue
|
||||
if hint:
|
||||
print(
|
||||
f"Hinweis: Bereich {range_label(start, end)} reicht über das Ende "
|
||||
f"der Untertitel hinaus{hint}"
|
||||
)
|
||||
if bounds and found[0] < bounds[-1][1]:
|
||||
bounds[-1] = (bounds[-1][0], max(bounds[-1][1], found[1]))
|
||||
else:
|
||||
|
|
|
|||
Loading…
Reference in a new issue