Compare commits

...
Author SHA1 Message Date
6f3eb23547 docs: Dateiablage mit Arbeitsordnern, Abschluss und Video-Glossaren
README: neue Abschnitte „Dateiablage“ und „Abschluss: abschliessen.py“,
Standardpfade abschriften/<Name>/ in allen Beispielen und Optionstabellen,
gemeinsame und Video-Glossare, testdaten/<rede>/, Tests und Einstellungen.

Bedienungsanleitung: neuer Abschnitt 10 „Schritt 6: Abschließen und
aufräumen“ (folgende Abschnitte umnummeriert), Ablaufdiagramm und
Schnellstart mit Arbeitsordner und Abschluss, Glossar-Arten, Dateien je
Arbeitsordner, Hinweis „im Programmordner arbeiten“, Sicherung von
glossare/ und fertig/, Fehlermeldungen und Kurzreferenz von abschliessen.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:33:09 +02:00
d66f185fdd fix: abschliessen.py zeigt kleine Dateien in Byte statt „0 KB“
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:30:29 +02:00
775560a64c test: echte Reden liegen lokal in testdaten/<rede>/ mit ihrem Glossar
testdaten/ enthält je Rede einen Ordner mit .vtt, .txt und dem Glossar
der Rede, keine Ausgaben mehr; gemeinsame Glossare bleiben in glossare/.
Die lokalen Rede-Tests lesen von dort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:27:11 +02:00
ebaec63963 feat: abschliessen.py – fertige Abschrift nach fertig/, Arbeitsordner löschen
Kopiert die korrigierte(n) Abschrift(en) eines Arbeitsordners mit
Quellenkopf (Titel, Kanal, Datum, URL, Abschlussdatum) nach fertig/,
listet Einträge des Video-Glossars, die in keinem gemeinsamen Glossar
stehen, und löscht den Ordner nach Rückfrage (--ja ohne).

Schutz: Abbruch bei offenen Prüfmarken (--trotzdem) und ungeklärten
*.neu.korrigiert.txt; fertig/ wird nie überschrieben; nicht gelöscht
werden Ordner mit fremden Dateien, das Aufrufverzeichnis und alles in
testdaten/, glossare/, tests/, fertig/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:26:14 +02:00
3aa04fff4b feat: Arbeitsordner je Video, Quellenangabe und automatisches Video-Glossar
- Ohne -o/-d schreiben yt_untertitel.py, extract_text.py und
  abschrift_korrigieren.py -u in abschriften/<Name>/ (ein Ordner je
  Video, auch für alle Ausschnitte).
- Beim Download entsteht neben dem Rohtext <Rohtext>.quelle.json mit
  Titel, Kanal, Datum und URL, für den Quellenkopf des fertigen Textes.
- Ein Glossar, das wie sein Ordner heißt (abschriften/<Name>/<Name>.glossar.txt),
  nutzen abschrift_korrigieren.py, glossar_vorschlag.py und aenderungen.py
  automatisch, nach den mit -g angegebenen gemeinsamen Glossaren.
- .gitignore: abschriften/, fertig/, *.quelle.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:24:32 +02:00
16 changed files with 984 additions and 173 deletions

5
.gitignore vendored
View file

@ -13,6 +13,11 @@ __pycache__/
# Eigene Ergebnisse und Testläufe # Eigene Ergebnisse und Testläufe
output/ output/
# Arbeitsordner je Video, fertige Abschriften und Quellenangaben: nur lokal
abschriften/
fertig/
*.quelle.json
# Echte Reden (Urheberrecht): nur lokal; die Tests nutzen tests/daten/ # Echte Reden (Urheberrecht): nur lokal; die Tests nutzen tests/daten/
testdaten/ testdaten/

View file

@ -16,14 +16,15 @@ ist. Eine knappe technische Übersicht steht in der [README](README.md).
7. [Schritt 3: Korrigieren](#7-schritt-3-korrigieren) 7. [Schritt 3: Korrigieren](#7-schritt-3-korrigieren)
8. [Schritt 4: Ergebnis prüfen](#8-schritt-4-ergebnis-prüfen) 8. [Schritt 4: Ergebnis prüfen](#8-schritt-4-ergebnis-prüfen)
9. [Schritt 5: Nachbessern und erneut laufen lassen](#9-schritt-5-nachbessern-und-erneut-laufen-lassen) 9. [Schritt 5: Nachbessern und erneut laufen lassen](#9-schritt-5-nachbessern-und-erneut-laufen-lassen)
10. [Nur Ausschnitte bearbeiten (Zeitauswahl)](#10-nur-ausschnitte-bearbeiten-zeitauswahl) 10. [Schritt 6: Abschließen und aufräumen](#10-schritt-6-abschließen-und-aufräumen)
11. [Englische Beiträge](#11-englische-beiträge) 11. [Nur Ausschnitte bearbeiten (Zeitauswahl)](#11-nur-ausschnitte-bearbeiten-zeitauswahl)
12. [Dateien, die entstehen](#12-dateien-die-entstehen) 12. [Englische Beiträge](#12-englische-beiträge)
13. [Zwischenstände, Abbruch und Fortsetzen](#13-zwischenstände-abbruch-und-fortsetzen) 13. [Dateien, die entstehen](#13-dateien-die-entstehen)
14. [Ein anderes Sprachmodell verwenden](#14-ein-anderes-sprachmodell-verwenden) 14. [Zwischenstände, Abbruch und Fortsetzen](#14-zwischenstände-abbruch-und-fortsetzen)
15. [Fehlermeldungen und Lösungen](#15-fehlermeldungen-und-lösungen) 15. [Ein anderes Sprachmodell verwenden](#15-ein-anderes-sprachmodell-verwenden)
16. [Tipps und häufige Fragen](#16-tipps-und-häufige-fragen) 16. [Fehlermeldungen und Lösungen](#16-fehlermeldungen-und-lösungen)
17. [Kurzreferenz aller Befehle](#17-kurzreferenz-aller-befehle) 17. [Tipps und häufige Fragen](#17-tipps-und-häufige-fragen)
18. [Kurzreferenz aller Befehle](#18-kurzreferenz-aller-befehle)
--- ---
@ -57,9 +58,15 @@ Die Werkzeuge im Überblick:
| `vtt2txt.py` | Untertiteldatei (`.vtt`) → Rohtext | | `vtt2txt.py` | Untertiteldatei (`.vtt`) → Rohtext |
| `glossar_vorschlag.py` | Entwurf für ein Glossar (vermutete Hörfehler bei Namen und Begriffen) | | `glossar_vorschlag.py` | Entwurf für ein Glossar (vermutete Hörfehler bei Namen und Begriffen) |
| `aenderungen.py` | Änderungsbericht für vorhandene Dateien erstellen | | `aenderungen.py` | Änderungsbericht für vorhandene Dateien erstellen |
| `abschliessen.py` | fertige Abschrift mit Quellenangabe nach `fertig/`, Arbeitsordner aufräumen |
Für den Normalfall braucht man nur `abschrift_korrigieren.py`, für bessere Für den Normalfall braucht man nur `abschrift_korrigieren.py` und am Ende
Ergebnisse zusätzlich ein Glossar. `abschliessen.py`, für bessere Ergebnisse zusätzlich ein Glossar.
**Wo die Dateien liegen:** Jedes Video bekommt einen eigenen Arbeitsordner
`abschriften/<Name>/`, in dem alles zu diesem Video entsteht. Ist die Abschrift
fertig, bleibt nur die Ergebnisdatei in `fertig/`. Gemeinsame Glossare liegen in
`glossare/`. Details in [Abschnitt 13](#13-dateien-die-entstehen).
--- ---
@ -95,7 +102,7 @@ Environment="OLLAMA_NUM_PARALLEL=1"
Mit `OLLAMA_NUM_PARALLEL=4` landet ein Drittel des Modells auf der CPU, und ein Mit `OLLAMA_NUM_PARALLEL=4` landet ein Drittel des Modells auf der CPU, und ein
Lauf dauert etwa zwölfmal so lange. Mit weniger Grafikspeicher hilft ein kleineres Lauf dauert etwa zwölfmal so lange. Mit weniger Grafikspeicher hilft ein kleineres
Modell (siehe [Abschnitt 14](#14-ein-anderes-sprachmodell-verwenden)), auf Kosten Modell (siehe [Abschnitt 15](#15-ein-anderes-sprachmodell-verwenden)), auf Kosten
der Qualität. der Qualität.
### 2.3 Prüfen, ob alles bereit ist ### 2.3 Prüfen, ob alles bereit ist
@ -117,6 +124,10 @@ Lassen sich die Programme nicht mit `./…` starten („Keine Berechtigung“),
ausführbar machen: `chmod +x *.py`. Alternativ immer `python3 programm.py …` ausführbar machen: `chmod +x *.py`. Alternativ immer `python3 programm.py …`
aufrufen. aufrufen.
**Immer im Programmordner arbeiten.** Die Ordner `abschriften/`, `fertig/` und
`glossare/` werden relativ zum aktuellen Verzeichnis angelegt bzw. gesucht. Wer die
Programme aus einem anderen Verzeichnis startet, bekommt dort eigene, leere Ordner.
--- ---
## 3. Schnellstart ## 3. Schnellstart
@ -133,16 +144,25 @@ URLs die Shell.
Das Programm Das Programm
1. lädt die Untertitel in der Originalsprache des Videos, 1. lädt die Untertitel in der Originalsprache des Videos,
2. speichert den Rohtext, z. B. `GGBLahnstein_Prof_Gabriele_Krone-Schmalz_Respekt_geht_anders_2026-07-30.txt`, 2. legt den Arbeitsordner an, z. B.
3. korrigiert ihn abschnittsweise und gliedert ihn in Absätze, `abschriften/GGBLahnstein_Prof_Gabriele_Krone-Schmalz_Respekt_geht_anders_2026-07-30/`
4. schreibt das Ergebnis nach `…_2026-07-30.korrigiert.txt` (kurz `abschriften/<Name>/`),
5. und den Änderungsbericht nach `…_2026-07-30.aenderungen.html`. 3. speichert dort den Rohtext `<Name>.txt` und die Quellenangabe `<Name>.quelle.json`
(Titel, Kanal, Datum, URL),
4. korrigiert den Text abschnittsweise und gliedert ihn in Absätze,
5. schreibt das Ergebnis nach `<Name>.korrigiert.txt`
6. und den Änderungsbericht nach `<Name>.aenderungen.html`.
Eine einstündige Rede braucht mit `qwen3:32b` auf einer RTX 3090 etwa 6–8 Eine einstündige Rede braucht mit `qwen3:32b` auf einer RTX 3090 etwa 6–8
Minuten. Danach den Bericht im Browser öffnen und die Liste **„Zu prüfen“** Minuten. Danach den Bericht im Browser öffnen und die Liste **„Zu prüfen“**
durchgehen (siehe [Abschnitt 8](#8-schritt-4-ergebnis-prüfen)). durchgehen (siehe [Abschnitt 8](#8-schritt-4-ergebnis-prüfen)). Ist alles erledigt:
Mit eigenem Dateinamen: ```bash
./abschliessen.py abschriften/<Name>
# → fertig/<Name>.txt mit Quellenkopf; der Arbeitsordner wird nach Rückfrage gelöscht
```
Mit eigenem Dateinamen (ohne Arbeitsordner):
```bash ```bash
./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.korrigiert.txt ./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.korrigiert.txt
@ -155,8 +175,10 @@ und einem passenden Glossar. Ein Lauf dauert etwa zwei bis drei Minuten und zeig
Ergebnisse, auch den Änderungsbericht: Ergebnisse, auch den Änderungsbericht:
```bash ```bash
./abschrift_korrigieren.py -i tests/daten/beispiel.de.txt -g tests/daten/beispiel.glossar.txt -o beispiel.korrigiert.txt ./abschrift_korrigieren.py -i tests/daten/beispiel.de.txt -g tests/daten/beispiel.glossar.txt \
# → beispiel.korrigiert.txt, beispiel.aenderungen.html -o abschriften/beispiel/beispiel.korrigiert.txt
# → abschriften/beispiel/: beispiel.korrigiert.txt, beispiel.aenderungen.html
./abschliessen.py abschriften/beispiel # Abschluss ausprobieren
``` ```
--- ---
@ -168,22 +190,28 @@ besonders bei vielen Namen, lohnt dieser Ablauf:
```text ```text
YouTube-URL YouTube-URL
│ Schritt 1: extract_text.py → rede.txt (Rohtext) │ Schritt 1: extract_text.py → abschriften/<Name>/<Name>.txt (Rohtext)
▼ ▼
Rohtext ansehen Rohtext ansehen
│ Schritt 2: glossar_vorschlag.py → rede.glossar.entwurf.txt │ Schritt 2: glossar_vorschlag.py → <Name>.glossar.entwurf.txt
│ prüfen, übernehmen → glossare/rede.glossar.txt │ prüfen, übernehmen → <Name>.glossar.txt (Video-Glossar)
│ bzw. glossare/<sprecher>.glossar.txt
▼ ▼
│ Schritt 3: abschrift_korrigieren.py → rede.korrigiert.txt │ Schritt 3: abschrift_korrigieren.py → <Name>.korrigiert.txt
│ rede.aenderungen.html │ <Name>.aenderungen.html
▼ ▼
│ Schritt 4: Bericht prüfen („Zu prüfen“) │ Schritt 4: Bericht prüfen („Zu prüfen“), Text von Hand nacharbeiten
▼ ▼
│ Schritt 5: Glossar ergänzen, erneut laufen lassen │ Schritt 5: Glossar ergänzen, erneut laufen lassen
▼ (nur Geändertes wird neu berechnet) │ (nur Geändertes wird neu berechnet)
▼
│ Schritt 6: abschliessen.py → fertig/<Name>.txt, Arbeitsordner gelöscht
▼
fertige Abschrift fertige Abschrift
``` ```
Alle Dateien eines Videos liegen bis zum Abschluss in `abschriften/<Name>/`.
--- ---
## 5. Schritt 1: Rohtext holen ## 5. Schritt 1: Rohtext holen
@ -191,7 +219,8 @@ fertige Abschrift
### 5.1 Von YouTube: `extract_text.py` ### 5.1 Von YouTube: `extract_text.py`
```bash ```bash
./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o rede.txt ./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ'
# → abschriften/<Name>/<Name>.txt und <Name>.quelle.json
``` ```
Das Programm zeigt Titel, gewählte Untertitelspur und Ziel an: Das Programm zeigt Titel, gewählte Untertitelspur und Ziel an:
@ -201,8 +230,10 @@ Video: Prof. Gabriele Krone-Schmalz – Respekt geht anders – …
Lade automatische Untertitel „de-orig“ … Lade automatische Untertitel „de-orig“ …
``` ```
Ohne `-o` entsteht ein Name aus Kanal, Titel und Datum (siehe Ohne `-o` entsteht ein Name aus Kanal, Titel und Datum, und die Datei kommt in den
[Abschnitt 12.2](#122-standard-dateinamen)). Arbeitsordner `abschriften/<Name>/` (siehe [Abschnitt 13.2](#132-standard-dateinamen)).
Mit `-o rede.txt` landet sie dort, wo man will; die Quellenangabe `rede.quelle.json`
entsteht daneben.
**Welche Untertitel werden genommen?** **Welche Untertitel werden genommen?**
@ -223,16 +254,19 @@ versteht `abschrift_korrigieren.py`.
Soll die Original-Untertiteldatei (`.vtt`, mit Zeitstempeln) erhalten bleiben: Soll die Original-Untertiteldatei (`.vtt`, mit Zeitstempeln) erhalten bleiben:
```bash ```bash
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d untertitel ./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein
# → untertitel/Lahnstein.de-orig.vtt # → abschriften/Lahnstein/Lahnstein.de-orig.vtt (+ Lahnstein.quelle.json)
./vtt2txt.py -i untertitel/Lahnstein.de-orig.vtt ./vtt2txt.py -i abschriften/Lahnstein/Lahnstein.de-orig.vtt
# → untertitel/Lahnstein.de-orig.txt # → abschriften/Lahnstein/Lahnstein.de-orig.txt
``` ```
`-n` bestimmt den Namen von Datei und Arbeitsordner; mit `-d` wählt man ein anderes
Zielverzeichnis.
Achtung: `yt_untertitel.py` nimmt ohne `-l` Deutsch (`de`), nicht die Achtung: `yt_untertitel.py` nimmt ohne `-l` Deutsch (`de`), nicht die
Originalsprache. Für englische Videos `-l en` angeben. Originalsprache. Für englische Videos `-l en` angeben.
Die `.vtt`-Datei braucht man für die [Zeitauswahl](#10-nur-ausschnitte-bearbeiten-zeitauswahl) Die `.vtt`-Datei braucht man für die [Zeitauswahl](#11-nur-ausschnitte-bearbeiten-zeitauswahl)
mit vorhandenen Dateien. `abschrift_korrigieren.py` nimmt sie auch direkt, der mit vorhandenen Dateien. `abschrift_korrigieren.py` nimmt sie auch direkt, der
Zwischenschritt über `vtt2txt.py` ist dann nur nötig, um den Rohtext anzusehen Zwischenschritt über `vtt2txt.py` ist dann nur nötig, um den Rohtext anzusehen
oder ein Glossar daraus zu erstellen. oder ein Glossar daraus zu erstellen.
@ -293,33 +327,43 @@ Regeln für feste Ersetzungen:
die Ersetzungen ohne Treffer. Bei einem Glossar für genau diesen Beitrag deutet die Ersetzungen ohne Treffer. Bei einem Glossar für genau diesen Beitrag deutet
„ohne Treffer“ auf einen Tippfehler. „ohne Treffer“ auf einen Tippfehler.
### 6.2 Mehrere Glossare kombinieren ### 6.2 Gemeinsame Glossare und Video-Glossare
Bewährt hat sich ein **gemeinsames Glossar** pro Redner oder Kanal (wiederkehrende Bewährt hat sich ein **gemeinsames Glossar** pro Redner oder Kanal (wiederkehrende
Namen und Hörfehler) plus eines **je Beitrag**: Namen und Hörfehler) plus ein **Video-Glossar** je Beitrag:
| Art | Ablage | Verwendung |
|---|---|---|
| gemeinsames Glossar | `glossare/krone-schmalz.glossar.txt` | mit `-g` angeben |
| Video-Glossar | `abschriften/<Name>/<Name>.glossar.txt`, heißt wie sein Ordner | **automatisch** |
```bash ```bash
./abschrift_korrigieren.py -i rede.txt -g glossare/krone-schmalz.glossar.txt -g glossare/rede.glossar.txt ./abschrift_korrigieren.py -i abschriften/<Name>/<Name>.txt -g glossare/krone-schmalz.glossar.txt
# Glossar glossare/krone-schmalz.glossar.txt: 7 Begriffe, 36 feste Ersetzungen
# Glossar abschriften/<Name>/<Name>.glossar.txt (Video-Glossar, automatisch): …
``` ```
Das gemeinsame Glossar zuerst angeben, damit seine Ersetzungen zuerst greifen. Das Video-Glossar wird nach den mit `-g` angegebenen angewendet; so greifen die
gemeinsamen Ersetzungen zuerst. Dasselbe gilt für `glossar_vorschlag.py` und
`aenderungen.py`. Beim Abschluss nennt `abschliessen.py` die Einträge des
Video-Glossars, die noch in keinem gemeinsamen Glossar stehen. Was auch in anderen
Videos vorkommen wird (Namen, Orte), gehört dann ins gemeinsame Glossar; der Rest
verschwindet mit dem Arbeitsordner.
**Ablage:** Glossare gehören ins Verzeichnis `glossare/` im Programmordner, z. B. **Glossare bleiben lokal.** Sie enthalten Namen realer Personen und Hörfehler realer
`glossare/krone-schmalz.glossar.txt` und `glossare/ramstein.glossar.txt`. Sie Beiträge und werden deshalb nicht ins Git-Repository übernommen (`glossare/`,
enthalten Namen realer Personen und Hörfehler realer Beiträge und werden deshalb `abschriften/` und `*.glossar.txt` stehen in `.gitignore`). `glossare/` deshalb
nicht ins Git-Repository übernommen: `glossare/` und jede Datei `*.glossar.txt` sind gesondert sichern (siehe [Abschnitt 17](#17-tipps-und-häufige-fragen)). Als Vorlage
über `.gitignore` ausgeschlossen und bleiben nur auf dem eigenen Rechner. Das ist eignet sich das kleine Glossar zur erfundenen Beispielrede in
gewollt; Glossare deshalb gesondert sichern. Einzige Ausnahme ist das kleine, `tests/daten/beispiel.glossar.txt`.
leicht nachvollziehbare Glossar zur erfundenen Beispielrede in
`tests/daten/beispiel.glossar.txt`, das sich gut als Vorlage eignet.
### 6.3 Entwurf vom Modell: `glossar_vorschlag.py` ### 6.3 Entwurf vom Modell: `glossar_vorschlag.py`
Statt den ganzen Rohtext zu lesen, kann das Modell einen ersten Entwurf liefern: Statt den ganzen Rohtext zu lesen, kann das Modell einen ersten Entwurf liefern:
```bash ```bash
./glossar_vorschlag.py -i rede.txt -g glossare/krone-schmalz.glossar.txt ./glossar_vorschlag.py -i abschriften/<Name>/<Name>.txt -g glossare/krone-schmalz.glossar.txt
# → rede.glossar.entwurf.txt # → abschriften/<Name>/<Name>.glossar.entwurf.txt
``` ```
Das Modell sucht abschnittsweise vermutete Hörfehler: Namen, Orte, Fremdwörter, Das Modell sucht abschnittsweise vermutete Hörfehler: Namen, Orte, Fremdwörter,
@ -346,8 +390,8 @@ irrt sich oft (im Beispiel ist „Deckmäntelchen“ richtig). Eine falsche fest
Ersetzung verdirbt den Text unbemerkt. Deshalb: Ersetzung verdirbt den Text unbemerkt. Deshalb:
1. Jeden Eintrag prüfen, im Zweifel im Rohtext oder Video nachsehen. 1. Jeden Eintrag prüfen, im Zweifel im Rohtext oder Video nachsehen.
2. Brauchbare Zeilen ohne `# ? ` ins Glossar des Beitrags (oder ins gemeinsame) 2. Brauchbare Zeilen ohne `# ? ` ins Video-Glossar
kopieren. `abschriften/<Name>/<Name>.glossar.txt` (oder ins gemeinsame) kopieren.
3. Unsichere Namen lieber als **Begriff** (ohne `=>`) aufnehmen. 3. Unsichere Namen lieber als **Begriff** (ohne `=>`) aufnehmen.
Nach einem Korrekturlauf kann der Entwurf zusätzlich die Wörter auflisten, die das Nach einem Korrekturlauf kann der Entwurf zusätzlich die Wörter auflisten, die das
@ -355,7 +399,8 @@ Modell geändert hat. Das zeigt Hörfehler, die man als feste Ersetzung absicher
möchte, und Fehlgriffe des Modells: möchte, und Fehlgriffe des Modells:
```bash ```bash
./glossar_vorschlag.py -i rede.txt -k rede.korrigiert.txt -g glossare/krone-schmalz.glossar.txt -g glossare/rede.glossar.txt ./glossar_vorschlag.py -i abschriften/<Name>/<Name>.txt -k abschriften/<Name>/<Name>.korrigiert.txt \
-g glossare/krone-schmalz.glossar.txt
``` ```
Diese Einträge stehen in einem eigenen Abschnitt mit ihrer Anzahl, z. B. Diese Einträge stehen in einem eigenen Abschnitt mit ihrer Anzahl, z. B.
@ -367,11 +412,12 @@ entfernen.
## 7. Schritt 3: Korrigieren ## 7. Schritt 3: Korrigieren
```bash ```bash
./abschrift_korrigieren.py -i rede.txt -g glossare/krone-schmalz.glossar.txt -g glossare/rede.glossar.txt ./abschrift_korrigieren.py -i abschriften/<Name>/<Name>.txt -g glossare/krone-schmalz.glossar.txt
``` ```
Statt `-i rede.txt` geht auch `-i rede.de.vtt` (Untertiteldatei) oder Statt des Rohtexts geht auch die Untertiteldatei (`-i …/<Name>.de-orig.vtt`) oder
`-u URL` (direkt von YouTube). `-u URL` (direkt von YouTube). Das Ergebnis entsteht neben der Eingabe, also im
Arbeitsordner; das Video-Glossar dort wird automatisch verwendet.
### 7.1 Was das Programm tut ### 7.1 Was das Programm tut
@ -401,7 +447,7 @@ Ein gekürztes Beispiel mit Erläuterungen:
Modell: qwen3:32b Modell: qwen3:32b
Sprache des Beitrags: de Sprache des Beitrags: de
Glossar glossare/krone-schmalz.glossar.txt: 7 Begriffe, 36 feste Ersetzungen Glossar glossare/krone-schmalz.glossar.txt: 7 Begriffe, 36 feste Ersetzungen
Glossar glossare/ramstein.glossar.txt: 2 Begriffe, 15 feste Ersetzungen Glossar abschriften/<Name>/<Name>.glossar.txt (Video-Glossar, automatisch): 2 Begriffe, 15 feste Ersetzungen
Ersetzt 2× „Willly Brand“ → „Willy Brandt“ ← feste Ersetzungen mit Treffern Ersetzt 2× „Willly Brand“ → „Willy Brandt“ ← feste Ersetzungen mit Treffern
Ersetzt 6× „Rammstein“ → „Ramstein“ Ersetzt 6× „Rammstein“ → „Ramstein“
13 Ersetzung(en) ohne Treffer: „Hostelchik“, … ← bei gemeinsamen Glossaren normal 13 Ersetzung(en) ohne Treffer: „Hostelchik“, … ← bei gemeinsamen Glossaren normal
@ -565,13 +611,60 @@ vorhandene Datei als von Hand geändert.
--- ---
## 10. Nur Ausschnitte bearbeiten (Zeitauswahl) ## 10. Schritt 6: Abschließen und aufräumen
Ist der Text geprüft und nachgearbeitet, wird das Video abgeschlossen. Danach zählt
nur noch die Ergebnisdatei; Rohtext, Bericht, Zwischenstände und Video-Glossar
werden nicht mehr gebraucht.
```bash
./abschliessen.py abschriften/<Name>
```
Was dabei passiert:
1. **Prüfen:** Gibt es noch `[prüfen: …]`-Marken im Text oder eine ungeklärte
`<Name>.neu.korrigiert.txt` (siehe [Abschnitt 9](#9-schritt-5-nachbessern-und-erneut-laufen-lassen)),
bricht das Programm ab und nennt die Stellen. Erst klären, dann erneut aufrufen.
Mit `--trotzdem` wird auch mit offenen Marken abgeschlossen.
2. **Übernehmen:** Jedes Ergebnis kommt nach `fertig/`, bei Ausschnitten jedes
einzeln, mit einem Quellenkopf:
```text
Titel: Prof. Gabriele Krone-Schmalz – Respekt geht anders
Kanal: GGBLahnstein
Datum: 2026-07-30
Quelle: https://www.youtube.com/watch?v=rm6lADgf6GQ
Abgeschlossen: 2026-10-11
Vielen Dank. Liebe Gäste, …
```
Die Angaben stammen aus `<Name>.quelle.json`, die beim Download entsteht. Fehlt
sie (z. B. bei einer eigenen `.vtt`), nennt der Kopf nur die Datei. Eine Datei in
`fertig/` mit anderem Text wird nie überschrieben.
3. **Glossar sichten:** Einträge des Video-Glossars, die in keinem Glossar unter
`glossare/` stehen, werden aufgelistet. Brauchbares jetzt ins gemeinsame Glossar
übernehmen.
4. **Aufräumen:** Der Inhalt des Arbeitsordners wird angezeigt, dann kommt die Frage
`Arbeitsordner … löschen? [j/N]`. Nur `j` löscht. Wer „n“ antwortet, kann
`abschliessen.py` später erneut aufrufen; die Datei in `fertig/` wird dann als
bereits übernommen erkannt. Mit `--ja` wird ohne Frage gelöscht.
**Schutz vor Versehen:** Nie gelöscht werden Ordner, die andere Dateien enthalten als
die, die das Programm selbst anlegt (`.txt`, `.vtt`, `.html`, `.json`, `*.parts/`),
außerdem das Aufrufverzeichnis und alles in `testdaten/`, `glossare/`, `tests/` und
`fertig/`. Das Programm nennt dann den Grund, und der Ordner bleibt stehen.
---
## 11. Nur Ausschnitte bearbeiten (Zeitauswahl)
Mit `-z` wird nur ein Teil des Videos bearbeitet. Das geht mit `-u` (YouTube) und Mit `-z` wird nur ein Teil des Videos bearbeitet. Das geht mit `-u` (YouTube) und
mit `.vtt`-Dateien, nicht mit reinem Text (der hat keine Zeitstempel). Dieselbe mit `.vtt`-Dateien, nicht mit reinem Text (der hat keine Zeitstempel). Dieselbe
Option kennen `extract_text.py` und `vtt2txt.py`. Option kennen `extract_text.py` und `vtt2txt.py`.
### 10.1 Zeitangaben ### 11.1 Zeitangaben
| Angabe | Bedeutung | | Angabe | Bedeutung |
|---|---| |---|---|
@ -584,7 +677,7 @@ Option kennen `extract_text.py` und `vtt2txt.py`.
> Reicht ein Bereich über das Ende des Videos hinaus, weist das Programm darauf hin > Reicht ein Bereich über das Ende des Videos hinaus, weist das Programm darauf hin
> und nennt die Länge der Untertitel. > und nennt die Länge der Untertitel.
### 10.2 Bereiche ### 11.2 Bereiche
| Aufruf | Bearbeitet | | Aufruf | Bearbeitet |
|---|---| |---|---|
@ -601,7 +694,7 @@ und werden nach der Zeit sortiert. Beispiel:
./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=…' -z 00:05-00:06 00:25 ./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=…' -z 00:05-00:06 00:25
``` ```
### 10.3 Was dabei herauskommt ### 11.3 Was dabei herauskommt
Alle Bereiche landen in **einer** Datei, jeder mit einer Überschrift, die seine Alle Bereiche landen in **einer** Datei, jeder mit einer Überschrift, die seine
tatsächlichen Zeiten nennt: tatsächlichen Zeiten nennt:
@ -625,7 +718,7 @@ Also, wenn ihr Programme baut mit Claude Code, …
--- ---
## 11. Englische Beiträge ## 12. Englische Beiträge
Mit `-u` erkennt das Programm die Sprache des Videos selbst. Bei Dateien die Mit `-u` erkennt das Programm die Sprache des Videos selbst. Bei Dateien die
Sprache angeben: Sprache angeben:
@ -646,28 +739,44 @@ Andere Sprachen lehnt das Programm mit einer Meldung ab.
--- ---
## 12. Dateien, die entstehen ## 13. Dateien, die entstehen
### 12.1 Übersicht ### 13.1 Übersicht
Beispiel für `-i rede.txt` bzw. `-o rede.korrigiert.txt`: Im Programmordner entstehen diese Verzeichnisse. Alle bleiben nur auf dem eigenen
Rechner, keines geht ins Git-Repository:
```text
glossare/ gemeinsame Glossare je Sprecher oder Kanal
abschriften/<Name>/ Arbeitsordner, einer je Video
fertig/ abgeschlossene Abschriften, je eine Datei
testdaten/<rede>/ nur für Entwickler: echte Reden für Regressionstests
```
Im Arbeitsordner `abschriften/<Name>/`:
| Datei | Inhalt | Wird überschrieben? | | Datei | Inhalt | Wird überschrieben? |
|---|---|---| |---|---|---|
| `rede.txt` | Rohtext (bei `-u` automatisch gespeichert) | nie, wenn sie schon mit anderem Inhalt existiert (dann Warnung) | | `<Name>.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)) | | `<Name>.quelle.json` | Titel, Kanal, Datum, URL (beim Download) | ja, beim nächsten Download |
| `rede.aenderungen.html` | Änderungsbericht | ja, bei jedem Lauf | | `<Name>.glossar.txt` | Video-Glossar (legt man selbst an) | nie |
| `rede.korrigiert.parts/` | Zwischenstände | wird ergänzt; darf jederzeit gelöscht werden | | `<Name>.korrigiert.txt` | korrigierte Abschrift | ja, außer sie wurde von Hand geändert (dann `<Name>.neu.korrigiert.txt`, siehe [Abschnitt 9](#9-schritt-5-nachbessern-und-erneut-laufen-lassen)) |
| `rede.glossar.entwurf.txt` | Glossar-Entwurf von `glossar_vorschlag.py` | ja | | `<Name>.aenderungen.html` | Änderungsbericht | ja, bei jedem Lauf |
| `rede.glossar.entwurf.parts/` | Zwischenstände des Entwurfs | wird ergänzt | | `<Name>.korrigiert.parts/` | Zwischenstände | wird ergänzt; darf jederzeit gelöscht werden |
| `<Name>.glossar.entwurf.txt` | Glossar-Entwurf von `glossar_vorschlag.py` | ja |
| `<Name>.glossar.entwurf.parts/` | Zwischenstände des Entwurfs | wird ergänzt |
Ohne `-o` liegt das Ergebnis neben der Eingabedatei. Bei `-u` ohne `-o` im Ausschnitte (`-z`) desselben Videos landen im selben Ordner, mit den Bereichen im
aktuellen Verzeichnis. Fehlende Verzeichnisse in `-o` werden angelegt. Namen. Mit `abschliessen.py` kommt jedes Ergebnis als `fertig/<Name>.txt` heraus, und
der Arbeitsordner wird gelöscht ([Abschnitt 10](#10-schritt-6-abschließen-und-aufräumen)).
### 12.2 Standard-Dateinamen Mit `-i` liegt das Ergebnis neben der Eingabedatei; mit `-o` dort, wo man will
(fehlende Verzeichnisse werden angelegt).
Ohne `-o` (bzw. `-n` bei `yt_untertitel.py`) entsteht der Name aus **Kanal, Titel ### 13.2 Standard-Dateinamen
und Hochladedatum**:
Ohne `-o` (bzw. `-n` bei `yt_untertitel.py`) entsteht der Name des Arbeitsordners
und der Dateien aus **Kanal, Titel und Hochladedatum**:
| Video | Name | | Video | Name |
|---|---| |---|---|
@ -680,7 +789,7 @@ denselben Namen.
--- ---
## 13. Zwischenstände, Abbruch und Fortsetzen ## 14. Zwischenstände, Abbruch und Fortsetzen
Jeder fertig korrigierte Abschnitt, jede Absatzgliederung und jede Entscheidung Jeder fertig korrigierte Abschnitt, jede Absatzgliederung und jede Entscheidung
beim Verbinden kurzer Absätze wird sofort in `<Ausgabe>.parts/` gespeichert. beim Verbinden kurzer Absätze wird sofort in `<Ausgabe>.parts/` gespeichert.
@ -691,11 +800,12 @@ beim Verbinden kurzer Absätze wird sofort in `<Ausgabe>.parts/` gespeichert.
- Ändern sich Modell, Anweisung, Glossar oder der Text eines Abschnitts, wird genau - Ändern sich Modell, Anweisung, Glossar oder der Text eines Abschnitts, wird genau
dieser Teil neu berechnet; alte Zwischenstände stören nicht. dieser Teil neu berechnet; alte Zwischenstände stören nicht.
- Der Ordner `*.parts/` darf jederzeit gelöscht werden; dann wird alles neu - Der Ordner `*.parts/` darf jederzeit gelöscht werden; dann wird alles neu
berechnet. berechnet. Er wächst mit jeder Modell- oder Glossaränderung, weil alte Fassungen
liegen bleiben. Beim Abschluss verschwindet er mit dem Arbeitsordner.
--- ---
## 14. Ein anderes Sprachmodell verwenden ## 15. Ein anderes Sprachmodell verwenden
```bash ```bash
ollama pull qwen3:14b ollama pull qwen3:14b
@ -713,7 +823,7 @@ Weitere Einstellungen (Abschnittsgröße, Absatzlängen, Schwellen) sind in der
--- ---
## 15. Fehlermeldungen und Lösungen ## 16. Fehlermeldungen und Lösungen
| Meldung | Ursache und Lösung | | Meldung | Ursache und Lösung |
|---|---| |---|---|
@ -725,7 +835,7 @@ Weitere Einstellungen (Abschnittsgröße, Absatzlängen, Schwellen) sind in der
| `Keine Untertitel in „de“ (weder manuell noch automatisch in Originalsprache).` | Das Video hat keine passende Spur. Die Meldung listet die vorhandenen Sprachen; ggf. mit `-l` eine davon wählen. Ist das Video z. B. englisch, `-l en` angeben. | | `Keine Untertitel in „de“ (weder manuell noch automatisch in Originalsprache).` | Das Video hat keine passende Spur. Die Meldung listet die vorhandenen Sprachen; ggf. mit `-l` eine davon wählen. Ist das Video z. B. englisch, `-l en` angeben. |
| `Der Beitrag ist auf „fr“; unterstützt: de, en (-l).` | Nur Deutsch und Englisch werden korrigiert. | | `Der Beitrag ist auf „fr“; unterstützt: de, en (-l).` | Nur Deutsch und Englisch werden korrigiert. |
| `-z braucht Zeitstempel: nur mit .vtt-Eingabe oder -u.` | Zeitauswahl geht nicht mit `.txt`. Die `.vtt`-Datei oder `-u URL` verwenden. | | `-z braucht Zeitstempel: nur mit .vtt-Eingabe oder -u.` | Zeitauswahl geht nicht mit `.txt`. Die `.vtt`-Datei oder `-u URL` verwenden. |
| `-z/--zeitraeume: …` | Zeitangabe ungültig oder Bereiche überschneiden sich, siehe [Abschnitt 10](#10-nur-ausschnitte-bearbeiten-zeitauswahl). | | `-z/--zeitraeume: …` | Zeitangabe ungültig oder Bereiche überschneiden sich, siehe [Abschnitt 11](#11-nur-ausschnitte-bearbeiten-zeitauswahl). |
| `Die Eingabedatei enthält im gewählten Zeitraum keinen Text.` | Im Bereich gibt es keine Untertitel. Zeiten prüfen (`05:00` = 5 Stunden!). | | `Die Eingabedatei enthält im gewählten Zeitraum keinen Text.` | Im Bereich gibt es keine Untertitel. Zeiten prüfen (`05:00` = 5 Stunden!). |
| `Die Eingabedatei ist leer.` | Datei prüfen. | | `Die Eingabedatei ist leer.` | Datei prüfen. |
| `Ausgabedatei darf nicht die Eingabedatei sein.` | Mit `-o` einen anderen Namen wählen. | | `Ausgabedatei darf nicht die Eingabedatei sein.` | Mit `-o` einen anderen Namen wählen. |
@ -738,12 +848,16 @@ Weitere Einstellungen (Abschnittsgröße, Absatzlängen, Schwellen) sind in der
| `Hinweis: keine Wortliste für „de“ (Paket wngerman); …` | `sudo apt install wngerman` (für Englisch `wamerican`). Ohne Wortliste fehlt im Bericht nur der Grund „gültiges Wort ersetzt“. | | `Hinweis: keine Wortliste für „de“ (Paket wngerman); …` | `sudo apt install wngerman` (für Englisch `wamerican`). Ohne Wortliste fehlt im Bericht nur der Grund „gültiges Wort ersetzt“. |
| `Hinweis: keine Untertitel im Bereich …` | Einer von mehreren Bereichen ist leer und wird übersprungen. In Klammern steht, wo die Untertitel enden. | | `Hinweis: keine Untertitel im Bereich …` | Einer von mehreren Bereichen ist leer und wird übersprungen. 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: 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. |
| `Noch offene Prüfmarken. Erst klären, oder mit --trotzdem abschließen.` | `abschliessen.py`: Im Text stehen noch `[prüfen: …]`-Marken (die Zeilen werden genannt). Auflösen und erneut aufrufen. |
| `Ungeklärter Konflikt: <Name>.neu.korrigiert.txt …` | `abschliessen.py`: Erst die eigenen Änderungen übertragen bzw. entscheiden, welche Fassung gilt, und die `.neu`-Datei löschen. |
| `Gibt es schon in fertig mit anderem Text: …` | In `fertig/` liegt schon eine Abschrift dieses Namens mit anderem Inhalt. Sie wird nie überschrieben: umbenennen oder entfernen, dann erneut aufrufen. |
| `Arbeitsordner … bleibt erhalten: Fremde Datei: …` | Im Arbeitsordner liegt eine Datei, die das Programm nicht selbst anlegt (z. B. Notizen). Die fertige Abschrift ist trotzdem in `fertig/`; den Ordner selbst aufräumen. |
| `Keine Berechtigung` beim Start mit `./…` | `chmod +x *.py` oder `python3 programm.py …` aufrufen. | | `Keine Berechtigung` beim Start mit `./…` | `chmod +x *.py` oder `python3 programm.py …` aufrufen. |
| Lauf ist extrem langsam | Das Modell passt nicht in den Grafikspeicher. `OLLAMA_NUM_PARALLEL=1` setzen (siehe [Abschnitt 2.2](#22-grafikkarte)), andere Programme mit Grafikspeicherbedarf schließen oder ein kleineres Modell wählen. Prüfen mit `ollama ps` (Spalte PROCESSOR sollte „100% GPU“ zeigen). | | Lauf ist extrem langsam | Das Modell passt nicht in den Grafikspeicher. `OLLAMA_NUM_PARALLEL=1` setzen (siehe [Abschnitt 2.2](#22-grafikkarte)), andere Programme mit Grafikspeicherbedarf schließen oder ein kleineres Modell wählen. Prüfen mit `ollama ps` (Spalte PROCESSOR sollte „100% GPU“ zeigen). |
--- ---
## 16. Tipps und häufige Fragen ## 17. Tipps und häufige Fragen
**Wie bekomme ich die besten Ergebnisse?** **Wie bekomme ich die besten Ergebnisse?**
Mit einem guten Glossar. Das Modell kennt viele Namen nicht oder schreibt sie Mit einem guten Glossar. Das Modell kennt viele Namen nicht oder schreibt sie
@ -777,17 +891,28 @@ for url in 'https://www.youtube.com/watch?v=AAA' 'https://www.youtube.com/watch?
done done
``` ```
**Wie sichere ich meine Daten?**
Wertvoll sind auf Dauer nur `glossare/` (gewachsenes Wissen über Namen und Hörfehler)
und `fertig/` (die Ergebnisse). Beide sind bewusst nicht im öffentlichen
Git-Repository. Am einfachsten ist ein zweites, **privates** Git-Repository nur für
diese beiden Ordner, oder man nimmt sie ins normale Backup auf. `abschriften/` muss
nicht gesichert werden; was dort liegt, ist entweder in Arbeit oder lässt sich neu
erzeugen.
**Welche Videos sind noch in Arbeit?**
Alle, die einen Ordner in `abschriften/` haben: `ls abschriften/`.
**Wo sind die Absätze?** **Wo sind die Absätze?**
In `*.korrigiert.txt` trennt eine Leerzeile die Absätze. Viele Textprogramme In `*.korrigiert.txt` trennt eine Leerzeile die Absätze. Viele Textprogramme
übernehmen das beim Einfügen direkt. übernehmen das beim Einfügen direkt.
**Kann ich das Ergebnis weiterverarbeiten (Word, LibreOffice)?** **Kann ich das Ergebnis weiterverarbeiten (Word, LibreOffice)?**
Die `.korrigiert.txt` ist reiner UTF-8-Text und lässt sich überall öffnen oder Die Dateien in `fertig/` (und die `.korrigiert.txt`) sind reiner UTF-8-Text und
einfügen. lassen sich überall öffnen oder einfügen.
--- ---
## 17. Kurzreferenz aller Befehle ## 18. Kurzreferenz aller Befehle
### `abschrift_korrigieren.py` ### `abschrift_korrigieren.py`
@ -795,7 +920,7 @@ einfügen.
|---|---| |---|---|
| `-i DATEI` | Eingabe: Rohtext (`.txt`) oder Untertitel (`.vtt`) | | `-i DATEI` | Eingabe: Rohtext (`.txt`) oder Untertitel (`.vtt`) |
| `-u URL` | statt `-i`: YouTube-Video laden und korrigieren | | `-u URL` | statt `-i`: YouTube-Video laden und korrigieren |
| `-o DATEI` | Ausgabedatei (Standard: `<Eingabe>.korrigiert.txt` bzw. `<Kanal>_<Titel>_<Datum>.korrigiert.txt`) | | `-o DATEI` | Ausgabedatei (Standard: `<Eingabe>.korrigiert.txt`, mit `-u` `abschriften/<Name>/<Name>.korrigiert.txt`) |
| `-g DATEI` | Glossar, mehrfach möglich (Reihenfolge zählt) | | `-g DATEI` | Glossar, mehrfach möglich (Reihenfolge zählt) |
| `-l SPRACHE` | `de` oder `en` (Standard: `de`, mit `-u` die Originalsprache) | | `-l SPRACHE` | `de` oder `en` (Standard: `de`, mit `-u` die Originalsprache) |
| `-m MODELL` | Ollama-Modell (Standard: `qwen3:32b`) | | `-m MODELL` | Ollama-Modell (Standard: `qwen3:32b`) |
@ -809,7 +934,7 @@ einfügen.
| Option | Bedeutung | | Option | Bedeutung |
|---|---| |---|---|
| `-u URL` | YouTube-URL (Pflicht) | | `-u URL` | YouTube-URL (Pflicht) |
| `-o DATEI` | Ausgabedatei (Standard: `<Kanal>_<Titel>_<Datum>.txt`) | | `-o DATEI` | Ausgabedatei (Standard: `abschriften/<Name>/<Name>.txt`) |
| `-l SPRACHE` | Sprache der Untertitel (Standard: Originalsprache) | | `-l SPRACHE` | Sprache der Untertitel (Standard: Originalsprache) |
| `-f` | Fließtext mit 80 Zeichen pro Zeile | | `-f` | Fließtext mit 80 Zeichen pro Zeile |
| `-z BEREICH …`, `--exakt` | Zeitauswahl wie oben | | `-z BEREICH …`, `--exakt` | Zeitauswahl wie oben |
@ -819,9 +944,9 @@ einfügen.
| Option | Bedeutung | | Option | Bedeutung |
|---|---| |---|---|
| `-u URL` | YouTube-URL (Pflicht) | | `-u URL` | YouTube-URL (Pflicht) |
| `-n NAME` | Dateiname ohne Endung (Standard: `<Kanal>_<Titel>_<Datum>`) | | `-n NAME` | Name von Datei und Arbeitsordner (Standard: `<Kanal>_<Titel>_<Datum>`) |
| `-l SPRACHE` | Sprache (Standard: `de`) | | `-l SPRACHE` | Sprache (Standard: `de`) |
| `-d VERZEICHNIS` | Zielverzeichnis (Standard: aktuelles) | | `-d VERZEICHNIS` | Zielverzeichnis (Standard: `abschriften/<Name>/`) |
### `vtt2txt.py` ### `vtt2txt.py`
@ -851,4 +976,16 @@ einfügen.
| `-g DATEI` | Glossare der Korrektur, mehrfach möglich | | `-g DATEI` | Glossare der Korrektur, mehrfach möglich |
| `-l SPRACHE` | Sprache für die Wortliste (Standard: `de`) | | `-l SPRACHE` | Sprache für die Wortliste (Standard: `de`) |
### `abschliessen.py`
| Option | Bedeutung |
|---|---|
| `ORDNER` | Arbeitsordner, z. B. `abschriften/<Name>` (Pflicht) |
| `--ziel VERZEICHNIS` | für fertige Abschriften (Standard: `fertig`) |
| `--glossare VERZEICHNIS` | gemeinsame Glossare für den Glossar-Hinweis (Standard: `glossare`) |
| `--ja` | Arbeitsordner ohne Rückfrage löschen |
| `--trotzdem` | auch mit offenen Prüfmarken abschließen |
Das Video-Glossar `abschriften/<Name>/<Name>.glossar.txt` verwenden
`abschrift_korrigieren.py`, `glossar_vorschlag.py` und `aenderungen.py` automatisch.
Jedes Programm zeigt mit `--help` seine Optionen. Jedes Programm zeigt mit `--help` seine Optionen.

193
README.md
View file

@ -21,6 +21,7 @@ Die Werkzeuge sind einzeln oder hintereinander nutzbar:
| `abschrift_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren | | `abschrift_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren |
| `aenderungen.py` | Änderungsbericht: was hat das Modell gegenüber dem Rohtext geändert? | | `aenderungen.py` | Änderungsbericht: was hat das Modell gegenüber dem Rohtext geändert? |
| `glossar_vorschlag.py` | Glossar-Entwurf: vermutete Hörfehler bei Namen und Begriffen | | `glossar_vorschlag.py` | Glossar-Entwurf: vermutete Hörfehler bei Namen und Begriffen |
| `abschliessen.py` | fertige Abschrift mit Quellenangabe nach `fertig/`, Arbeitsordner löschen |
## Voraussetzungen ## Voraussetzungen
@ -41,26 +42,31 @@ der Lauf wird etwa 12-mal langsamer.
## Aufruf ## Aufruf
Von der YouTube-URL zum korrigierten Text in einem Befehl: Von der YouTube-URL zum korrigierten Text in einem Befehl, dann abschließen:
```bash ```bash
./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.korrigiert.txt ./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -g glossare/krone-schmalz.glossar.txt
# → lahnstein.korrigiert.txt, lahnstein.txt (Rohtext), lahnstein.aenderungen.html # → abschriften/<Name>/: <Name>.txt (Rohtext), <Name>.quelle.json,
# <Name>.korrigiert.txt, <Name>.aenderungen.html, <Name>.korrigiert.parts/
./abschliessen.py abschriften/<Name> # → fertig/<Name>.txt, Arbeitsordner nach Rückfrage gelöscht
``` ```
`<Name>` ist `<Kanal>_<Titel>_<Datum>`, siehe [Dateiablage](#dateiablage).
Oder in zwei Schritten, um den Rohtext vorher anzusehen: Oder in zwei Schritten, um den Rohtext vorher anzusehen:
```bash ```bash
./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.txt ./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' # → abschriften/<Name>/<Name>.txt
./abschrift_korrigieren.py -i lahnstein.txt # → lahnstein.korrigiert.txt ./abschrift_korrigieren.py -i abschriften/<Name>/<Name>.txt # Ergebnis im selben Ordner
``` ```
Dasselbe in Einzelschritten, wenn die VTT-Datei erhalten bleiben soll: Dasselbe in Einzelschritten, wenn die VTT-Datei erhalten bleiben soll:
```bash ```bash
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d untertitel ./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein
./vtt2txt.py -i untertitel/Lahnstein.de-orig.vtt # → abschriften/Lahnstein/Lahnstein.de-orig.vtt
./abschrift_korrigieren.py -i untertitel/Lahnstein.de-orig.txt ./vtt2txt.py -i abschriften/Lahnstein/Lahnstein.de-orig.vtt
./abschrift_korrigieren.py -i abschriften/Lahnstein/Lahnstein.de-orig.txt
``` ```
Der Schritt über `vtt2txt.py` ist optional: `abschrift_korrigieren.py` nimmt auch die Der Schritt über `vtt2txt.py` ist optional: `abschrift_korrigieren.py` nimmt auch die
@ -75,7 +81,7 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
```bash ```bash
./extract_text.py -u 'https://www.youtube.com/watch?v=0LmCveTM788' ./extract_text.py -u 'https://www.youtube.com/watch?v=0LmCveTM788'
# → John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30.txt # → abschriften/John_Mearsheimer_…_2026-07-30/John_Mearsheimer_…_2026-07-30.txt (+ .quelle.json)
./extract_text.py -u URL -l de -o texte/rede.txt # Sprache und Ziel ./extract_text.py -u URL -l de -o texte/rede.txt # Sprache und Ziel
./extract_text.py -u URL -f -o rede.txt # lesbarer Fließtext ./extract_text.py -u URL -f -o rede.txt # lesbarer Fließtext
./extract_text.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt ./extract_text.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt
@ -87,7 +93,7 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
|---|---| |---|---|
| `-u`, `--url` | YouTube-URL, Pflicht | | `-u`, `--url` | YouTube-URL, Pflicht |
| `-l`, `--lang` | Sprache; Standard: Sprache des Original-Audios (`<sprache>-orig`) | | `-l`, `--lang` | Sprache; Standard: Sprache des Original-Audios (`<sprache>-orig`) |
| `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `<Kanal>_<Titel>_<Datum>.txt`, siehe unten | | `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `abschriften/<Name>/<Name>.txt`, siehe unten |
| `-z`, `--zeitraeume` | nur Untertitel in diesen Bereichen, siehe [Zeitauswahl](#zeitauswahl--z) | | `-z`, `--zeitraeume` | nur Untertitel in diesen Bereichen, siehe [Zeitauswahl](#zeitauswahl--z) |
| `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen | | `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen |
| `-f`, `--fliesstext` | Fließtext, auf 80 Zeichen umbrochen (Wörter werden nie getrennt), statt der kurzen Untertitelzeilen; beides versteht `abschrift_korrigieren.py` | | `-f`, `--fliesstext` | Fließtext, auf 80 Zeichen umbrochen (Wörter werden nie getrennt), statt der kurzen Untertitelzeilen; beides versteht `abschrift_korrigieren.py` |
@ -95,7 +101,8 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
### Standard-Dateinamen für YouTube-Videos ### Standard-Dateinamen für YouTube-Videos
Ohne `-o` bzw. `-n` bilden `extract_text.py`, `yt_untertitel.py` und Ohne `-o` bzw. `-n` bilden `extract_text.py`, `yt_untertitel.py` und
`abschrift_korrigieren.py -u` den Namen aus Kanal, Titel und Hochladedatum: `abschrift_korrigieren.py -u` den Namen aus Kanal, Titel und Hochladedatum und
schreiben in den Arbeitsordner `abschriften/<Name>/`:
| Video | Name | | Video | Name |
|---|---| |---|---|
@ -108,8 +115,11 @@ Wörter werden mit `_` verbunden. Bindestriche und Punkte im Wort bleiben
Video-ID. Mit `-z` kommen die Bereiche dazu, z. B. Video-ID. Mit `-z` kommen die Bereiche dazu, z. B.
`…_2026-07-30_00-03-17_bis_00-05-25` (`-z A`: `_ab_…`, `-z 0-B` bzw. `-z -B`: `_bis_…`; `…_2026-07-30_00-03-17_bis_00-05-25` (`-z A`: `_ab_…`, `-z 0-B` bzw. `-z -B`: `_bis_…`;
mehrere Bereiche mit `+` verbunden: `…_00-05-00_bis_00-06-00+ab_00-25-00`). mehrere Bereiche mit `+` verbunden: `…_00-05-00_bis_00-06-00+ab_00-25-00`).
Gleiches Video ergibt immer denselben Namen; eine vorhandene Datei wird Gleiches Video ergibt immer denselben Namen und denselben Ordner, Ausschnitte
überschrieben (ein mit `-u` gespeicherter, abweichender Rohtext aber nie). eines Videos landen also beisammen. Eine vorhandene Datei wird überschrieben (ein mit
`-u` gespeicherter, abweichender Rohtext und eine von Hand geänderte Korrektur aber
nie). Neben dem Rohtext entsteht `<Rohtext>.quelle.json` mit Titel, Kanal, Datum und
URL; daraus macht `abschliessen.py` den Quellenkopf.
### Untertitel herunterladen: `yt_untertitel.py` ### Untertitel herunterladen: `yt_untertitel.py`
@ -130,7 +140,8 @@ gehören zur Synchronisation und werden nicht genommen. Sprachen werden auch mit
Region gefunden: `-l en` findet manuelle Untertitel `en-US`. Region gefunden: `-l en` findet manuelle Untertitel `en-US`.
```bash ```bash
./yt_untertitel.py -u URL # → <Kanal>_<Titel>_<Datum>.de-orig.vtt ./yt_untertitel.py -u URL # → abschriften/<Name>/<Name>.de-orig.vtt
./yt_untertitel.py -u URL -n Lahnstein # → abschriften/Lahnstein/Lahnstein.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 untertitel # → untertitel/Lahnstein.de-orig.vtt
``` ```
@ -139,7 +150,7 @@ Region gefunden: `-l en` findet manuelle Untertitel `en-US`.
| `-u`, `--url` | YouTube-URL, Pflicht | | `-u`, `--url` | YouTube-URL, Pflicht |
| `-n`, `--name` | Dateiname ohne Endung; Standard: `<Kanal>_<Titel>_<Datum>` | | `-n`, `--name` | Dateiname ohne Endung; Standard: `<Kanal>_<Titel>_<Datum>` |
| `-l`, `--lang` | Sprache; Standard: `de` | | `-l`, `--lang` | Sprache; Standard: `de` |
| `-d`, `--dir` | Zielverzeichnis (wird angelegt); Standard: aktuelles Verzeichnis | | `-d`, `--dir` | Zielverzeichnis (wird angelegt); Standard: Arbeitsordner `abschriften/<Name>/` |
Eine vorhandene Datei gleichen Namens wird überschrieben. Eine vorhandene Datei gleichen Namens wird überschrieben.
@ -232,7 +243,7 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1.
./abschrift_korrigieren.py -i rede.txt -g glossare/rede.glossar.txt # mit Glossar ./abschrift_korrigieren.py -i rede.txt -g glossare/rede.glossar.txt # mit Glossar
./abschrift_korrigieren.py -i rede.txt -g glossare/allgemein.glossar.txt -g glossare/rede.glossar.txt # kombiniert ./abschrift_korrigieren.py -i rede.txt -g glossare/allgemein.glossar.txt -g glossare/rede.glossar.txt # kombiniert
./abschrift_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt ./abschrift_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt
./abschrift_korrigieren.py -u URL # YouTube → <Kanal>_<Titel>_<Datum>.korrigiert.txt ./abschrift_korrigieren.py -u URL # YouTube → abschriften/<Name>/<Name>.korrigiert.txt
./abschrift_korrigieren.py -i talk.txt -l en # englischer Beitrag ./abschrift_korrigieren.py -i talk.txt -l en # englischer Beitrag
./abschrift_korrigieren.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt des Videos ./abschrift_korrigieren.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt des Videos
./abschrift_korrigieren.py -u URL -z 00:05-00:06 00:25 # mehrere Bereiche, eine Datei ./abschrift_korrigieren.py -u URL -z 00:05-00:06 00:25 # mehrere Bereiche, eine Datei
@ -243,10 +254,10 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1.
| `-i`, `--input` | Eingabedatei: Rohabschrift (`.txt`) oder Untertitel (`.vtt`, wird wie mit `vtt2txt.py` umgewandelt) | | `-i`, `--input` | Eingabedatei: Rohabschrift (`.txt`) oder Untertitel (`.vtt`, wird wie mit `vtt2txt.py` umgewandelt) |
| `-u`, `--url` | statt `-i`: YouTube-URL; Untertitel wie bei `extract_text.py`. Der Rohtext wird neben der Ausgabe gespeichert (`rede.korrigiert.txt` → `rede.txt`, sonst `<Name>.roh.txt`), eine vorhandene, abweichende Datei aber nie überschrieben | | `-u`, `--url` | statt `-i`: YouTube-URL; Untertitel wie bei `extract_text.py`. Der Rohtext wird neben der Ausgabe gespeichert (`rede.korrigiert.txt` → `rede.txt`, sonst `<Name>.roh.txt`), eine vorhandene, abweichende Datei aber nie überschrieben |
| `-l`, `--lang` | Sprache des Beitrags: `de` oder `en` (auch mit Region, z. B. `en-US`); wählt Prompts und Prüfmarke. Mit `-u` zugleich die Sprache der Untertitel. Standard: `de`, mit `-u` die Sprache des Original-Audios | | `-l`, `--lang` | Sprache des Beitrags: `de` oder `en` (auch mit Region, z. B. `en-US`); wählt Prompts und Prüfmarke. Mit `-u` zugleich die Sprache der Untertitel. Standard: `de`, mit `-u` die Sprache des Original-Audios |
| `-o`, `--output` | Ausgabedatei; Standard: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe, mit `-u` `<Kanal>_<Titel>_<Datum>.korrigiert.txt`; mit `-z` zusätzlich die Bereiche im Namen | | `-o`, `--output` | Ausgabedatei; Standard: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe, mit `-u` `abschriften/<Name>/<Name>.korrigiert.txt`; mit `-z` zusätzlich die Bereiche im Namen |
| `-z`, `--zeitraeume` | nur diese Bereiche korrigieren, siehe [Zeitauswahl](#zeitauswahl--z); nur mit `.vtt`-Eingabe oder `-u`, da reiner Text keine Zeitstempel hat | | `-z`, `--zeitraeume` | nur diese Bereiche korrigieren, siehe [Zeitauswahl](#zeitauswahl--z); nur mit `.vtt`-Eingabe oder `-u`, da reiner Text keine Zeitstempel hat |
| `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen | | `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen |
| `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen; mehrfach angeben zum Kombinieren (Ersetzungen in Reihenfolge der Angabe) | | `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen; mehrfach angeben zum Kombinieren (Ersetzungen in Reihenfolge der Angabe). Das Video-Glossar des Ausgabeordners kommt automatisch dazu, siehe [Glossar](#glossar) |
| `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) | | `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) |
| `--kein-bericht` | keinen Änderungsbericht schreiben | | `--kein-bericht` | keinen Änderungsbericht schreiben |
| `--ueberschreiben` | Ausgabedatei auch ersetzen, wenn sie seit dem letzten Lauf von Hand geändert wurde | | `--ueberschreiben` | Ausgabedatei auch ersetzen, wenn sie seit dem letzten Lauf von Hand geändert wurde |
@ -263,6 +274,31 @@ Modell an, an den Rändern der Ausschnitte nichts zu ergänzen. Eine Rohabschrif
(`.txt`) mit solchen Überschriften, etwa von `vtt2txt.py -z`, wird ebenso in (`.txt`) mit solchen Überschriften, etwa von `vtt2txt.py -z`, wird ebenso in
Blöcken korrigiert. Blöcken korrigiert.
## Dateiablage
```text
glossare/ gemeinsame Glossare je Sprecher oder Kanal
abschriften/<Name>/ Arbeitsordner, einer je Video (auch für Ausschnitte)
<Name>.txt Rohtext
<Name>.quelle.json Titel, Kanal, Datum, URL
<Name>.glossar.txt Glossar nur dieses Videos (optional, automatisch genutzt)
<Name>[_bereiche].korrigiert.txt Ergebnis, darf von Hand nachbearbeitet werden
<Name>[_bereiche].aenderungen.html Änderungsbericht
<Name>[_bereiche].korrigiert.parts/ Zwischenstände
fertig/<Name>[_bereiche].txt abgeschlossene Abschriften mit Quellenkopf
testdaten/<rede>/ echte Reden als Regressionstest-Eingaben (.vtt, .txt, Glossar)
tests/daten/ erfundene Beispielrede für die Tests (im Repository)
```
`glossare/`, `abschriften/`, `fertig/` und `testdaten/` stehen in `.gitignore`: Sie
enthalten Inhalte realer Beiträge und Personen und bleiben nur lokal. Wertvoll sind
davon auf Dauer nur `glossare/` und `fertig/`. Diese beiden gehören in eine eigene
Sicherung, z. B. ein zweites, **privates** Git-Repository.
Ein Video durchläuft: Rohtext holen → (Video-Glossar anlegen) → korrigieren →
Bericht prüfen und nacharbeiten → mit `abschliessen.py` abschließen. Danach bleibt
nur `fertig/<Name>.txt`.
## Ablauf ## Ablauf
0. **Vorprüfung:** Läuft Ollama, ist das Modell installiert? Sonst Abbruch mit 0. **Vorprüfung:** Läuft Ollama, ist das Modell installiert? Sonst Abbruch mit
@ -368,17 +404,22 @@ Ersetzungen ohne Treffer werden gesammelt gemeldet. Bei einem Glossar für einen
einzelnen Beitrag deutet das auf einen Tippfehler hin, bei einem gemeinsamen Glossar ist einzelnen Beitrag deutet das auf einen Tippfehler hin, bei einem gemeinsamen Glossar ist
es normal. es normal.
Mehrere Glossare lassen sich kombinieren: ein gemeinsames für wiederkehrende Namen Es gibt zwei Arten von Glossaren:
und Hörfehler eines Redners oder Kanals und eines je Beitrag, z. B.
`glossare/krone-schmalz.glossar.txt` (gemeinsam) plus `glossare/ramstein.glossar.txt`.
Das gemeinsame Glossar zuerst angeben, damit seine Ersetzungen vor den speziellen
greifen.
Glossare liegen im Verzeichnis `glossare/`. Sie gehören zu realen Beiträgen und - **Gemeinsame Glossare** für wiederkehrende Namen und Hörfehler eines Redners oder
Personen und werden deshalb nicht versioniert: `glossare/` und `*.glossar.txt` stehen Kanals liegen in `glossare/`, z. B. `glossare/krone-schmalz.glossar.txt`. Sie werden
in `.gitignore`, die Glossare bleiben nur lokal und sollten gesondert gesichert mit `-g` angegeben.
werden. Im Repository ist als Beispiel nur `tests/daten/beispiel.glossar.txt` zur - **Video-Glossare** gelten nur für ein Video und liegen in dessen Arbeitsordner,
erfundenen Beispielrede. benannt wie der Ordner: `abschriften/<Name>/<Name>.glossar.txt`.
`abschrift_korrigieren.py`, `glossar_vorschlag.py` und `aenderungen.py` nutzen es
automatisch (Meldung „Video-Glossar, automatisch“), und zwar nach den mit `-g`
angegebenen, damit die gemeinsamen Ersetzungen zuerst greifen. Beim Abschluss nennt
`abschliessen.py` seine Einträge, die noch in keinem gemeinsamen Glossar stehen.
Glossare gehören zu realen Beiträgen und Personen und werden deshalb nicht
versioniert (`glossare/`, `abschriften/` und `*.glossar.txt` stehen in `.gitignore`);
`glossare/` sollte gesondert gesichert werden. Im Repository ist als Beispiel nur
`tests/daten/beispiel.glossar.txt` zur erfundenen Beispielrede.
### Glossar-Entwurf: `glossar_vorschlag.py` ### Glossar-Entwurf: `glossar_vorschlag.py`
@ -420,6 +461,46 @@ Der Entwurf ist ein Ausgangspunkt, kein fertiges Glossar: jeden Eintrag prüfen,
Brauchbares ins Glossar des Beitrags (oder ins gemeinsame) übernehmen. Die Modellantworten Brauchbares ins Glossar des Beitrags (oder ins gemeinsame) übernehmen. Die Modellantworten
werden in `<Entwurf>.parts/` zwischengespeichert. werden in `<Entwurf>.parts/` zwischengespeichert.
## Abschluss: `abschliessen.py`
```bash
./abschliessen.py abschriften/<Name> # mit Rückfrage vor dem Löschen
./abschliessen.py abschriften/<Name> --ja # ohne Rückfrage
```
1. Ergebnisse sind alle `*.korrigiert.txt` im Ordner (bei Ausschnitten mehrere).
2. Abbruch, solange ein `*.neu.korrigiert.txt` (Konflikt mit Handarbeit) da ist oder
noch `[prüfen: …]`-Marken im Text stehen (`--trotzdem` schließt dennoch ab).
3. Jedes Ergebnis kommt als `fertig/<Name>[_bereiche].txt` mit Quellenkopf:
```text
Titel: Prof. Gabriele Krone-Schmalz – Respekt geht anders
Kanal: GGBLahnstein
Datum: 2026-07-30
Quelle: https://www.youtube.com/watch?v=rm6lADgf6GQ
Abgeschlossen: 2026-10-11
<korrigierter Text>
```
Ohne `*.quelle.json` nennt der Kopf nur die Datei. Eine vorhandene Datei in
`fertig/` mit anderem Text wird nie überschrieben; mit demselben Text gilt das
Video als schon übernommen (z. B. nach „nicht löschen“).
4. Einträge des Video-Glossars, die in keinem Glossar unter `glossare/` stehen, werden
aufgelistet, damit Brauchbares vorher ins gemeinsame Glossar wandert.
5. Der Arbeitsordner wird angezeigt und nach Rückfrage gelöscht. Nie gelöscht werden
Ordner mit fremden Dateien (andere als `.txt`, `.vtt`, `.html`, `.json`,
`*.parts/`), das Aufrufverzeichnis und alles in `testdaten/`, `glossare/`, `tests/`
oder `fertig/`.
| Option | Bedeutung |
|---|---|
| `ordner` | Arbeitsordner, z. B. `abschriften/<Name>` |
| `--ziel` | Verzeichnis für fertige Abschriften; Standard: `fertig` |
| `--glossare` | Verzeichnis der gemeinsamen Glossare; Standard: `glossare` |
| `--ja` | ohne Rückfrage löschen |
| `--trotzdem` | auch mit offenen Prüfmarken abschließen |
## Testdaten ## Testdaten
**Im Repository:** `tests/daten/` enthält eine frei erfundene Beispielrede **Im Repository:** `tests/daten/` enthält eine frei erfundene Beispielrede
@ -434,20 +515,29 @@ passendes Glossar (`beispiel.glossar.txt`) für die eingebauten Hörfehler wie
``` ```
**Nur lokal:** Echte Reden gehören wegen des Urheberrechts nicht ins Repository; **Nur lokal:** Echte Reden gehören wegen des Urheberrechts nicht ins Repository;
`testdaten/` ist deshalb in `.gitignore`, ebenso die Glossare (`glossare/`). `testdaten/` ist deshalb in `.gitignore`, ebenso die Glossare. Es enthält je Rede
Liegen die drei Reden, mit denen das Programm entwickelt wurde, in `testdaten/` und einen Ordner mit unveränderlichen Eingaben, nie Ausgaben:
ihre Glossare in `glossare/`, laufen zusätzlich die Tests, die diese Glossare
prüfen; sonst werden sie übersprungen:
- `ramstein.de.*`: Rede über die US Air Base Ramstein (Glossar `glossare/ramstein.glossar.txt`) - `testdaten/ramstein/`: `ramstein.de.vtt`, `ramstein.de.txt`, `ramstein.glossar.txt`
- `magdeburg.de-orig.*`: Rede in Magdeburg (Glossar `glossare/magdeburg.glossar.txt`) (Rede über die US Air Base Ramstein)
- `Lahnstein.de-orig.*`: Rede „Respekt geht anders“ in Lahnstein (Glossar - `testdaten/magdeburg/`: `magdeburg.de-orig.vtt`, `.txt`, `magdeburg.glossar.txt`
`glossare/lahnstein.glossar.txt`, erstellt mit Hilfe von `glossar_vorschlag.py`), (Rede in Magdeburg)
zu laden mit - `testdaten/lahnstein/`: `Lahnstein.de-orig.vtt`, `.txt`, `lahnstein.glossar.txt`
`./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten` (Rede „Respekt geht anders“, Glossar 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`
Alle drei mit dem gemeinsamen `glossare/krone-schmalz.glossar.txt` davor. Ein Lauf über eine Liegen sie dort und `glossare/krone-schmalz.glossar.txt` daneben, laufen zusätzlich
Rede dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa 6–8 Minuten. die Tests, die diese Glossare prüfen; sonst werden sie übersprungen. Eine echte Rede
korrigiert man mit Ausgabe in einen Arbeitsordner, damit `testdaten/` sauber bleibt:
```bash
./abschrift_korrigieren.py -i testdaten/ramstein/ramstein.de.txt \
-g glossare/krone-schmalz.glossar.txt -g testdaten/ramstein/ramstein.glossar.txt \
-o abschriften/ramstein.de/ramstein.de.korrigiert.txt
```
Ein Lauf über eine Rede dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa
6–8 Minuten.
## Tests ## Tests
@ -457,9 +547,9 @@ python3 -m pytest
Die Tests brauchen weder Ollama noch Netz: Modellaufrufe und `yt-dlp` werden durch 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 Fakes ersetzt. Als Text dient die erfundene Beispielrede in `tests/daten/` (siehe
[Testdaten](#testdaten)). In einem frischen Klon laufen 318 Tests, 7 werden [Testdaten](#testdaten)). In einem frischen Klon laufen 339 Tests, 7 werden
übersprungen; sie prüfen die Glossare der echten Reden und laufen nur, wenn diese übersprungen; sie prüfen die Glossare der echten Reden und laufen nur, wenn diese
lokal in `testdaten/` liegen (dann 325). lokal in `testdaten/` liegen (dann 346).
`tests/test_abschrift_korrigieren.py` prüft Abschnittsbildung, Satzgrenzen `tests/test_abschrift_korrigieren.py` prüft Abschnittsbildung, Satzgrenzen
(Abkürzungen und Ordnungszahlen sind kein Satzende), Glossar (jede Ersetzung des (Abkürzungen und Ordnungszahlen sind kein Satzende), Glossar (jede Ersetzung des
@ -467,7 +557,8 @@ Beispielglossars trifft die Beispielrede; lokal zusätzlich: jede Ersetzung im
Glossar einer Rede trifft diese Rede, jede im gemeinsamen Glossar mindestens eine), Glossar einer Rede trifft diese Rede, jede im gemeinsamen Glossar mindestens eine),
Prompts (keine Namensteile ergänzen), Streaming-Auswertung, Absatzgliederung (Text Prompts (keine Namensteile ergänzen), Streaming-Auswertung, Absatzgliederung (Text
bleibt unverändert, 3–10 Sätze), Verbinden kurzer Absätze (Modell wird nur gefragt, bleibt unverändert, 3–10 Sätze), Verbinden kurzer Absätze (Modell wird nur gefragt,
wenn beide Nachbarn passen), Zwischenspeicher/Fortsetzen, Schutz von Handarbeit wenn beide Nachbarn passen), Zwischenspeicher/Fortsetzen, Arbeitsordner,
Quellenangabe und automatisches Video-Glossar, Schutz von Handarbeit
(`.neu.korrigiert.txt`, `--ueberschreiben`), Zeitauswahl, Sprachen und die (`.neu.korrigiert.txt`, `--ueberschreiben`), Zeitauswahl, Sprachen und die
Kommandozeile. Kommandozeile.
`tests/test_glossar_vorschlag.py` prüft Filter (nur Funde im Text, keine bekannten), `tests/test_glossar_vorschlag.py` prüft Filter (nur Funde im Text, keine bekannten),
@ -481,6 +572,9 @@ Die Tests nutzen eine kleine eigene Wortliste statt der des Systems.
(Original-Audio, manuell vor automatisch) und Fehlerfälle. (Original-Audio, manuell vor automatisch) und Fehlerfälle.
`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und `tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und
Fehlerfälle (ohne Netz, `yt-dlp` wird durch Fakes ersetzt). Fehlerfälle (ohne Netz, `yt-dlp` wird durch Fakes ersetzt).
`tests/test_abschliessen.py` prüft den Abschluss: Quellenkopf, mehrere Ausschnitte,
Abbruch bei Prüfmarken und Konflikten, nie überschreiben, Rückfrage, Schutz vor dem
Löschen fremder und geschützter Ordner und den Hinweis auf Video-Glossar-Einträge.
`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: die Beispiel-`.vtt` (und lokal `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 jede Rede in `testdaten/`) ergibt genau die zugehörige `.txt`, echte Wiederholungen
bleiben erhalten, auch bei Untertiteln ohne rollendes Fenster, dazu Zeitauswahl bleiben erhalten, auch bei Untertiteln ohne rollendes Fenster, dazu Zeitauswahl
@ -494,7 +588,9 @@ Verbinden kurzer Absätze wird in `<Ausgabe>.parts/` gespeichert. Ein erneuter
Lauf überspringt alles, was schon berechnet ist. Lauf überspringt alles, was schon berechnet ist.
Ändern sich Modell (auch per `-m`), Prompt, Glossar oder Text eines Abschnitts, wird Ändern sich Modell (auch per `-m`), Prompt, Glossar oder Text eines Abschnitts, wird
genau dieser Teil neu berechnet. Glossar-Begriffe stehen im Prompt; ein neuer Begriff genau dieser Teil neu berechnet. Glossar-Begriffe stehen im Prompt; ein neuer Begriff
lässt deshalb alle Abschnitte neu korrigieren. Den Ordner kann man jederzeit löschen. lässt deshalb alle Abschnitte neu korrigieren. Den Ordner kann man jederzeit löschen;
`abschliessen.py` löscht ihn mit dem Arbeitsordner. Er wächst sonst mit jeder
Prompt- oder Modelländerung, weil alte Fassungen liegen bleiben.
`glossar_vorschlag.py` speichert seine Modellantworten ebenso in `glossar_vorschlag.py` speichert seine Modellantworten ebenso in
`<Entwurf>.parts/`. `<Entwurf>.parts/`.
@ -530,6 +626,15 @@ In `vtt2txt.py`:
| `MAX_SNAP_SECONDS` | 30 | so weit darf ein Zeitbereich bis zur nächsten Satzgrenze wachsen | | `MAX_SNAP_SECONDS` | 30 | so weit darf ein Zeitbereich bis zur nächsten Satzgrenze wachsen |
| `ROLLING_SHARE` | 0.5 | ab diesem Anteil sich überlappender Blöcke gilt eine Datei als rollend | | `ROLLING_SHARE` | 0.5 | ab diesem Anteil sich überlappender Blöcke gilt eine Datei als rollend |
In `yt_untertitel.py` und `abschliessen.py` (Pfade relativ zum Aufrufverzeichnis, also
im Programmordner aufrufen):
| Konstante | Standard | Bedeutung |
|---|---|---|
| `WORK_DIR` | `abschriften` | Arbeitsordner je Video: `abschriften/<Name>/` |
| `FINISHED_DIR` | `fertig` | abgeschlossene Abschriften (Option `--ziel`) |
| `GLOSSARY_DIR` | `glossare` | gemeinsame Glossare (Option `--glossare`) |
## Bekannte Grenzen ## Bekannte Grenzen
- Das Modell ändert gelegentlich Wörter eigenmächtig, auch sinnverändernd - Das Modell ändert gelegentlich Wörter eigenmächtig, auch sinnverändernd

244
abschliessen.py Executable file
View file

@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Schließt die Abschrift eines Videos ab: Die korrigierte Textdatei kommt mit einem
Quellenkopf nach fertig/, der Arbeitsordner abschriften/<Name>/ wird nach Rückfrage
gelöscht."""
from datetime import date
from pathlib import Path
import argparse
import json
import shutil
import sys
from abschrift_korrigieren import read_glossary, video_glossary
from aenderungen import REVIEW_MARK
FINISHED_DIR = Path("fertig")
GLOSSARY_DIR = Path("glossare")
# What a working folder may hold; anything else stops the deletion
WORK_SUFFIXES = {".txt", ".vtt", ".html", ".json"}
# Never deleted, nor anything inside them
PROTECTED = {"testdaten", "glossare", "tests", "fertig"}
SHOWN_MARKS = 3
def results(folder):
"""Corrected texts of the folder (several for excerpts), without conflicts."""
return sorted(
p for p in folder.glob("*.korrigiert.txt")
if not p.name.endswith(".neu.korrigiert.txt")
)
def finished_name(result):
"""rede_00-05-00_bis_00-06-00.korrigiert.txt -> rede_00-05-00_bis_00-06-00.txt"""
return result.name.removesuffix(".korrigiert.txt") + ".txt"
def read_source(folder):
"""Contents of the folder's <Rohtext>.quelle.json, None without one."""
for path in sorted(folder.glob("*.quelle.json")):
return json.loads(path.read_text(encoding="utf-8"))
return None
def format_date(text):
""""20260328" -> "2026-03-28"; anything else unchanged."""
if text and len(text) == 8 and text.isdigit():
return f"{text[:4]}-{text[4:6]}-{text[6:]}"
return text
def header(source, result, today):
"""Source lines in front of the finished text, then a blank line."""
lines = []
if source:
for label, value in (
("Titel", source.get("titel")),
("Kanal", source.get("kanal")),
("Datum", format_date(source.get("datum"))),
("Quelle", source.get("url")),
):
if value:
lines.append(f"{label}: {value}")
else:
lines.append(f"Datei: {result.name}")
lines.append(f"Abgeschlossen: {today}")
return "\n".join(lines) + "\n\n"
def already_finished(target, text):
"""True if the target holds this text below a header."""
if not target.exists():
return False
_, _, body = target.read_text(encoding="utf-8").partition("\n\n")
return body == text
def open_marks(text):
"""(line number, line) of every line with a review mark."""
return [
(number, line.strip())
for number, line in enumerate(text.splitlines(), start=1)
if REVIEW_MARK.search(line)
]
def unshared_entries(glossary, glossary_dir):
"""Entries of the video glossary that are in no shared glossary."""
shared_terms, shared_replacements = set(), set()
for path in sorted(glossary_dir.glob("*.glossar.txt")):
terms, replacements = read_glossary(path)
shared_terms.update(terms)
shared_replacements.update(replacements)
terms, replacements = read_glossary(glossary)
return [
f"{wrong} => {right}" for wrong, right in replacements
if (wrong, right) not in shared_replacements
] + [term for term in terms if term not in shared_terms]
def deletion_blockers(folder):
"""Reasons not to delete the folder; empty for a plain working folder."""
resolved = folder.resolve()
cwd = Path.cwd().resolve()
if resolved == cwd or resolved in cwd.parents:
return ["Er ist das Aufrufverzeichnis oder liegt darüber."]
for path in (resolved, *resolved.parents):
if path == cwd:
break
if path.name in PROTECTED:
return [f"Er ist oder liegt in „{path.name}“, das nie gelöscht wird."]
return [
f"Fremde Datei: {entry.name}"
for entry in sorted(folder.iterdir())
if not (
(entry.is_dir() and entry.name.endswith(".parts"))
or (entry.is_file() and entry.suffix.lower() in WORK_SUFFIXES)
)
]
def size(path):
"""Size of a file or folder, readable: "830 B", "17 KB"."""
if path.is_file():
total = path.stat().st_size
else:
total = sum(p.stat().st_size for p in path.rglob("*") if p.is_file())
return f"{total} B" if total < 1024 else f"{total / 1024:.0f} KB"
def confirm(question):
try:
return input(question).strip().lower() in {"j", "ja", "y", "yes"}
except EOFError:
return False
def parse_args():
parser = argparse.ArgumentParser(
description="Schließt die Abschrift eines Videos ab: Ergebnis mit Quellenkopf "
"nach fertig/, Arbeitsordner nach Rückfrage löschen."
)
parser.add_argument("ordner", type=Path, help="Arbeitsordner, z. B. abschriften/<Name>")
parser.add_argument(
"--ziel", type=Path, default=FINISHED_DIR,
help=f"Verzeichnis für fertige Abschriften (Standard: {FINISHED_DIR})",
)
parser.add_argument(
"--glossare", type=Path, default=GLOSSARY_DIR,
help=f"Verzeichnis der gemeinsamen Glossare (Standard: {GLOSSARY_DIR})",
)
parser.add_argument(
"--ja", action="store_true", help="Arbeitsordner ohne Rückfrage löschen"
)
parser.add_argument(
"--trotzdem", action="store_true",
help="auch abschließen, wenn noch Prüfmarken [prüfen: …] im Text stehen",
)
args = parser.parse_args()
if not args.ordner.is_dir():
parser.error(f"Ordner nicht gefunden: {args.ordner}")
return args
def main():
args = parse_args()
folder = args.ordner
found = results(folder)
if not found:
raise SystemExit(f"Keine korrigierte Abschrift (*.korrigiert.txt) in {folder}.")
conflicts = sorted(folder.glob("*.neu.korrigiert.txt"))
if conflicts:
names = ", ".join(p.name for p in conflicts)
raise SystemExit(
f"Ungeklärter Konflikt: {names}. Erst die Änderungen übertragen bzw. die "
"Datei löschen, dann abschließen."
)
texts = {result: result.read_text(encoding="utf-8") for result in found}
marked = {result: open_marks(text) for result, text in texts.items()}
if any(marked.values()):
for result, marks in marked.items():
if marks:
print(f"{result.name}: {len(marks)} Zeile(n) mit Prüfmarken, z. B.")
for number, line in marks[:SHOWN_MARKS]:
print(f" Zeile {number}: {line[:120]}")
if not args.trotzdem:
raise SystemExit(
"Noch offene Prüfmarken. Erst klären, oder mit --trotzdem abschließen."
)
targets = {result: args.ziel / finished_name(result) for result in found}
# A finished text with the same body is from an earlier run (e.g. answered
# "n" when asked to delete); a different one is never overwritten
done = {result for result, target in targets.items() if already_finished(target, texts[result])}
existing = [str(t) for r, t in targets.items() if t.exists() and r not in done]
if existing:
raise SystemExit(
f"Gibt es schon in {args.ziel} mit anderem Text: {', '.join(existing)}. "
"Nichts überschrieben; vorhandene Datei umbenennen oder entfernen."
)
source = read_source(folder)
if source is None and len(done) < len(targets):
print("Hinweis: keine Quellenangabe (*.quelle.json); der Kopf nennt nur die Datei.")
today = date.today().isoformat()
args.ziel.mkdir(parents=True, exist_ok=True)
for result, target in targets.items():
if result in done:
print(f"Bereits fertig: {target}")
continue
target.write_text(header(source, result, today) + texts[result], encoding="utf-8")
print(f"Fertig: {target}")
glossary = video_glossary(folder)
if glossary is not None:
missing = unshared_entries(glossary, args.glossare)
if missing:
print(
f"\nDiese Einträge aus {glossary} stehen in keinem Glossar in "
f"{args.glossare}/; vor dem Löschen ggf. in ein gemeinsames übernehmen:"
)
for entry in missing:
print(f" {entry}")
blockers = deletion_blockers(folder)
if blockers:
print(f"\nArbeitsordner {folder} bleibt erhalten:")
for reason in blockers:
print(f" {reason}")
return
print(f"\nArbeitsordner {folder}:")
for entry in sorted(folder.iterdir()):
print(f" {entry.name}{'/' if entry.is_dir() else ''} ({size(entry)})")
if not args.ja and not confirm(f"Arbeitsordner {folder} löschen? [j/N] "):
print("Nicht gelöscht.")
return
shutil.rmtree(folder)
print(f"Gelöscht: {folder}")
if __name__ == "__main__":
sys.exit(main())

View file

@ -21,7 +21,9 @@ from vtt2txt import (
add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments, add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments,
time_suffix, vtt_to_text, time_suffix, vtt_to_text,
) )
from yt_untertitel import YT_DLP, base_language, default_name, original_language from yt_untertitel import (
YT_DLP, base_language, default_name, original_language, work_folder, write_source,
)
MODEL = "qwen3:32b" MODEL = "qwen3:32b"
OLLAMA_URL = "http://127.0.0.1:11434/api/chat" OLLAMA_URL = "http://127.0.0.1:11434/api/chat"
@ -264,6 +266,22 @@ def read_glossary(path):
return terms, replacements return terms, replacements
def video_glossary(folder):
"""Glossary of the video in `folder`, named like the folder:
abschriften/<Name>/<Name>.glossar.txt; None if there is none."""
path = folder / f"{folder.resolve().name}.glossar.txt"
return path if path.is_file() else None
def with_video_glossary(glossaries, folder):
"""The given glossaries plus the video glossary of `folder` (last, after the
shared ones), unless it was given; returns (glossaries, added or None)."""
found = video_glossary(folder)
if found is None or any(g.resolve() == found.resolve() for g in glossaries):
return list(glossaries), None
return [*glossaries, found], found
def apply_replacements(text, replacements): def apply_replacements(text, replacements):
"""Replace whole words in file order; report rules without any match.""" """Replace whole words in file order; report rules without any match."""
unmatched = [] unmatched = []
@ -633,8 +651,8 @@ def parse_args():
parser.add_argument( parser.add_argument(
"-o", "--output", type=Path, "-o", "--output", type=Path,
help="Ausgabedatei (Standard: <Eingabe ohne Endung>.korrigiert.txt, " help="Ausgabedatei (Standard: <Eingabe ohne Endung>.korrigiert.txt, "
"mit -u <Kanal>_<Titel>_<Datum>.korrigiert.txt; mit -z zusätzlich " "mit -u abschriften/<Name>/<Name>.korrigiert.txt mit "
"die Bereiche)", "<Name> = <Kanal>_<Titel>_<Datum>; mit -z zusätzlich die Bereiche)",
) )
parser.add_argument( parser.add_argument(
"-g", "--glossar", type=Path, action="append", default=[], "-g", "--glossar", type=Path, action="append", default=[],
@ -709,9 +727,12 @@ def read_source(args):
f"Der Beitrag ist auf „{lang}“; unterstützt: {', '.join(PROMPTS)} (-l)." f"Der Beitrag ist auf „{lang}“; unterstützt: {', '.join(PROMPTS)} (-l)."
) )
suffix = time_suffix(args.bereiche) suffix = time_suffix(args.bereiche)
target = args.output or Path(f"{default_name(info)}{suffix}.korrigiert.txt") name = default_name(info)
target = args.output or work_folder(name) / f"{name}{suffix}.korrigiert.txt"
raw = text + "\n" raw = text + "\n"
save_raw_text(raw_text_path(target), raw) raw_path = raw_text_path(target)
save_raw_text(raw_path, raw)
write_source(info, raw_path.with_suffix(".quelle.json"))
return raw, target, lang return raw, target, lang
raw = args.input.read_text(encoding="utf-8-sig") raw = args.input.read_text(encoding="utf-8-sig")
@ -762,10 +783,12 @@ def main():
parts_dir = target.with_suffix(".parts") parts_dir = target.with_suffix(".parts")
terms, replacements = [], [] terms, replacements = [], []
for glossary in args.glossar: glossaries, automatic = with_video_glossary(args.glossar, target.parent)
for glossary in glossaries:
more_terms, more_replacements = read_glossary(glossary) more_terms, more_replacements = read_glossary(glossary)
note = " (Video-Glossar, automatisch)" if glossary == automatic else ""
print( print(
f"Glossar {glossary}: {len(more_terms)} Begriffe, " f"Glossar {glossary}{note}: {len(more_terms)} Begriffe, "
f"{len(more_replacements)} feste Ersetzungen" f"{len(more_replacements)} feste Ersetzungen"
) )
terms += more_terms terms += more_terms

View file

@ -473,13 +473,16 @@ def parse_args():
def main(): def main():
# abschrift_korrigieren imports this module: import it only when needed # abschrift_korrigieren imports this module: import it only when needed
from abschrift_korrigieren import apply_replacements, read_glossary from abschrift_korrigieren import apply_replacements, read_glossary, with_video_glossary
from yt_untertitel import base_language from yt_untertitel import base_language
args = parse_args() args = parse_args()
raw = read_raw(args.roh) raw = read_raw(args.roh)
terms, replacements = [], [] terms, replacements = [], []
for glossary in args.glossar: glossaries, automatic = with_video_glossary(args.glossar, args.korrigiert.parent)
if automatic is not None:
print(f"Video-Glossar (automatisch): {automatic}")
for glossary in glossaries:
more_terms, more_replacements = read_glossary(glossary) more_terms, more_replacements = read_glossary(glossary)
terms += more_terms terms += more_terms
replacements += more_replacements replacements += more_replacements

View file

@ -14,7 +14,7 @@ from vtt2txt import (
) )
from yt_untertitel import ( from yt_untertitel import (
YT_DLP, choose_track, default_name, download, fetch_info, no_track_message, YT_DLP, choose_track, default_name, download, fetch_info, no_track_message,
original_language, original_language, work_folder, write_source,
) )
FLOW_WIDTH = 80 # Zeilenbreite für --fliesstext FLOW_WIDTH = 80 # Zeilenbreite für --fliesstext
@ -31,8 +31,8 @@ def parse_args():
) )
parser.add_argument( parser.add_argument(
"-o", "--output", type=Path, "-o", "--output", type=Path,
help="Ausgabedatei (Standard: <Kanal>_<Titel>_<Datum>.txt, " help="Ausgabedatei (Standard: abschriften/<Name>/<Name>.txt mit "
"mit -z zusätzlich die Bereiche)", "<Name> = <Kanal>_<Titel>_<Datum>, mit -z zusätzlich die Bereiche)",
) )
parser.add_argument( parser.add_argument(
"-f", "--fliesstext", action="store_true", "-f", "--fliesstext", action="store_true",
@ -87,7 +87,8 @@ def main():
args = parse_args() args = parse_args()
info, text = fetch_text(args.url, args.lang, args.bereiche, args.exakt) info, text = fetch_text(args.url, args.lang, args.bereiche, args.exakt)
suffix = time_suffix(args.bereiche) suffix = time_suffix(args.bereiche)
output = args.output or Path(f"{default_name(info)}{suffix}.txt") name = default_name(info)
output = args.output or work_folder(name) / f"{name}{suffix}.txt"
segments = split_segments(text) segments = split_segments(text)
if args.fliesstext: if args.fliesstext:
@ -104,6 +105,7 @@ def main():
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(text + "\n", encoding="utf-8") output.write_text(text + "\n", encoding="utf-8")
write_source(info, output.with_suffix(".quelle.json"))
print( print(
f"Gespeichert: {output} " f"Gespeichert: {output} "
f"({len(text.splitlines())} Zeilen, " f"({len(text.splitlines())} Zeilen, "

View file

@ -13,7 +13,7 @@ import re
from aenderungen import compare, read_raw from aenderungen import compare, read_raw
from abschrift_korrigieren import ( from abschrift_korrigieren import (
MODEL, apply_replacements, chat_request, check_ollama, ollama_open, read_glossary, MODEL, apply_replacements, chat_request, check_ollama, ollama_open, read_glossary,
split_chunks, split_chunks, with_video_glossary,
) )
from vtt2txt import split_segments from vtt2txt import split_segments
@ -226,7 +226,10 @@ def main():
raw = text raw = text
known, replacements = set(), [] known, replacements = set(), []
for glossary in args.glossar: glossaries, automatic = with_video_glossary(args.glossar, args.input.parent)
if automatic is not None:
print(f"Video-Glossar (automatisch): {automatic}")
for glossary in glossaries:
_, more = read_glossary(glossary) _, more = read_glossary(glossary)
replacements += more replacements += more
known.update(wrong for wrong, _ in more) known.update(wrong for wrong, _ in more)

162
tests/test_abschliessen.py Normal file
View file

@ -0,0 +1,162 @@
"""Tests for abschliessen.py (finish a video: result to fertig/, delete working folder)."""
import json
import sys
from datetime import date
import pytest
import abschliessen as ab
TEXT = "Der Kreml ist in Moskau.\n\nDas ist bekannt.\n"
SOURCE = {
"titel": "Eine Rede", "kanal": "Kanal", "datum": "20260328",
"url": "https://www.youtube.com/watch?v=abc123", "id": "abc123",
}
@pytest.fixture
def work(tmp_path, monkeypatch):
"""abschriften/rede/ with everything a run leaves behind; cwd is tmp_path."""
monkeypatch.chdir(tmp_path)
folder = tmp_path / "abschriften" / "rede"
parts = folder / "rede.korrigiert.parts"
parts.mkdir(parents=True)
(parts / "part001-abc.txt").write_text("x", encoding="utf-8")
(folder / "rede.txt").write_text("der kremmel ist in moskau\n", encoding="utf-8")
(folder / "rede.korrigiert.txt").write_text(TEXT, encoding="utf-8")
(folder / "rede.aenderungen.html").write_text("<p>", encoding="utf-8")
(folder / "rede.quelle.json").write_text(json.dumps(SOURCE), encoding="utf-8")
return folder
def run_main(monkeypatch, *args, answer=None):
monkeypatch.setattr(sys, "argv", ["abschliessen.py", *map(str, args)])
if answer is not None:
monkeypatch.setattr("builtins.input", lambda question: answer)
ab.main()
def test_finish_writes_header_and_deletes(work, tmp_path, monkeypatch, capsys):
run_main(monkeypatch, work, "--ja")
finished = (tmp_path / "fertig" / "rede.txt").read_text(encoding="utf-8")
assert finished == (
"Titel: Eine Rede\nKanal: Kanal\nDatum: 2026-03-28\n"
"Quelle: https://www.youtube.com/watch?v=abc123\n"
f"Abgeschlossen: {date.today().isoformat()}\n\n" + TEXT
)
assert not work.exists()
out = capsys.readouterr().out
assert "rede.korrigiert.parts/" in out and f"Gelöscht: {work}" in out
def test_answer_no_keeps_folder_and_rerun_deletes(work, tmp_path, monkeypatch, capsys):
run_main(monkeypatch, work, answer="n")
assert work.is_dir()
assert (tmp_path / "fertig" / "rede.txt").is_file()
assert "Nicht gelöscht." in capsys.readouterr().out
# Later: the result is already in fertig/, only the deletion is left
run_main(monkeypatch, work, answer="j")
assert not work.exists()
assert "Bereits fertig" in capsys.readouterr().out
def test_open_review_marks_stop(work, tmp_path, monkeypatch, capsys):
(work / "rede.korrigiert.txt").write_text(
"Der Kreml [prüfen: Kremmel] ist in Moskau.\n", encoding="utf-8"
)
with pytest.raises(SystemExit, match="offene Prüfmarken"):
run_main(monkeypatch, work, "--ja")
assert "Zeile 1: Der Kreml [prüfen: Kremmel]" in capsys.readouterr().out
assert not (tmp_path / "fertig").exists() and work.is_dir()
run_main(monkeypatch, work, "--ja", "--trotzdem")
assert (tmp_path / "fertig" / "rede.txt").is_file()
def test_conflict_stops(work, monkeypatch):
(work / "rede.neu.korrigiert.txt").write_text(TEXT, encoding="utf-8")
with pytest.raises(SystemExit, match="Ungeklärter Konflikt: rede.neu.korrigiert.txt"):
run_main(monkeypatch, work, "--ja")
assert work.is_dir()
def test_no_result_stops(work, monkeypatch):
(work / "rede.korrigiert.txt").unlink()
with pytest.raises(SystemExit, match="Keine korrigierte Abschrift"):
run_main(monkeypatch, work, "--ja")
def test_existing_different_target_is_never_overwritten(work, tmp_path, monkeypatch):
(tmp_path / "fertig").mkdir()
(tmp_path / "fertig" / "rede.txt").write_text("Anderer Text\n", encoding="utf-8")
with pytest.raises(SystemExit, match="mit anderem Text"):
run_main(monkeypatch, work, "--ja")
assert (tmp_path / "fertig" / "rede.txt").read_text(encoding="utf-8") == "Anderer Text\n"
assert work.is_dir()
def test_without_source_header_names_the_file(work, tmp_path, monkeypatch, capsys):
(work / "rede.quelle.json").unlink()
run_main(monkeypatch, work, "--ja")
finished = (tmp_path / "fertig" / "rede.txt").read_text(encoding="utf-8")
assert finished.startswith("Datei: rede.korrigiert.txt\nAbgeschlossen: ")
assert "keine Quellenangabe" in capsys.readouterr().out
def test_every_excerpt_is_finished(work, tmp_path, monkeypatch):
(work / "rede_ab_00-05-00.korrigiert.txt").write_text("[00:05:00 - 00:06:00]\n\nB.\n")
run_main(monkeypatch, work, "--ja")
assert (tmp_path / "fertig" / "rede.txt").is_file()
assert (tmp_path / "fertig" / "rede_ab_00-05-00.txt").read_text().endswith(
"[00:05:00 - 00:06:00]\n\nB.\n"
)
def test_foreign_file_prevents_deletion(work, tmp_path, monkeypatch, capsys):
(work / "notizen.docx").write_bytes(b"x")
run_main(monkeypatch, work, "--ja")
assert work.is_dir()
assert (tmp_path / "fertig" / "rede.txt").is_file()
assert "Fremde Datei: notizen.docx" in capsys.readouterr().out
@pytest.mark.parametrize("protected", ["testdaten", "glossare", "tests", "fertig"])
def test_protected_folders_are_never_deleted(tmp_path, monkeypatch, capsys, protected):
monkeypatch.chdir(tmp_path)
folder = tmp_path / protected / "rede"
folder.mkdir(parents=True)
(folder / "rede.korrigiert.txt").write_text(TEXT, encoding="utf-8")
run_main(monkeypatch, folder, "--ja", "--ziel", tmp_path / "ziel")
assert folder.is_dir()
assert f"„{protected}“, das nie gelöscht wird" in capsys.readouterr().out
def test_current_directory_is_never_deleted(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
(tmp_path / "rede.korrigiert.txt").write_text(TEXT, encoding="utf-8")
run_main(monkeypatch, ".", "--ja")
assert (tmp_path / "rede.korrigiert.txt").is_file()
assert "Aufrufverzeichnis" in capsys.readouterr().out
def test_video_glossary_entries_not_shared_are_listed(work, tmp_path, monkeypatch, capsys):
(work / "rede.glossar.txt").write_text(
"Kremmel => Kreml\nNetano => Netanjahu\nEgon Bahr\nMoskau\n", encoding="utf-8"
)
(tmp_path / "glossare").mkdir()
(tmp_path / "glossare" / "gemeinsam.glossar.txt").write_text(
"Kremmel => Kreml\nMoskau\n", encoding="utf-8"
)
run_main(monkeypatch, work, "--ja")
out = capsys.readouterr().out
listed = out.split("gemeinsames übernehmen:")[1].split("\n\n")[0]
assert "Netano => Netanjahu" in listed and "Egon Bahr" in listed
assert "Kremmel" not in listed and "Moskau" not in listed
def test_missing_folder(tmp_path, monkeypatch, capsys):
with pytest.raises(SystemExit):
run_main(monkeypatch, tmp_path / "fehlt")
assert "Ordner nicht gefunden" in capsys.readouterr().err

View file

@ -14,7 +14,8 @@ REPO = Path(__file__).resolve().parent.parent
# Own, made-up sample speech; real speeches are only kept locally in testdaten/ # Own, made-up sample speech; real speeches are only kept locally in testdaten/
SAMPLE = REPO / "tests" / "daten" / "beispiel.de.txt" SAMPLE = REPO / "tests" / "daten" / "beispiel.de.txt"
GLOSSARY = REPO / "tests" / "daten" / "beispiel.glossar.txt" GLOSSARY = REPO / "tests" / "daten" / "beispiel.glossar.txt"
# Real glossaries and speeches are only kept locally (see .gitignore) # Real glossaries and speeches are only kept locally (see .gitignore): shared
# glossaries in glossare/, each speech with its glossary in testdaten/<rede>/
GLOSSARIES = REPO / "glossare" GLOSSARIES = REPO / "glossare"
SHARED_GLOSSARY = GLOSSARIES / "krone-schmalz.glossar.txt" SHARED_GLOSSARY = GLOSSARIES / "krone-schmalz.glossar.txt"
SPEECHES = REPO / "testdaten" SPEECHES = REPO / "testdaten"
@ -127,17 +128,18 @@ def test_build_system_appends_terms():
assert "keep only the surname" in ak.SYSTEM_EN assert "keep only the surname" in ak.SYSTEM_EN
# testdaten/<rede>/: raw text and the glossary of that speech
SAMPLE_GLOSSARIES = [ SAMPLE_GLOSSARIES = [
("ramstein.glossar.txt", "ramstein.de.txt"), ("ramstein/ramstein.glossar.txt", "ramstein/ramstein.de.txt"),
("magdeburg.glossar.txt", "magdeburg.de-orig.txt"), ("magdeburg/magdeburg.glossar.txt", "magdeburg/magdeburg.de-orig.txt"),
("lahnstein.glossar.txt", "Lahnstein.de-orig.txt"), ("lahnstein/lahnstein.glossar.txt", "lahnstein/Lahnstein.de-orig.txt"),
] ]
# Real speeches and their glossaries are not in the repository; without them these skip # Real speeches and their glossaries are not in the repository; without them these skip
needs_speeches = pytest.mark.skipif( needs_speeches = pytest.mark.skipif(
not all( not all(
(SPEECHES / sample).is_file() and (GLOSSARIES / glossary).is_file() (SPEECHES / sample).is_file() and (SPEECHES / glossary).is_file()
for glossary, sample in SAMPLE_GLOSSARIES for glossary, sample in SAMPLE_GLOSSARIES
) or not SHARED_GLOSSARY.is_file(), ) or not SHARED_GLOSSARY.is_file(),
reason="echte Reden (testdaten/) und ihre Glossare nur lokal", reason="echte Reden (testdaten/) und ihre Glossare nur lokal",
@ -157,7 +159,7 @@ def test_sample_glossary_replacements_all_match(capsys, glossary, sample):
text = flat((SPEECHES / sample).read_text(encoding="utf-8")) text = flat((SPEECHES / sample).read_text(encoding="utf-8"))
text = ak.apply_replacements(text, ak.read_glossary(SHARED_GLOSSARY)[1]) text = ak.apply_replacements(text, ak.read_glossary(SHARED_GLOSSARY)[1])
capsys.readouterr() capsys.readouterr()
ak.apply_replacements(text, ak.read_glossary(GLOSSARIES / glossary)[1]) ak.apply_replacements(text, ak.read_glossary(SPEECHES / glossary)[1])
assert "ohne Treffer" not in capsys.readouterr().out assert "ohne Treffer" not in capsys.readouterr().out
@ -636,13 +638,63 @@ def kreml_glossary(folder):
def test_main_url_default_names(from_url, tmp_path, monkeypatch): def test_main_url_default_names(from_url, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
work = tmp_path / "abschriften" / "Kanal_Rede_2026-03-28"
run_main(monkeypatch, "-u", URL, "-g", kreml_glossary(tmp_path)) run_main(monkeypatch, "-u", URL, "-g", kreml_glossary(tmp_path))
raw = (tmp_path / "Kanal_Rede_2026-03-28.txt").read_text(encoding="utf-8") raw = (work / "Kanal_Rede_2026-03-28.txt").read_text(encoding="utf-8")
assert raw == "Der Kremmel ist in Moskau.\nDas ist bekannt.\n" assert raw == "Der Kremmel ist in Moskau.\nDas ist bekannt.\n"
result = (tmp_path / "Kanal_Rede_2026-03-28.korrigiert.txt").read_text(encoding="utf-8") result = (work / "Kanal_Rede_2026-03-28.korrigiert.txt").read_text(encoding="utf-8")
assert result == "Der Kreml ist in Moskau. Das ist bekannt.\n" assert result == "Der Kreml ist in Moskau. Das ist bekannt.\n"
assert (tmp_path / "Kanal_Rede_2026-03-28.aenderungen.html").is_file() assert (work / "Kanal_Rede_2026-03-28.aenderungen.html").is_file()
source = json.loads((work / "Kanal_Rede_2026-03-28.quelle.json").read_text(encoding="utf-8"))
assert source["titel"] == "Rede" and source["kanal"] == "Kanal"
assert source["datum"] == "20260328"
assert source["url"] == "https://www.youtube.com/watch?v=abc123"
def video_folder(tmp_path):
"""abschriften/rede/ with raw text and its video glossary."""
folder = tmp_path / "abschriften" / "rede"
folder.mkdir(parents=True)
(folder / "rede.txt").write_text("Der Kremmel ist in Moskau.\n", encoding="utf-8")
(folder / "rede.glossar.txt").write_text("Kremmel => Kreml\n", encoding="utf-8")
return folder
def test_main_uses_video_glossary(tmp_path, monkeypatch, capsys):
folder = video_folder(tmp_path)
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
run_main(monkeypatch, "-i", folder / "rede.txt")
result = (folder / "rede.korrigiert.txt").read_text(encoding="utf-8")
assert result == "Der Kreml ist in Moskau.\n"
assert "rede.glossar.txt (Video-Glossar, automatisch)" in capsys.readouterr().out
def test_main_video_glossary_given_explicitly_counts_once(tmp_path, monkeypatch, capsys):
folder = video_folder(tmp_path)
shared = tmp_path / "gemeinsam.glossar.txt"
shared.write_text("Moskau\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", folder / "rede.txt", "-g", shared, "-g", folder / "rede.glossar.txt")
out = capsys.readouterr().out
assert out.count("rede.glossar.txt") == 1
assert "automatisch" not in out
# Shared glossaries first, as given
assert out.index("gemeinsam.glossar.txt") < out.index("rede.glossar.txt")
def test_with_video_glossary(tmp_path):
folder = video_folder(tmp_path)
shared = tmp_path / "gemeinsam.glossar.txt"
assert ak.with_video_glossary([shared], folder) == (
[shared, folder / "rede.glossar.txt"], folder / "rede.glossar.txt"
)
# Without a glossary named like the folder nothing is added
assert ak.with_video_glossary([shared], tmp_path) == ([shared], None)
def test_main_url_output_option_and_kept_raw_text(from_url, tmp_path, monkeypatch, capsys): def test_main_url_output_option_and_kept_raw_text(from_url, tmp_path, monkeypatch, capsys):
@ -762,19 +814,21 @@ def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
def test_main_url_time_range_default_names(from_url, tmp_path, monkeypatch): def test_main_url_time_range_default_names(from_url, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
work = tmp_path / "abschriften" / "Kanal_Rede_2026-03-28"
run_main(monkeypatch, "-u", URL, "-z", "0:00:02") run_main(monkeypatch, "-u", URL, "-z", "0:00:02")
name = "Kanal_Rede_2026-03-28_ab_00-00-02" name = "Kanal_Rede_2026-03-28_ab_00-00-02"
raw = (tmp_path / f"{name}.txt").read_text(encoding="utf-8") raw = (work / f"{name}.txt").read_text(encoding="utf-8")
assert raw == "[00:00:02 - 00:00:03]\nDas ist bekannt.\n" assert raw == "[00:00:02 - 00:00:03]\nDas ist bekannt.\n"
result = (tmp_path / f"{name}.korrigiert.txt").read_text(encoding="utf-8") result = (work / f"{name}.korrigiert.txt").read_text(encoding="utf-8")
assert result == "[00:00:02 - 00:00:03]\n\nDas ist bekannt.\n" assert result == "[00:00:02 - 00:00:03]\n\nDas ist bekannt.\n"
def test_main_url_range_from_beginning(from_url, tmp_path, monkeypatch): def test_main_url_range_from_beginning(from_url, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
work = tmp_path / "abschriften" / "Kanal_Rede_2026-03-28"
run_main(monkeypatch, "-u", URL, "-z", "-0:00:02") run_main(monkeypatch, "-u", URL, "-z", "-0:00:02")
raw = (tmp_path / "Kanal_Rede_2026-03-28_bis_00-00-02.txt").read_text(encoding="utf-8") raw = (work / "Kanal_Rede_2026-03-28_bis_00-00-02.txt").read_text(encoding="utf-8")
assert raw == "[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n" assert raw == "[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n"
@ -926,14 +980,15 @@ def test_main_url_several_ranges(fake_yt_dlp, yt_dlp_installed, tmp_path, monkey
monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=MULTI_VTT)) monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=MULTI_VTT))
sent = chunk_recorder(monkeypatch) sent = chunk_recorder(monkeypatch)
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
work = tmp_path / "abschriften" / "Kanal_Rede_2026-03-28"
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", kreml_glossary(tmp_path))
name = "Kanal_Rede_2026-03-28_bis_00-00-30+ab_00-02-00" name = "Kanal_Rede_2026-03-28_bis_00-00-30+ab_00-02-00"
assert (tmp_path / f"{name}.txt").read_text(encoding="utf-8") == ( assert (work / f"{name}.txt").read_text(encoding="utf-8") == (
"[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n\n" "[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n\n"
"[00:02:00 - 00:02:01]\nDas ist bekannt.\n" "[00:02:00 - 00:02:01]\nDas ist bekannt.\n"
) )
assert (tmp_path / f"{name}.korrigiert.txt").read_text(encoding="utf-8") == ( assert (work / f"{name}.korrigiert.txt").read_text(encoding="utf-8") == (
"[00:00:01 - 00:00:02]\n\nDer Kreml ist in Moskau.\n\n" "[00:00:01 - 00:00:02]\n\nDer Kreml ist in Moskau.\n\n"
"[00:02:00 - 00:02:01]\n\nDas ist bekannt.\n" "[00:02:00 - 00:02:01]\n\nDas ist bekannt.\n"
) )

View file

@ -321,3 +321,19 @@ def test_report_and_summary_put_word_forms_last(tmp_path, capsys):
out = capsys.readouterr().out.split("Zu prüfen: ")[1] out = capsys.readouterr().out.split("Zu prüfen: ")[1]
assert out.startswith("2 Stellen (davon 1 Wortformen)") assert out.startswith("2 Stellen (davon 1 Wortformen)")
assert out.index("Professorin") < out.index("„das“ → „dass“") assert out.index("Professorin") < out.index("„das“ → „dass“")
def test_cli_uses_video_glossary(tmp_path, monkeypatch, capsys):
folder = tmp_path / "rede"
folder.mkdir()
raw = folder / "rede.txt"
raw.write_text("das Kremmel und\nBar heute\n", encoding="utf-8")
corrected = folder / "rede.korrigiert.txt"
corrected.write_text("Das Kreml und Bahr heute.\n", encoding="utf-8")
(folder / "rede.glossar.txt").write_text("Kremmel => Kreml\nEgon Bahr\n", encoding="utf-8")
monkeypatch.setattr(sys, "argv", ["aenderungen.py", "-a", str(raw), "-b", str(corrected)])
ae.main()
out = capsys.readouterr().out
assert "Video-Glossar (automatisch)" in out
assert "Zu prüfen: 0 Stellen" in out

View file

@ -1,4 +1,5 @@
"""Tests for extract_text.py; yt-dlp is always replaced by fakes (no network).""" """Tests for extract_text.py; yt-dlp is always replaced by fakes (no network)."""
import json
import sys import sys
import pytest import pytest
@ -38,14 +39,19 @@ def test_default_language_is_original_audio(fake_yt_dlp, tmp_path, monkeypatch,
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL) run_main(monkeypatch, "-u", URL)
text = (tmp_path / "Kanal_Eine_Rede_2026-03-28.txt").read_text(encoding="utf-8") work = tmp_path / "abschriften" / "Kanal_Eine_Rede_2026-03-28"
text = (work / "Kanal_Eine_Rede_2026-03-28.txt").read_text(encoding="utf-8")
assert text == "Guten Abend.\nSchön, dass Sie da sind.\n" assert text == "Guten Abend.\nSchön, dass Sie da sind.\n"
assert "de-orig" in run.calls[1] assert "de-orig" in run.calls[1]
assert "--write-auto-subs" in run.calls[1] assert "--write-auto-subs" in run.calls[1]
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Kanal_Eine_Rede_2026-03-28.txt (2 Zeilen, 7 Wörter)" in out assert "Kanal_Eine_Rede_2026-03-28.txt (2 Zeilen, 7 Wörter)" in out
# Only the text file remains, no VTT # Only the text file and where it comes from remain, no VTT
assert [p.name for p in tmp_path.iterdir()] == ["Kanal_Eine_Rede_2026-03-28.txt"] assert sorted(p.name for p in work.iterdir()) == [
"Kanal_Eine_Rede_2026-03-28.quelle.json", "Kanal_Eine_Rede_2026-03-28.txt",
]
source = json.loads((work / "Kanal_Eine_Rede_2026-03-28.quelle.json").read_text())
assert source["kanal"] == "Kanal" and source["url"].endswith("v=abc123")
def test_default_language_ignores_dubbed_tracks(fake_yt_dlp, tmp_path, monkeypatch): def test_default_language_ignores_dubbed_tracks(fake_yt_dlp, tmp_path, monkeypatch):
@ -133,7 +139,8 @@ def test_time_range_default_name(fake_yt_dlp, tmp_path, monkeypatch):
run_main(monkeypatch, "-u", URL, "-z", "0:00:02-0:00:03") run_main(monkeypatch, "-u", URL, "-z", "0:00:02-0:00:03")
name = "Kanal_Eine_Rede_2026-03-28_00-00-02_bis_00-00-03.txt" name = "Kanal_Eine_Rede_2026-03-28_00-00-02_bis_00-00-03.txt"
assert (tmp_path / name).read_text(encoding="utf-8") == ( work = tmp_path / "abschriften" / "Kanal_Eine_Rede_2026-03-28"
assert (work / name).read_text(encoding="utf-8") == (
"[00:00:02 - 00:00:03]\nSchön, dass Sie da sind.\n" "[00:00:02 - 00:00:03]\nSchön, dass Sie da sind.\n"
) )
@ -183,7 +190,8 @@ def test_several_ranges_flow_text_keeps_headers(fake_yt_dlp, tmp_path, monkeypat
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-f") run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-f")
name = "Kanal_Eine_Rede_2026-03-28_bis_00-00-30+ab_00-02-00.txt" name = "Kanal_Eine_Rede_2026-03-28_bis_00-00-30+ab_00-02-00.txt"
assert (tmp_path / name).read_text(encoding="utf-8") == ( work = tmp_path / "abschriften" / "Kanal_Eine_Rede_2026-03-28"
assert (work / name).read_text(encoding="utf-8") == (
"[00:00:01 - 00:00:02]\nErster Satz der\nRede.\n\n" "[00:00:01 - 00:00:02]\nErster Satz der\nRede.\n\n"
"[00:02:00 - 00:02:01]\nDritter Satz, der\netwas länger ist.\n" "[00:02:00 - 00:02:01]\nDritter Satz, der\netwas länger ist.\n"
) )

View file

@ -160,3 +160,18 @@ def test_time_headers_never_reach_model_or_changes(tmp_path, monkeypatch):
draft = out.read_text(encoding="utf-8") draft = out.read_text(encoding="utf-8")
assert "# ? Kremmel => Kreml (1×)" in draft assert "# ? Kremmel => Kreml (1×)" in draft
assert "00:0" not in draft.replace(str(raw), "") assert "00:0" not in draft.replace(str(raw), "")
def test_main_uses_video_glossary(tmp_path, monkeypatch, capsys):
folder = tmp_path / "rede"
folder.mkdir()
raw = folder / "rede.txt"
raw.write_text(RAW + "\n", encoding="utf-8")
(folder / "rede.glossar.txt").write_text("Kremmel => Kreml\n", encoding="utf-8")
model = fake_model(ENTRIES)
monkeypatch.setattr(urllib.request, "urlopen", model)
run_main(monkeypatch, "-i", raw)
sent = model.calls[0]["messages"][1]["content"]
assert "Kreml" in sent and "Kremmel" not in sent
assert "Video-Glossar (automatisch)" in capsys.readouterr().out

View file

@ -124,8 +124,8 @@ def speech(name):
@pytest.mark.parametrize("vtt_path", [ @pytest.mark.parametrize("vtt_path", [
SAMPLE_VTT, SAMPLE_VTT,
speech("ramstein.de.vtt"), speech("magdeburg.de-orig.vtt"), speech("ramstein/ramstein.de.vtt"), speech("magdeburg/magdeburg.de-orig.vtt"),
speech("Lahnstein.de-orig.vtt"), speech("lahnstein/Lahnstein.de-orig.vtt"),
]) ])
def test_sample_vtt_gives_sample_txt(vtt_path): def test_sample_vtt_gives_sample_txt(vtt_path):
vtt = vtt_path.read_text(encoding="utf-8-sig") vtt = vtt_path.read_text(encoding="utf-8-sig")

View file

@ -140,8 +140,11 @@ def test_yt_dlp_runs_in_temporary_directory(fake_yt_dlp, tmp_path, monkeypatch):
assert all(cwd is not None and Path(cwd) != tmp_path for cwd in run.cwds) assert all(cwd is not None and Path(cwd) != tmp_path for cwd in run.cwds)
template = run.calls[1][run.calls[1].index("-o") + 1] template = run.calls[1][run.calls[1].index("-o") + 1]
assert template == f"{tmp_path / 'Rede'}.%(ext)s" # Without -d: working folder abschriften/<Name>/, named after -n
assert (tmp_path / "Rede.de-orig.vtt").is_file() work = tmp_path / "abschriften" / "Rede"
assert template == f"{work / 'Rede'}.%(ext)s"
assert (work / "Rede.de-orig.vtt").is_file()
assert (work / "Rede.quelle.json").is_file()
def test_main_prefers_manual_and_default_name(fake_yt_dlp, tmp_path, monkeypatch): def test_main_prefers_manual_and_default_name(fake_yt_dlp, tmp_path, monkeypatch):

View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Lädt die Untertitel eines YouTube-Videos als VTT-Datei herunter (über yt-dlp), """Lädt die Untertitel eines YouTube-Videos als VTT-Datei herunter (über yt-dlp),
als Eingabe für vtt2txt.py bzw. abschrift_korrigieren.py.""" als Eingabe für vtt2txt.py bzw. abschrift_korrigieren.py."""
from datetime import date
from pathlib import Path from pathlib import Path
import argparse import argparse
import json import json
@ -14,6 +15,7 @@ YT_DLP = "yt-dlp"
NAME_WORD = re.compile(r"\w+(?:[-.]\w+)*") # keeps "Krone-Schmalz", "28.03.2026" NAME_WORD = re.compile(r"\w+(?:[-.]\w+)*") # keeps "Krone-Schmalz", "28.03.2026"
MAX_CHANNEL_CHARS = 40 MAX_CHANNEL_CHARS = 40
MAX_TITLE_CHARS = 60 MAX_TITLE_CHARS = 60
WORK_DIR = Path("abschriften") # ein Arbeitsordner je Video: abschriften/<Name>/
def run_yt_dlp(arguments): def run_yt_dlp(arguments):
@ -112,6 +114,31 @@ def default_name(info):
return "_".join(p for p in parts if p) or info.get("id") or "video" return "_".join(p for p in parts if p) or info.get("id") or "video"
def work_folder(name):
"""Working folder of one video: abschriften/<Name>/"""
return WORK_DIR / name
def write_source(info, path):
"""Store where a transcript comes from (<Name>.quelle.json), for the
header of the finished result."""
url = info.get("webpage_url") or (
f"https://www.youtube.com/watch?v={info['id']}" if info.get("id") else None
)
source = {
"titel": info.get("title"),
"kanal": info.get("channel") or info.get("uploader"),
"datum": info.get("upload_date"),
"url": url,
"id": info.get("id"),
"dauer": info.get("duration"),
"abgerufen": date.today().isoformat(),
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(source, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def available_tracks(info): def available_tracks(info):
"""Manual tracks and the automatic ones usable for this program.""" """Manual tracks and the automatic ones usable for this program."""
manual = sorted((info.get("subtitles") or {}).keys()) manual = sorted((info.get("subtitles") or {}).keys())
@ -164,8 +191,8 @@ def parse_args():
"-l", "--lang", default="de", help="Sprache der Untertitel (Standard: de)" "-l", "--lang", default="de", help="Sprache der Untertitel (Standard: de)"
) )
parser.add_argument( parser.add_argument(
"-d", "--dir", type=Path, default=Path("."), "-d", "--dir", type=Path,
help="Zielverzeichnis (Standard: aktuelles Verzeichnis)", help="Zielverzeichnis (Standard: Arbeitsordner abschriften/<Name>/)",
) )
args = parser.parse_args() args = parser.parse_args()
@ -189,9 +216,12 @@ def main():
kind = "automatische" if is_auto else "manuelle" kind = "automatische" if is_auto else "manuelle"
print(f"Lade {kind} Untertitel „{track}“ …", flush=True) print(f"Lade {kind} Untertitel „{track}“ …", flush=True)
args.dir.mkdir(parents=True, exist_ok=True) name = args.name or default_name(info)
target_base = args.dir / (args.name or default_name(info)) folder = args.dir or work_folder(name)
folder.mkdir(parents=True, exist_ok=True)
target_base = folder / name
download(args.url, track, is_auto, target_base) download(args.url, track, is_auto, target_base)
write_source(info, folder / f"{name}.quelle.json")
target = Path(f"{target_base}.{track}.vtt") target = Path(f"{target_base}.{track}.vtt")
if not target.is_file(): if not target.is_file():