Compare commits
10 commits
7838863dbf
...
09c6d910a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 09c6d910a7 | |||
| 4805fe75ed | |||
| f350e49bd5 | |||
| 2a057d04e1 | |||
| ba98ac4eb4 | |||
| 03877588e1 | |||
| 054f867a8a | |||
| e2fb113213 | |||
| 83fd13fb98 | |||
| 81b2e90326 |
17 changed files with 1920 additions and 66 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -3,3 +3,13 @@ surreal_data/
|
||||||
notebook_data/
|
notebook_data/
|
||||||
services/logs/
|
services/logs/
|
||||||
services/voices/
|
services/voices/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Artefakt versehentlicher Shell-Redirects
|
||||||
|
/3
|
||||||
|
|
||||||
|
# Backups vor Image-Updates (scripts/update_stack.sh)
|
||||||
|
backups/
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ siehe [`CLAUDE.md`](CLAUDE.md).
|
||||||
5. [Stimmklonung](#5-stimmklonung)
|
5. [Stimmklonung](#5-stimmklonung)
|
||||||
6. [Modelle und Provider verwalten](#6-modelle-und-provider-verwalten)
|
6. [Modelle und Provider verwalten](#6-modelle-und-provider-verwalten)
|
||||||
7. [Dienste starten/stoppen](#7-dienste-startenstoppen)
|
7. [Dienste starten/stoppen](#7-dienste-startenstoppen)
|
||||||
8. [Troubleshooting](#8-troubleshooting)
|
8. [Updates einspielen](#8-updates-einspielen)
|
||||||
|
9. [Suche über Chunks (Search/Ask)](#9-suche-über-chunks-searchask)
|
||||||
|
10. [Die API in eigenen Programmen nutzen](#10-die-api-in-eigenen-programmen-nutzen)
|
||||||
|
11. [Troubleshooting](#11-troubleshooting)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -116,6 +119,36 @@ mehreren Sprechern) erzeugen — Text via Sprachmodell, Audio via TTS (hier: lok
|
||||||
Alle Episode-/Sprecherprofile sind bereits auf lokale Modelle (`qwen3.5:27b` für Text,
|
Alle Episode-/Sprecherprofile sind bereits auf lokale Modelle (`qwen3.5:27b` für Text,
|
||||||
Chatterbox für Sprache) umgestellt — keine zusätzlichen Kosten.
|
Chatterbox für Sprache) umgestellt — keine zusätzlichen Kosten.
|
||||||
|
|
||||||
|
### Episoden löschen räumt nur halb auf
|
||||||
|
|
||||||
|
**Der Mülleimer-Button löscht nicht alles.** Er entfernt die finale MP3 und den
|
||||||
|
Datenbankeintrag — der Ordner der Episode bleibt aber liegen, samt Einzelclips:
|
||||||
|
|
||||||
|
```
|
||||||
|
notebook_data/podcasts/episodes/<uuid>/
|
||||||
|
├── clips/ ← 1 MP3 pro Dialogsegment BLEIBT LIEGEN
|
||||||
|
├── outline.json BLEIBT LIEGEN
|
||||||
|
├── transcript.json BLEIBT LIEGEN
|
||||||
|
└── audio/<uuid>.mp3 ← nur diese wird gelöscht
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist eine Lücke im Open-Notebook-Code selbst (der Löschpfad ruft `unlink()` nur auf die eine
|
||||||
|
MP3-Datei auf), nicht an dieser Installation einstellbar. Ebenso hinterlässt jeder
|
||||||
|
fehlgeschlagene oder per Retry wiederholte Lauf einen Ordner, der nie aufgeräumt wird.
|
||||||
|
|
||||||
|
Zum Aufräumen dient:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/prune_podcast_data.py # zeigt nur an, was wegfiele (Trockenlauf)
|
||||||
|
./scripts/prune_podcast_data.py --yes # löscht wirklich
|
||||||
|
```
|
||||||
|
|
||||||
|
Es entfernt (a) Ordner ohne zugehörige Episode und (b) die `clips/` fertiger Episoden — die
|
||||||
|
Einzelclips sind nach dem Zusammenbau reines Zwischenprodukt, die Episode bleibt vollständig
|
||||||
|
abspielbar. Schutzmechanismen: bei einem laufenden Podcast-Job bricht es ab, und Ordner, die
|
||||||
|
jünger als 60 Minuten sind, bleiben unangetastet (`--min-age`). Mit `--keep-clips` werden nur
|
||||||
|
die verwaisten Ordner gelöscht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Stimmklonung
|
## 5. Stimmklonung
|
||||||
|
|
@ -134,8 +167,7 @@ geklonten echten Personen, siehe [`CLAUDE.md`](CLAUDE.md)).
|
||||||
2. Datei nach `services/voices/<name>.wav` legen (z. B. `services/voices/elena.wav`).
|
2. Datei nach `services/voices/<name>.wav` legen (z. B. `services/voices/elena.wav`).
|
||||||
3. TTS-Server neu starten (Stimmen werden nur beim Start eingelesen):
|
3. TTS-Server neu starten (Stimmen werden nur beim Start eingelesen):
|
||||||
```bash
|
```bash
|
||||||
pkill -f "python tts_server.py"
|
systemctl --user restart open-notebook-tts
|
||||||
./scripts/start_services.sh
|
|
||||||
```
|
```
|
||||||
4. Prüfen: `curl http://127.0.0.1:8901/health` sollte `<name>` in der `voices`-Liste zeigen.
|
4. Prüfen: `curl http://127.0.0.1:8901/health` sollte `<name>` in der `voices`-Liste zeigen.
|
||||||
5. In einem Sprecherprofil (Podcast) den betreffenden Sprecher auf `voice_id: "<name>"` setzen,
|
5. In einem Sprecherprofil (Podcast) den betreffenden Sprecher auf `voice_id: "<name>"` setzen,
|
||||||
|
|
@ -205,12 +237,17 @@ Weitere OpenRouter-Modelle lassen sich jederzeit über `POST /api/models` regist
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
# TTS/STT (lokale Hintergrundprozesse, nicht in Docker)
|
# TTS/STT (auf dem Host, nicht in Docker — systemd-User-Dienste)
|
||||||
./scripts/start_services.sh
|
systemctl --user status open-notebook-tts open-notebook-stt
|
||||||
pkill -f "python tts_server.py"
|
systemctl --user restart open-notebook-tts open-notebook-stt
|
||||||
pkill -f "python stt_server.py"
|
systemctl --user stop open-notebook-tts open-notebook-stt
|
||||||
|
journalctl --user -u open-notebook-tts -f # Logs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
TTS/STT starten nach einem Reboot **automatisch** mit. Nur bei der Ersteinrichtung (oder nach
|
||||||
|
Änderungen an `services/systemd/*.service`) einmal `./scripts/start_services.sh` ausführen — das
|
||||||
|
installiert die Unit-Dateien und aktiviert den Autostart.
|
||||||
|
|
||||||
Ollama selbst läuft als systemd-Dienst und muss normalerweise nicht manuell verwaltet werden:
|
Ollama selbst läuft als systemd-Dienst und muss normalerweise nicht manuell verwaltet werden:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -220,7 +257,159 @@ ollama ps # aktuell geladene Modelle + GPU/CPU-Verteilung
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Troubleshooting
|
## 8. Updates einspielen
|
||||||
|
|
||||||
|
Open Notebook läuft hier auf einer **festgenagelten Version** (Digest-Pin in
|
||||||
|
`docker-compose.yml`). Weder ein Start noch ein Reboot tauscht die Anwendung unbemerkt aus — Sie
|
||||||
|
bekommen immer genau den Stand, der zuletzt geprüft wurde.
|
||||||
|
|
||||||
|
**Beim Sitzungsstart** (ändert nichts, dauert Sekunden):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check_updates.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ausgabe entweder „Du laeufst auf dem aktuellen Release. Nichts zu tun." oder ein Hinweis auf ein
|
||||||
|
neues Image.
|
||||||
|
|
||||||
|
**Update einspielen:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update_stack.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript sichert zuerst Datenbank und Notebook-Daten nach `backups/<Zeitstempel>/`, stellt dann
|
||||||
|
auf das neue Image um, startet den Stack und fährt einen **Rauchtest** (API, Sprachausgabe,
|
||||||
|
Spracherkennung, YouTube-Import, alle lokalen Anpassungen). Schlägt der fehl, wird **automatisch
|
||||||
|
zurückgerollt** — Daten und Konfiguration landen wieder auf dem alten Stand.
|
||||||
|
|
||||||
|
Läuft alles, committen Sie den neuen Stand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker-compose.yml && git commit -m "Update auf <Version>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warum nicht einfach immer die neueste Version?** Ein Update verändert das Datenbankschema, und
|
||||||
|
eine ältere Version kann eine bereits migrierte Datenbank in der Regel nicht mehr lesen — ohne
|
||||||
|
Sicherung wäre ein misslungenes Update also eine Einbahnstraße. Außerdem hängen die Anpassungen
|
||||||
|
dieser Installation (Podcast-Vorlagen, YouTube-Sprachen, TTS-Einstellungen) an Interna der
|
||||||
|
Anwendung und können durch ein Update **still** kaputtgehen, ohne dass etwas abstürzt. Genau
|
||||||
|
deshalb der Rauchtest.
|
||||||
|
|
||||||
|
Ihn können Sie auch jederzeit einzeln laufen lassen, etwa wenn sich etwas seltsam verhält:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/smoke_test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Suche über Chunks (Search/Ask)
|
||||||
|
|
||||||
|
Jedes Dokument wird beim Hochladen automatisch in feste, überlappende Textabschnitte
|
||||||
|
("Chunks", ~400 Tokens) zerlegt und eingebettet — **nicht** kapitelweise, sondern rein
|
||||||
|
größenbasiert (siehe Abschnitt 2). Um gezielt in diesen Chunks zu suchen, gibt es zwei
|
||||||
|
API-Endpunkte (in der UI vermutlich unter einem eigenen Menüpunkt wie „Search"/„Ask" mit
|
||||||
|
Lupen-Symbol, getrennt von „Mit Notebook chatten" — die genaue Beschriftung war ohne
|
||||||
|
Browser-Zugriff nicht zu verifizieren):
|
||||||
|
|
||||||
|
### Einfache Suche — schnell, liefert Roh-Textstellen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:5055/api/search \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "Suchbegriff", "type": "vector", "limit": 5}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Liefert die ähnlichsten Chunks per Embedding-Vergleich samt Ähnlichkeits-Score (0–1), ohne
|
||||||
|
KI-Interpretation — am schnellsten, aber du musst die Antwort selbst zusammensetzen.
|
||||||
|
|
||||||
|
### „Ask" — durchsuchen und direkt beantworten lassen
|
||||||
|
|
||||||
|
Die smartere Variante: Die KI zerlegt die Frage selbst in mehrere gezielte Suchanfragen,
|
||||||
|
durchsucht die Chunks für jede getrennt und fasst alles zu einer einzigen, mit
|
||||||
|
Quellenverweisen (`[source:...]`) belegten Antwort zusammen. Getestet mit „Wie kam Wallenstein
|
||||||
|
ums Leben?" — Ergebnis war eine vollständige, korrekt zitierte Antwort in ca. 3 Minuten
|
||||||
|
(mehrere LLM-Aufrufe hintereinander: Suchstrategie → Einzelantworten → Zusammenfassung).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MODEL_ID=$(curl -s http://127.0.0.1:5055/api/models/defaults | python3 -c "import json,sys; print(json.load(sys.stdin)['default_chat_model'])")
|
||||||
|
curl -N -X POST http://127.0.0.1:5055/api/search/ask \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"question\": \"Deine Frage\", \"strategy_model\": \"$MODEL_ID\", \"answer_model\": \"$MODEL_ID\", \"final_answer_model\": \"$MODEL_ID\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort kommt als Server-Sent-Events-Stream (`data: {...}`-Zeilen, kein einzelnes JSON-Objekt);
|
||||||
|
das letzte `"type": "complete"`-Event enthält die finale Antwort im Feld `final_answer`.
|
||||||
|
|
||||||
|
**Wann was nutzen:** Für gezielte Detailfragen über lange/viele Quellen ist „Ask" meist
|
||||||
|
treffsicherer und dabei noch schneller als der „vollständiger Inhalt"-Chat-Modus (Abschnitt 3),
|
||||||
|
weil nur die relevanten Chunks statt des ganzen Dokuments verarbeitet werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Die API in eigenen Programmen nutzen
|
||||||
|
|
||||||
|
Die REST-API ist eine ganz normale JSON-über-HTTP-API **ohne Authentifizierung** — kein API-Key
|
||||||
|
nötig, da nur `127.0.0.1` gebunden (jeder lokale Prozess auf diesem Rechner hat Zugriff, aber
|
||||||
|
niemand aus dem Netzwerk). Funktioniert mit jeder Sprache, die HTTP kann.
|
||||||
|
|
||||||
|
**Bester Startpunkt:** `http://127.0.0.1:5055/docs` — interaktive Swagger-UI mit allen
|
||||||
|
Endpunkten, Schemas und einer „Try it out"-Funktion zum direkten Ausprobieren im Browser.
|
||||||
|
Rohes OpenAPI-Schema: `http://127.0.0.1:5055/openapi.json`.
|
||||||
|
|
||||||
|
### Mit curl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Notebooks auflisten
|
||||||
|
curl -s http://127.0.0.1:5055/api/notebooks | python3 -m json.tool
|
||||||
|
|
||||||
|
# Neues Notebook anlegen
|
||||||
|
curl -s -X POST http://127.0.0.1:5055/api/notebooks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "Mein Notebook", "description": "..."}'
|
||||||
|
|
||||||
|
# Text-Quelle hinzufügen (multipart, nicht JSON)
|
||||||
|
curl -s -X POST http://127.0.0.1:5055/api/sources \
|
||||||
|
-F "type=text" -F "notebook_id=notebook:xxx" -F "title=..." \
|
||||||
|
-F "content=..." -F "embed=true"
|
||||||
|
|
||||||
|
# Chat-Session erstellen + Nachricht senden
|
||||||
|
curl -s -X POST http://127.0.0.1:5055/api/chat/sessions \
|
||||||
|
-H "Content-Type: application/json" -d '{"notebook_id": "notebook:xxx"}'
|
||||||
|
curl -s -X POST http://127.0.0.1:5055/api/chat/execute \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"session_id": "chat_session:xxx", "message": "...", "context": {}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Weitere Beispiele (Quellen-Insights, Podcast-Generierung, Modell-/Provider-Verwaltung,
|
||||||
|
Chunk-Suche) stehen bereits in den jeweiligen Abschnitten dieser Anleitung.
|
||||||
|
|
||||||
|
### Aus Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:5055/api"
|
||||||
|
|
||||||
|
notebooks = requests.get(f"{BASE}/notebooks").json()
|
||||||
|
|
||||||
|
session = requests.post(f"{BASE}/chat/sessions",
|
||||||
|
json={"notebook_id": notebooks[0]["id"]}).json()
|
||||||
|
answer = requests.post(f"{BASE}/chat/execute", json={
|
||||||
|
"session_id": session["id"],
|
||||||
|
"message": "Wovon handelt das Buch?",
|
||||||
|
"context": {},
|
||||||
|
}).json()
|
||||||
|
print(answer["messages"][-1]["content"])
|
||||||
|
```
|
||||||
|
|
||||||
|
Genauso mit JavaScript (`fetch`), Go, oder jeder anderen Sprache — nichts Open-Notebook-
|
||||||
|
Spezifisches nötig, kein SDK erforderlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Troubleshooting
|
||||||
|
|
||||||
### „Chat gibt keine Antwort" (leere Antwort, kein Fehler)
|
### „Chat gibt keine Antwort" (leere Antwort, kein Fehler)
|
||||||
|
|
||||||
|
|
@ -261,8 +450,8 @@ Such-/Ask-Funktion oder „nur Erkenntnisse" nach einer Zusammenfassung nutzen.
|
||||||
docker compose exec open_notebook curl -sf http://host.docker.internal:8901/health
|
docker compose exec open_notebook curl -sf http://host.docker.internal:8901/health
|
||||||
```
|
```
|
||||||
|
|
||||||
Bei Timeout: `ufw`-Regeln fehlen (siehe README 2.4) oder die Server laufen nicht
|
Bei Timeout: `ufw`-Regeln fehlen (siehe README 2.4) oder die Dienste laufen nicht
|
||||||
(`./scripts/start_services.sh`).
|
(`systemctl --user status open-notebook-tts open-notebook-stt`).
|
||||||
|
|
||||||
### Podcast-Profil zeigt „Einrichtung erforderlich"
|
### Podcast-Profil zeigt „Einrichtung erforderlich"
|
||||||
|
|
||||||
|
|
@ -272,6 +461,114 @@ Das Episode- oder Sprecherprofil verweist auf ein Modell ohne konfiguriertes Cre
|
||||||
`PUT /api/speaker-profiles/{id}` (`voice_model`) auf registrierte lokale/OpenRouter-Modelle
|
`PUT /api/speaker-profiles/{id}` (`voice_model`) auf registrierte lokale/OpenRouter-Modelle
|
||||||
umstellen — siehe `scripts/setup_models.sh` für die IDs der Standardmodelle.
|
umstellen — siehe `scripts/setup_models.sh` für die IDs der Standardmodelle.
|
||||||
|
|
||||||
|
### Podcast wird auf Englisch statt Deutsch erzeugt (oder auf Englisch mit deutschem Akzent vorgelesen)
|
||||||
|
|
||||||
|
Das ist behoben: Die Podcast-Prompt-Vorlagen (`prompts/podcast/outline.jinja` und
|
||||||
|
`transcript.jinja` in diesem Projekt) wurden um eine explizite Sprachanweisung ergänzt, die das
|
||||||
|
`language`-Feld des Episode-Profils auswertet — sie werden per Bind-Mount aus `docker-compose.yml`
|
||||||
|
in den Container eingehängt. Setzt du am Episode-Profil `language: "de"`, kommt der Podcast auf
|
||||||
|
Deutsch. (Vorher hatte das `language`-Feld keine Wirkung, weil die mitgelieferten Vorlagen es
|
||||||
|
ignorierten — daher englischer Text, der von der deutsch-fixierten TTS mit Akzent vorgelesen wurde.)
|
||||||
|
|
||||||
|
Falls doch wieder Englisch erscheint, prüfe das `language`-Feld:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:5055/api/episode-profiles | python3 -c \
|
||||||
|
"import json,sys; [print(p['name'], p['language']) for p in json.load(sys.stdin)]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Fehlt `de`/`de-DE`, per `PUT /api/episode-profiles/{id}` (vollständiger Body) ergänzen. Zusätzlich
|
||||||
|
sind Briefing und Sprecherbeschreibungen der mitgelieferten Profile auf Deutsch übersetzt (hilft
|
||||||
|
gegen Sprach-Drift, wenn der Quellinhalt englisch ist). **Wichtig:** Änderst du die
|
||||||
|
Vorlagen-Dateien selbst, ist danach ein Container-Neustart nötig (`docker compose up -d
|
||||||
|
--force-recreate open_notebook`), damit der Bind-Mount die neue Version übernimmt.
|
||||||
|
|
||||||
|
### Podcast-Erzeugung schlägt mit „Invalid json output" fehl
|
||||||
|
|
||||||
|
Das Textmodell hat ungültiges JSON geliefert — passierte bei `qwen3.5:27b` (einem „denkenden"
|
||||||
|
Modell), wenn der gesamte Notebook-Inhalt (bei einem ganzen Buch ~70.000 Tokens) in jeden
|
||||||
|
Transkript-Abschnitt gepackt wird: Der Denk-Block frisst dann das Antwort-Budget auf und das JSON
|
||||||
|
wird abgeschnitten. Behoben durch `/no_think` als erste Zeile beider Podcast-Vorlagen (schaltet
|
||||||
|
den Denkmodus ab, ~3× schneller, volles Budget fürs JSON). Falls es dennoch auftritt: kürzeren
|
||||||
|
Quellinhalt verwenden (nicht das ganze Buch) oder die Generierung erneut starten.
|
||||||
|
|
||||||
|
### Video ohne Untertitel hinzufügen (`scripts/add_video_source.py`)
|
||||||
|
|
||||||
|
Open Notebook liest bei Videos **nur vorhandene Untertitel**. Gibt es keine, bleibt die Quelle
|
||||||
|
leer. Für diesen Fall gibt es das Skript:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/add_video_source.py https://www.youtube.com/watch?v=...
|
||||||
|
./scripts/add_video_source.py <url> --notebook notebook:abc --language de
|
||||||
|
./scripts/add_video_source.py <url> --force-audio # Untertitel ignorieren
|
||||||
|
```
|
||||||
|
|
||||||
|
Es wählt den Weg selbst:
|
||||||
|
|
||||||
|
- **Untertitel vorhanden** → die URL wird normal als Link-Quelle übergeben (schnell, kein
|
||||||
|
Download).
|
||||||
|
- **keine Untertitel** → `yt-dlp` lädt nur die Tonspur, die wird hochgeladen und von Open
|
||||||
|
Notebook mit dem lokalen Whisper-Server transkribiert und eingebettet. Rechnen Sie mit etwa
|
||||||
|
1–2 Minuten pro 20 Minuten Video (läuft auf GPU 2). Die hochgeladene Audiodatei wird danach
|
||||||
|
automatisch wieder gelöscht.
|
||||||
|
|
||||||
|
Das funktioniert für alles, was `yt-dlp` kennt (YouTube, Vimeo, Mediatheken …), nicht nur für
|
||||||
|
YouTube. `--force-audio` ist auch dann nützlich, wenn die automatischen YouTube-Untertitel zu
|
||||||
|
schlecht sind: Whisper liefert meist deutlich saubereren Text (richtige Satzzeichen,
|
||||||
|
korrekte Eigennamen).
|
||||||
|
|
||||||
|
Hinweis: Beim Audio-Weg wird die Audiodatei als Quell-Asset hinterlegt, die ursprüngliche
|
||||||
|
Video-URL steht dann nicht mehr am Eintrag (beim Untertitel-Weg schon).
|
||||||
|
|
||||||
|
### YouTube-Video wird als leere Quelle angelegt
|
||||||
|
|
||||||
|
Behoben. Symptom war: Quelle hinzufügen scheint zu klappen (der **Titel** erscheint), aber es gibt
|
||||||
|
keinen Inhalt — kein Text, nichts zum Chatten, nichts zum Zusammenfassen.
|
||||||
|
|
||||||
|
Ursache: Open Notebook holt YouTube-Untertitel über die Bibliothek `content-core`, und die suchte
|
||||||
|
nur nach Transkripten in **Englisch, Spanisch und Portugiesisch**. Ein Video mit ausschließlich
|
||||||
|
deutschem Transkript fiel damit durch; der Fehler stand nur im Container-Log, in der Oberfläche
|
||||||
|
sah die Quelle einfach leer aus. Behoben durch `config/content_core.yaml` (per Bind-Mount
|
||||||
|
eingehängt), die Deutsch an erste Stelle setzt.
|
||||||
|
|
||||||
|
Falls doch mal ein Video leer bleibt, ist meist **gar kein Untertitel** vorhanden (weder manuell
|
||||||
|
noch automatisch) — das lässt sich so prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec open_notebook /app/.venv/bin/python -c "
|
||||||
|
from youtube_transcript_api import YouTubeTranscriptApi
|
||||||
|
for t in YouTubeTranscriptApi().list('VIDEO_ID'): print(t.language_code, t.is_generated)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Kommt hier nichts zurück, hat das Video keine Untertitel — dann hilft nur, das Video separat
|
||||||
|
herunterzuladen und über die STT-Funktion (Audio-Upload) zu transkribieren.
|
||||||
|
|
||||||
|
### Podcast schlägt beim Vertonen fehl („Failed to generate speech")
|
||||||
|
|
||||||
|
Text und Transkript sind fertig, erst die Sprachausgabe scheitert. Die genaue Fehlermeldung
|
||||||
|
(UI-Episodenliste oder `docker compose logs open_notebook`) unterscheidet **zwei verschiedene
|
||||||
|
Ursachen** — die Verwechslung kostet sonst viel Zeit:
|
||||||
|
|
||||||
|
| Meldung | Bedeutung | Abhilfe |
|
||||||
|
|---|---|---|
|
||||||
|
| `All connection attempts failed` (`ConnectError`) | Der TTS-Server **läuft nicht** — niemand nimmt den Request an. | `systemctl --user status open-notebook-tts`, ggf. `restart`. |
|
||||||
|
| `OpenAI-compatible TTS endpoint error: HTTP 500` | Der TTS-Server **lebt**, bricht aber bei der Arbeit ab — meist `CUDA out of memory`. | `journalctl --user -u open-notebook-tts -n 50` prüfen. |
|
||||||
|
|
||||||
|
Der GPU-Speicherfall entstand so: GPU 2 teilen sich drei Prozesse (TTS, STT und der separate
|
||||||
|
`chatterbox-tts`-MCP-Dienst auf Port 9999), es bleiben nur ~12 GB übrig. Der Podcast-Generator
|
||||||
|
schickte aber 5 Clips gleichzeitig, die im TTS-Server echt parallel auf derselben GPU liefen —
|
||||||
|
der Speicherbedarf vervielfachte sich (bis 16,7 GB) und sprengte die Karte. Behoben durch zwei
|
||||||
|
Maßnahmen: `tts_server.py` serialisiert seine Generierung intern (ein Clip zur Zeit — auf einer
|
||||||
|
einzelnen GPU kostet das keinen Durchsatz), und `TTS_BATCH_SIZE=1` in `docker-compose.yml` sorgt
|
||||||
|
dafür, dass gar nicht erst mehrere Anfragen gleichzeitig eintreffen. Ein fehlgeschlagener Podcast
|
||||||
|
lässt sich anschließend ohne Neuanlage wiederholen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:5055/api/podcasts/episodes/{episode_id}/retry
|
||||||
|
```
|
||||||
|
|
||||||
|
(Der Retry legt einen neuen Episoden-Eintrag an; der alte, fehlgeschlagene verschwindet.)
|
||||||
|
|
||||||
### Weitere technische Details
|
### Weitere technische Details
|
||||||
|
|
||||||
Siehe [`CLAUDE.md`](CLAUDE.md) — dort stehen alle bisher aufgetretenen Probleme mit exakter
|
Siehe [`CLAUDE.md`](CLAUDE.md) — dort stehen alle bisher aufgetretenen Probleme mit exakter
|
||||||
|
|
|
||||||
232
CLAUDE.md
232
CLAUDE.md
|
|
@ -4,25 +4,80 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## What this repo is
|
## What this repo is
|
||||||
|
|
||||||
This is a local Docker Compose deployment of [Open Notebook](https://github.com/lfnovo/open-notebook) (upstream image `lfnovo/open_notebook`), not a clone of its source. There is no application source code here — only deployment config (`docker-compose.yml`, `.env`, `.gitignore`). Application bugs/features belong upstream; this repo only concerns itself with how the stack is run locally.
|
This is a local Docker Compose deployment of [Open Notebook](https://github.com/lfnovo/open-notebook) (upstream image `lfnovo/open_notebook`), not a clone of its source. The application's own code is **not** here and its bugs/features belong upstream — this repo only concerns itself with how the stack is run locally.
|
||||||
|
|
||||||
|
What *is* here, beyond `docker-compose.yml`/`.env`:
|
||||||
|
|
||||||
|
- `services/` — our own source: the TTS/STT wrapper servers (Open Notebook has no local TTS/STT provider) plus their systemd units.
|
||||||
|
- `scripts/` — setup, maintenance and update tooling (see Commands below).
|
||||||
|
- `prompts/podcast/`, `config/content_core.yaml` — **overlays** bind-mounted over files inside the image, to fix upstream behaviour we can't configure otherwise (podcast language/reliability, YouTube caption languages). These are the fragile part: they can silently rot when the image is updated, which is what `scripts/smoke_test.sh` exists to catch.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d # start (pulls images per pull_policy: always)
|
docker compose up -d # start (images are digest-pinned; no implicit upgrade)
|
||||||
docker compose down # stop and remove containers (data volumes persist)
|
docker compose down # stop and remove containers (data volumes persist)
|
||||||
docker compose logs -f open_notebook # follow app logs
|
docker compose logs -f open_notebook # follow app logs
|
||||||
docker compose ps # container status
|
docker compose ps # container status
|
||||||
|
|
||||||
|
./scripts/check_updates.sh # is there a newer release? (reports only, changes nothing)
|
||||||
|
./scripts/update_stack.sh # backup -> update -> smoke test -> rollback on failure
|
||||||
|
./scripts/smoke_test.sh # verify the stack still does what we rely on
|
||||||
```
|
```
|
||||||
|
|
||||||
No build/lint/test suite exists in this repo — nothing here to run.
|
`smoke_test.sh` is the closest thing to a test suite here — run it after touching
|
||||||
|
`docker-compose.yml`, the bind-mounted overlays, or the host TTS/STT services.
|
||||||
|
|
||||||
|
## Image versioning
|
||||||
|
|
||||||
|
Images are **pinned by digest**, and updating is an explicit act — never a side effect of
|
||||||
|
starting the stack.
|
||||||
|
|
||||||
|
The previous setup (`v1-latest` + `pull_policy: always`) was unreliable in *both* directions:
|
||||||
|
`docker compose up -d` silently pulled a new application — including an irreversible SurrealDB
|
||||||
|
schema migration — while a reboot (`restart: always`) or `docker compose start` kept running the
|
||||||
|
**old** image without any pull. So the running version was essentially unpredictable.
|
||||||
|
|
||||||
|
Note `v1-latest` tracks *releases*, not `main`: the tag lags the repo by weeks. Chasing `main`
|
||||||
|
would mean building from source and running unreleased code — explicitly not wanted here.
|
||||||
|
|
||||||
|
Updating (`scripts/update_stack.sh`): resolve the new digest → stop → back up `surreal_data` +
|
||||||
|
`notebook_data` + `docker-compose.yml` into `backups/<ts>/` → rewrite the digest → start → run
|
||||||
|
`smoke_test.sh` → **roll back data and compose file if the smoke test fails**. The data backup is
|
||||||
|
the point: the app migrates the database on startup, and an older application generally cannot
|
||||||
|
read a migrated database, so a bad update would otherwise be a one-way door. Backup/restore runs
|
||||||
|
`tar` **inside a container** because the data directories are root-owned (the container writes as
|
||||||
|
root) and the host user cannot restore over them.
|
||||||
|
|
||||||
|
Both scripts read the digest from the `lfnovo/open_notebook` line specifically — a naive "first
|
||||||
|
sha256 in the file" grep picks up **surrealdb** (it is listed first), which would make the update
|
||||||
|
script rewrite the database image instead of the application.
|
||||||
|
|
||||||
|
### Why a smoke test, and not just "does it start"
|
||||||
|
|
||||||
|
Every local adaptation in this repo leans on upstream internals, and an update can break them
|
||||||
|
*silently* — the stack comes up fine and misbehaves later:
|
||||||
|
|
||||||
|
- the `prompts/podcast/` **directory** mount masks the image's directory: a third template added
|
||||||
|
upstream would simply be invisible;
|
||||||
|
- `config/content_core.yaml` is a full dump of content-core's defaults (because `CCORE_CONFIG_PATH`
|
||||||
|
replaces rather than merges), so new upstream defaults would be frozen out;
|
||||||
|
- the env-var workarounds (`TTS_BATCH_SIZE`, `OPENAI_COMPATIBLE_BASE_URL_STT`,
|
||||||
|
`ESPERANTO_TTS_TIMEOUT`) depend on how content-core / esperanto / podcast_creator behave today.
|
||||||
|
|
||||||
|
`smoke_test.sh` therefore checks those assumptions explicitly (template file list vs. the image,
|
||||||
|
`{{ language }}` and `/no_think` still present, `language` still plumbed by podcast_creator,
|
||||||
|
`TTS_BATCH_SIZE` still read, `preferred_languages` still used, esperanto still honouring
|
||||||
|
`OPENAI_COMPATIBLE_BASE_URL_STT`, our config still covering all default keys) on top of the obvious
|
||||||
|
end-to-end ones (API, TTS audio, STT, YouTube transcript). `SKIP_NET=1` skips the internet-dependent
|
||||||
|
check.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Two services, defined in `docker-compose.yml`:
|
Two services, defined in `docker-compose.yml`:
|
||||||
|
|
||||||
- **surrealdb** (`surrealdb/surrealdb:v2`) — the database, RocksDB-backed, bound to `127.0.0.1:8000` only (local debugging access, e.g. `surreal sql`). Credentials come from `SURREAL_USER`/`SURREAL_PASSWORD` (default `root`/`root` if unset in `.env`) and are shared with the `open_notebook` service so they stay in sync.
|
- **surrealdb** (`surrealdb/surrealdb:v2`) — the database, RocksDB-backed, bound to `127.0.0.1:8000` only (local debugging access, e.g. `surreal sql`). Credentials come from `SURREAL_USER`/`SURREAL_PASSWORD` (default `root`/`root` if unset in `.env`) and are shared with the `open_notebook` service so they stay in sync.
|
||||||
- **open_notebook** (`lfnovo/open_notebook:v1-latest`) — bundles the web UI (port 8502) and REST API (port 5055) in one image, both bound to `127.0.0.1` only. Connects to `surrealdb` over the internal compose network (`ws://surrealdb:8000/rpc`).
|
- **open_notebook** (`lfnovo/open_notebook:v1-latest`, digest-pinned — see *Image versioning*) — bundles the web UI (port 8502) and REST API (port 5055) in one image, both bound to `127.0.0.1` only. Connects to `surrealdb` over the internal compose network (`ws://surrealdb:8000/rpc`).
|
||||||
|
|
||||||
Data persists in `./surreal_data` and `./notebook_data` (bind mounts, gitignored).
|
Data persists in `./surreal_data` and `./notebook_data` (bind mounts, gitignored).
|
||||||
|
|
||||||
|
|
@ -41,6 +96,117 @@ A separate local `llama.cpp` server exists on this machine (managed via `~/llama
|
||||||
|
|
||||||
**Chat model context window:** the Ollama credential used for `qwen3.5:27b` (`credential:di8b31l16zikyyb6isyi`) has `num_ctx=98304` set explicitly. Without this, esperanto's `OllamaLanguageModel` silently defaults `num_ctx` to **8192** regardless of the model's real capability or `OLLAMA_CONTEXT_LENGTH` — any chat context (e.g. "full content" mode on a real document) beyond that gets truncated by Ollama, and because `qwen3.5:27b` is a thinking model, the truncated prompt frequently produces an empty final answer (the response is silently swallowed by `clean_thinking_content`) instead of a visible error — looks exactly like "chat gives no answer" from the UI. `98304` is the largest value that still keeps the whole model on GPU 1 (`ollama ps` shows `100% GPU`); `131072` spills ~91% onto CPU and makes a single response take 5-6 minutes instead of ~1-2. Even at 98304, a full-book "Volltext" chat (~70k+ tokens of context) takes ~2-3 minutes — that's inherent to processing that much context on one consumer GPU, not a bug. For large documents, the default short/RAG context mode (only relevant chunks, not the whole document) stays fast; reserve "Volltext" for shorter sources. If `qwen3.5:27b` ever gets swapped for a different Ollama chat model, re-check this credential's `num_ctx` still makes sense for its VRAM footprint.
|
**Chat model context window:** the Ollama credential used for `qwen3.5:27b` (`credential:di8b31l16zikyyb6isyi`) has `num_ctx=98304` set explicitly. Without this, esperanto's `OllamaLanguageModel` silently defaults `num_ctx` to **8192** regardless of the model's real capability or `OLLAMA_CONTEXT_LENGTH` — any chat context (e.g. "full content" mode on a real document) beyond that gets truncated by Ollama, and because `qwen3.5:27b` is a thinking model, the truncated prompt frequently produces an empty final answer (the response is silently swallowed by `clean_thinking_content`) instead of a visible error — looks exactly like "chat gives no answer" from the UI. `98304` is the largest value that still keeps the whole model on GPU 1 (`ollama ps` shows `100% GPU`); `131072` spills ~91% onto CPU and makes a single response take 5-6 minutes instead of ~1-2. Even at 98304, a full-book "Volltext" chat (~70k+ tokens of context) takes ~2-3 minutes — that's inherent to processing that much context on one consumer GPU, not a bug. For large documents, the default short/RAG context mode (only relevant chunks, not the whole document) stays fast; reserve "Volltext" for shorter sources. If `qwen3.5:27b` ever gets swapped for a different Ollama chat model, re-check this credential's `num_ctx` still makes sense for its VRAM footprint.
|
||||||
|
|
||||||
|
**Podcast language + reliability — fixed via patched prompt templates (`prompts/podcast/`,
|
||||||
|
bind-mounted).** Two separate problems, one fix location:
|
||||||
|
|
||||||
|
1. *Language.* The episode profile's `language` field (`"de"` → resolved to `"German"`) **is**
|
||||||
|
passed into both `podcast_creator` prompt templates as a `{{ language }}` variable
|
||||||
|
(`nodes.py`, both `generate_outline_node` and `generate_transcript_node`), but the stock
|
||||||
|
templates shipped in the image never reference it — so setting `language` had no effect and
|
||||||
|
podcasts came out in English (the stock briefings and speaker backstories are English, which
|
||||||
|
is the only language signal the model then sees). English text read aloud by the German-locked
|
||||||
|
Chatterbox TTS is what produced the "English with a bad German accent" symptom.
|
||||||
|
2. *Reliability.* `qwen3.5:27b` is a thinking model. Podcast content is the **entire notebook**
|
||||||
|
(`Notebook.get_context()` → every source's full text + all insights; for a whole book that's
|
||||||
|
~70k+ tokens), fed into *each* transcript-segment call. On long segments the `<think>` block
|
||||||
|
eats the 5000-token response budget and the JSON gets truncated → `Invalid json output` →
|
||||||
|
whole job fails. (Confirmed: `/no_think` cut a segment from 98s to 34s and left valid JSON.)
|
||||||
|
|
||||||
|
Fix — both handled in the two templates under `prompts/podcast/`, bind-mounted read-only over
|
||||||
|
the image's copies via `docker-compose.yml`:
|
||||||
|
- Prepended `/no_think` as the first line of both templates (Qwen honors it anywhere; harmless
|
||||||
|
text for non-Qwen models) — disables thinking, freeing the token budget and ~3x speedup.
|
||||||
|
- Added a `{% if language %}CRITICAL LANGUAGE REQUIREMENT … in {{ language }}{% endif %}` block
|
||||||
|
near the end of both templates, so the already-plumbed `language` field now actually forces the
|
||||||
|
output language.
|
||||||
|
|
||||||
|
The bundled briefings and speaker backstory/personality were **also** translated to German (belt
|
||||||
|
and suspenders; reduces English drift from the source content), but the template change is what
|
||||||
|
makes `language` authoritative. **Editing these templates requires a container restart** to take
|
||||||
|
effect — they're bind-mounted, and the mount is the directory `prompts/podcast/` (not individual
|
||||||
|
files, which go stale on inode-replacing edits). If you switch off `qwen3.5:27b`, the `/no_think`
|
||||||
|
line becomes inert but harmless.
|
||||||
|
|
||||||
|
If you create your own episode profile, just set `language` — the template handles the rest.
|
||||||
|
|
||||||
|
**Deleting an episode leaks its working directory (upstream gap).** `DELETE /api/podcasts/episodes/{id}`
|
||||||
|
(`api/routers/podcasts.py`) resolves `episode.audio_file` and calls `audio_path.unlink()` on it — the
|
||||||
|
final MP3 only. The enclosing `data/podcasts/episodes/<uuid>/` directory, holding `clips/` (one MP3 per
|
||||||
|
dialogue segment), `outline.json` and `transcript.json`, is never touched, and nothing else reaps it.
|
||||||
|
Failed and `/retry`-replaced runs leak a directory too (the retry writes a *new* episode row with a new
|
||||||
|
uuid dir; the old one loses its DB entry and just stays). Upstream this would be roughly
|
||||||
|
`shutil.rmtree(audio_path.parent.parent)` in that handler. We deliberately do **not** patch it here by
|
||||||
|
bind-mounting a modified router — that would fork app logic into a deployment repo and rot against
|
||||||
|
image updates (the same trap the existing overlays already carry). Instead:
|
||||||
|
`scripts/prune_podcast_data.py` (dry-run by default, `--yes` to
|
||||||
|
apply) reconciles the episode list from the API against the directories on disk and removes orphans plus
|
||||||
|
the `clips/` of completed episodes. Two guards matter — it aborts if any job is `running`/`pending`
|
||||||
|
(a running job has no `audio_file` yet, so its directory is indistinguishable from an orphan) and it
|
||||||
|
skips directories touched in the last 60 minutes. **Deletion runs inside the container** (`docker compose
|
||||||
|
exec … rm -rf`), because the container writes as root and the host user can't remove those directories.
|
||||||
|
|
||||||
|
### YouTube sources — German transcripts need `CCORE_CONFIG_PATH`
|
||||||
|
|
||||||
|
Open Notebook extracts YouTube via `content-core`, which pulls captions with `youtube-transcript-api`
|
||||||
|
(no `yt-dlp`/`pytube` in the image). `content_core/processors/youtube.py` only ever looks for the
|
||||||
|
languages in `preferred_languages`, and the built-in default is `["en", "es", "pt"]` — **no German**.
|
||||||
|
All four fallback attempts in `_fetch_best_transcript` use that same list, so a video with only a
|
||||||
|
German transcript raises `NoTranscriptFound`, `get_best_transcript` swallows it and returns `None`,
|
||||||
|
and the source is created with an **empty body**. The title is still fetched (separate HTTP scrape),
|
||||||
|
so the failure is quiet: a source that looks fine but has no content, with the real reason only in
|
||||||
|
the container log (`Failed to get transcript for video <id> after retries`).
|
||||||
|
|
||||||
|
The `youtube_transcripts.preferred_languages` key in the package's `cc_config.yaml` looks like the
|
||||||
|
fix, but it is **dead code** on the default path: `load_config()` copies only the `extraction` block
|
||||||
|
out of `cc_config.yaml`, so the key never reaches `CONFIG` and the hardcoded `["en","es","pt"]`
|
||||||
|
fallback always wins. The only way to set it is `CCORE_CONFIG_PATH`.
|
||||||
|
|
||||||
|
Fix: `config/content_core.yaml` (bind-mounted read-only, `CCORE_CONFIG_PATH=/app/config/content_core.yaml`
|
||||||
|
in `docker-compose.yml`) sets `preferred_languages: ["de", "en", "es", "pt"]`.
|
||||||
|
|
||||||
|
**`CCORE_CONFIG_PATH` replaces the config wholesale — it does not merge** (`config.py`:
|
||||||
|
`return yaml.safe_load(file)`). So that file is a full dump of the effective default config plus the
|
||||||
|
`youtube_transcripts` key, not a small override; a minimal file would silently drop `extraction`
|
||||||
|
(engine selection) and the model/timeout defaults. Its header comment carries the one-liner that
|
||||||
|
regenerates it from inside the container — do that after an image update if content-core's defaults
|
||||||
|
change. The `openai`/`gpt-4o-mini` entries in it are content-core's internal defaults and are unused
|
||||||
|
here (Open Notebook picks its own models); they're only present because the file must be complete.
|
||||||
|
|
||||||
|
Note this only fixes *caption* languages. A video with **no** captions is handled by the audio
|
||||||
|
fallback below.
|
||||||
|
|
||||||
|
### Video without captions — audio fallback (`scripts/add_video_source.py`)
|
||||||
|
|
||||||
|
`yt-dlp` exists on the **host** only (`~/miniforge3/bin/yt-dlp`), not in the image, so the chain
|
||||||
|
can't live inside the app. `scripts/add_video_source.py` bridges it:
|
||||||
|
|
||||||
|
- captions available → hands the URL to Open Notebook as a `link` source (fast, no download);
|
||||||
|
- no captions → `yt-dlp -x` pulls just the audio track (mono 16 kHz mp3, ~6 MB per 17 min) and
|
||||||
|
uploads it as an `upload` source. Open Notebook then transcribes it **itself** via its configured
|
||||||
|
speech-to-text model — i.e. the local faster-whisper server — and embeds it. We deliberately do
|
||||||
|
not transcribe in the script: the app's own pipeline already does STT, chunking and embedding.
|
||||||
|
|
||||||
|
Caption availability is probed with `youtube-transcript-api` **inside the container**, not with
|
||||||
|
`yt-dlp`. `yt-dlp`'s `automatic_captions` lists YouTube's ~100 auto-*translation* targets (German
|
||||||
|
is always among them), which `youtube-transcript-api` does not accept as transcripts — trusting it
|
||||||
|
would send a Japanese-only video down the caption path and produce an empty source again.
|
||||||
|
|
||||||
|
**`OPENAI_COMPATIBLE_BASE_URL_STT` is what makes the upload path work at all.** Open Notebook passes
|
||||||
|
its STT model (`openai_compatible`/`faster-whisper-large-v3`) into content-core as
|
||||||
|
`audio_provider`/`audio_model` (`open_notebook/graphs/source.py`), but content-core builds it with
|
||||||
|
`AIFactory.create_speech_to_text(provider, model, {'timeout': …})` — **no `base_url`**
|
||||||
|
(`content_core/processors/audio.py`). Esperanto then can't locate the local server, content-core
|
||||||
|
silently falls back to its default (`openai`/`whisper-1`) and the job dies with *"OpenAI API key not
|
||||||
|
found"* — which reads like a missing key but is really a missing URL. The env var supplies it. Do
|
||||||
|
**not** "fix" this by putting an `OPENAI_API_KEY` into the container: that would send audio to OpenAI
|
||||||
|
(paid, off-machine) while a working local Whisper sits idle on GPU 2, and it contradicts the
|
||||||
|
two-provider rule above.
|
||||||
|
|
||||||
|
The script uploads with `delete_source=true` and names the file after the video id, so
|
||||||
|
`notebook_data/uploads/` doesn't accumulate a few MB per video and a second video can't collide with
|
||||||
|
the previous `audio.mp3`. Known gap: the `upload` path stores the audio file as the source's asset,
|
||||||
|
so the original video URL is not recorded on the source (the `link` path does record it).
|
||||||
|
|
||||||
### Text-to-speech / speech-to-text
|
### Text-to-speech / speech-to-text
|
||||||
|
|
||||||
Open Notebook's `openai_compatible` provider type talks to any endpoint implementing OpenAI's audio API shape (`POST /audio/speech`, `POST /audio/transcriptions` — see `esperanto.providers.tts.openai_compatible` / `.stt.openai_compatible` inside the app container for the exact contract). Since neither Ollama nor OpenRouter support TTS/STT, two thin wrapper servers in `services/` expose the locally installed tools this way:
|
Open Notebook's `openai_compatible` provider type talks to any endpoint implementing OpenAI's audio API shape (`POST /audio/speech`, `POST /audio/transcriptions` — see `esperanto.providers.tts.openai_compatible` / `.stt.openai_compatible` inside the app container for the exact contract). Since neither Ollama nor OpenRouter support TTS/STT, two thin wrapper servers in `services/` expose the locally installed tools this way:
|
||||||
|
|
@ -48,15 +214,61 @@ Open Notebook's `openai_compatible` provider type talks to any endpoint implemen
|
||||||
- `services/tts_server.py` — wraps `chatterbox-tts` (via `~/chatterbox-tts-cli/chatterbox_cli_v4.py`), run with the `chatterbox` conda env (`~/miniforge3/envs/chatterbox/bin/python`).
|
- `services/tts_server.py` — wraps `chatterbox-tts` (via `~/chatterbox-tts-cli/chatterbox_cli_v4.py`), run with the `chatterbox` conda env (`~/miniforge3/envs/chatterbox/bin/python`).
|
||||||
- `services/stt_server.py` — wraps `faster-whisper` (`large-v3` model), run with the base `python3`.
|
- `services/stt_server.py` — wraps `faster-whisper` (`large-v3` model), run with the base `python3`.
|
||||||
|
|
||||||
Both are plain background processes (`nohup ... &`), **not** systemd units — they don't survive a reboot; restart manually if needed:
|
Both run as **systemd user units**. The unit files live in the repo (`services/systemd/*.service`,
|
||||||
|
using `%h` rather than a hardcoded home) and `scripts/start_services.sh` installs them into
|
||||||
|
`~/.config/systemd/user/`, enables `linger` and starts them — so they come up on boot without a
|
||||||
|
login session. Re-run that script after editing a unit; it's idempotent. They log to the journal,
|
||||||
|
not to `services/logs/` (those files are leftovers from the earlier `nohup` setup):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/open-notebook/services
|
systemctl --user status open-notebook-tts open-notebook-stt
|
||||||
CUDA_VISIBLE_DEVICES=2 TTS_PORT=8901 nohup ~/miniforge3/envs/chatterbox/bin/python tts_server.py > logs/tts_server.log 2>&1 &
|
systemctl --user restart open-notebook-tts # after editing tts_server.py or adding voices
|
||||||
CUDA_VISIBLE_DEVICES=2 STT_PORT=8902 nohup python3 stt_server.py > logs/stt_server.log 2>&1 &
|
journalctl --user -u open-notebook-tts -f
|
||||||
```
|
```
|
||||||
|
|
||||||
They're pinned to `CUDA_VISIBLE_DEVICES=2` deliberately — GPU 1 tends to fill up with Ollama's resident models (`OLLAMA_KEEP_ALIVE=-1` keeps them loaded), so TTS/STT get GPU 2 to themselves rather than risking an OOM race with Ollama.
|
They were previously plain `nohup ... &` background processes, which silently did not survive a
|
||||||
|
reboot — a podcast then failed at the audio stage with `Failed to generate speech: All connection
|
||||||
|
attempts failed` (`httpx.ConnectError` against `host.docker.internal:8901`) even though outline
|
||||||
|
and transcript had generated fine. That's the signature of a dead TTS server, not a model problem.
|
||||||
|
|
||||||
|
Both units pin GPU 2 **by UUID** (`CUDA_VISIBLE_DEVICES=GPU-83ba6d1f-…`), not by index: GPU 1 is
|
||||||
|
occupied by Ollama's resident models (`OLLAMA_KEEP_ALIVE=-1` keeps them loaded), so TTS/STT go to
|
||||||
|
GPU 2 rather than racing Ollama for VRAM. The index form (`CUDA_VISIBLE_DEVICES=2`) is **not**
|
||||||
|
reliable here — CUDA's default device order is "fastest first", not nvidia-smi's PCI order, so
|
||||||
|
index 2 can resolve to the T600. Re-check the UUID with
|
||||||
|
`nvidia-smi --query-gpu=index,name,uuid --format=csv` if the GPUs are ever reseated.
|
||||||
|
|
||||||
|
#### GPU 2 is shared — TTS concurrency is capped on purpose
|
||||||
|
|
||||||
|
GPU 2 does **not** belong to the TTS server alone. Three processes sit on it: `open-notebook-tts`
|
||||||
|
(~4 GB idle-loaded), `open-notebook-stt` (faster-whisper `large-v3`, ~4 GB), and the unrelated
|
||||||
|
`chatterbox-tts.service` MCP server on port 9999 (~3.4 GB). That leaves roughly 12 GB of working
|
||||||
|
headroom, and TTS inference must fit inside it.
|
||||||
|
|
||||||
|
`podcast_creator` requests `TTS_BATCH_SIZE` clips **concurrently** (`asyncio.gather` per batch,
|
||||||
|
`nodes.py`; upstream default **5**). `tts_server.py`'s `/audio/speech` is a *sync* FastAPI handler,
|
||||||
|
so FastAPI runs each request in a threadpool thread — five clips therefore generate genuinely in
|
||||||
|
parallel on one shared model, and the activation memory multiplies. That drove the TTS process to
|
||||||
|
**16.7 GB** and produced `torch.OutOfMemoryError` inside chatterbox's `s3gen.embed_ref`, surfacing
|
||||||
|
in the app as `Failed to generate speech: OpenAI-compatible TTS endpoint error: HTTP 500`. Note
|
||||||
|
this is a *different* failure from the dead-server `ConnectError` above — a 500 means the server is
|
||||||
|
alive and rejecting the work, so check the TTS journal for the CUDA OOM before assuming a restart
|
||||||
|
will help.
|
||||||
|
|
||||||
|
Two changes keep it bounded, and both matter:
|
||||||
|
|
||||||
|
- **`tts_server.py` serializes generation behind `_GPU_LOCK`** (a `threading.Lock` around model load
|
||||||
|
+ the chunk loop, plus `torch.cuda.empty_cache()` afterwards). Generation is GPU-bound, so running
|
||||||
|
clips in parallel buys no throughput on a single card — it only multiplies peak VRAM. This is the
|
||||||
|
hard guarantee: it holds no matter which client calls, and no matter what batch size they use.
|
||||||
|
- **`TTS_BATCH_SIZE=1` in `docker-compose.yml`** so the client sends clips one at a time. Without
|
||||||
|
this, the lock still prevents the OOM, but 4 of the 5 batched requests sit queued in it — and with
|
||||||
|
~40-60 s per clip the last one approaches esperanto's **300 s** TTS timeout. `ESPERANTO_TTS_TIMEOUT=600`
|
||||||
|
is set alongside it as extra headroom for long segments.
|
||||||
|
|
||||||
|
Measured after the fix: 5 concurrent `/audio/speech` requests all return 200, GPU 2 peaks at
|
||||||
|
~11.5 GB total (vs. 16.7 GB for the TTS process alone before), i.e. ~12 GB of headroom left.
|
||||||
|
If you add another GPU-resident service to GPU 2, re-check that budget.
|
||||||
|
|
||||||
Reachability from the `open_notebook` container requires **two things**, both already done: the `extra_hosts: host.docker.internal:host-gateway` entry in `docker-compose.yml`, and UFW rules allowing inbound `8901/tcp` and `8902/tcp` (same pattern as the existing `11434/tcp` rule for Ollama — UFW defaults to deny-incoming, so any new host-side port a container needs to reach must be explicitly opened).
|
Reachability from the `open_notebook` container requires **two things**, both already done: the `extra_hosts: host.docker.internal:host-gateway` entry in `docker-compose.yml`, and UFW rules allowing inbound `8901/tcp` and `8902/tcp` (same pattern as the existing `11434/tcp` rule for Ollama — UFW defaults to deny-incoming, so any new host-side port a container needs to reach must be explicitly opened).
|
||||||
|
|
||||||
|
|
@ -74,7 +286,7 @@ Three reference clips exist in `services/voices/` (gitignored, not committed):
|
||||||
piper --model de_DE-thorsten-medium.onnx --output_file male_thorsten.wav <<< "some German sentence"
|
piper --model de_DE-thorsten-medium.onnx --output_file male_thorsten.wav <<< "some German sentence"
|
||||||
```
|
```
|
||||||
|
|
||||||
Podcast speaker profiles are assigned across these three: `business_panel` (Marcus→male_thorsten, Elena→female_kerstin, Johny→default), `tech_experts` (Alex→male_thorsten, Jamie→female_kerstin), `solo_expert`/`test_speaker_local` (→female_kerstin/Anna). To add another distinct voice, drop a new `<name>.wav` (~10-30s, clean single-speaker audio, licensing-checked) into `services/voices/`, restart `tts_server.py` (voices are scanned once at startup), and set the speaker's `voice_id` to `<name>` via `PUT /api/speaker-profiles/{id}`.
|
Podcast speaker profiles are assigned across these three: `business_panel` (Marcus→male_thorsten, Elena→female_kerstin, Johny→default), `tech_experts` (Alex→male_thorsten, Jamie→female_kerstin), `solo_expert`/`test_speaker_local` (→female_kerstin/Anna). To add another distinct voice, drop a new `<name>.wav` (~10-30s, clean single-speaker audio, licensing-checked) into `services/voices/`, restart the TTS server (`systemctl --user restart open-notebook-tts` — voices are scanned once at startup), and set the speaker's `voice_id` to `<name>` via `PUT /api/speaker-profiles/{id}`.
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
|
|
|
||||||
131
README.md
131
README.md
|
|
@ -24,8 +24,8 @@ Weiterentwicklung: siehe [`CLAUDE.md`](CLAUDE.md).
|
||||||
| `surrealdb` | Docker | Datenbank (nur `127.0.0.1:8000`) |
|
| `surrealdb` | Docker | Datenbank (nur `127.0.0.1:8000`) |
|
||||||
| `open_notebook` | Docker | Web-UI (`:8502`) + REST-API (`:5055`) |
|
| `open_notebook` | Docker | Web-UI (`:8502`) + REST-API (`:5055`) |
|
||||||
| Ollama | Host (systemd) | Chat-/Embedding-Modelle, GPU 1+2 |
|
| Ollama | Host (systemd) | Chat-/Embedding-Modelle, GPU 1+2 |
|
||||||
| `services/tts_server.py` | Host (Hintergrundprozess) | Chatterbox TTS, GPU 2, Port `8901` |
|
| `services/tts_server.py` | Host (systemd-User-Dienst) | Chatterbox TTS, GPU 2, Port `8901` |
|
||||||
| `services/stt_server.py` | Host (Hintergrundprozess) | faster-whisper STT, GPU 2, Port `8902` |
|
| `services/stt_server.py` | Host (systemd-User-Dienst) | faster-whisper STT, GPU 2, Port `8902` |
|
||||||
|
|
||||||
Der `open_notebook`-Container erreicht Ollama und die TTS/STT-Wrapper über
|
Der `open_notebook`-Container erreicht Ollama und die TTS/STT-Wrapper über
|
||||||
`host.docker.internal` (Linux-Route via `extra_hosts: host-gateway`).
|
`host.docker.internal` (Linux-Route via `extra_hosts: host-gateway`).
|
||||||
|
|
@ -49,6 +49,8 @@ Der `open_notebook`-Container erreicht Ollama und die TTS/STT-Wrapper über
|
||||||
in Schritt 2)
|
in Schritt 2)
|
||||||
- **`ufw`** (falls als Firewall aktiv) — Standardrichtlinie „deny incoming“ wird vorausgesetzt
|
- **`ufw`** (falls als Firewall aktiv) — Standardrichtlinie „deny incoming“ wird vorausgesetzt
|
||||||
- **`openssl`**, **`ffmpeg`**, **`curl`**, **`python3`**
|
- **`openssl`**, **`ffmpeg`**, **`curl`**, **`python3`**
|
||||||
|
- **`yt-dlp`** — nur für `scripts/add_video_source.py` (Videos ohne Untertitel); der übrige Stack
|
||||||
|
läuft auch ohne
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -80,11 +82,14 @@ chmod 600 .env
|
||||||
### 2.3 Container starten
|
### 2.3 Container starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose ps # beide Container sollten "Up" sein
|
docker compose ps # beide Container sollten "Up" sein
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Die Images sind in `docker-compose.yml` auf einen **Digest gepinnt** (`pull_policy: missing`) —
|
||||||
|
`up -d` lädt sie beim ersten Mal und danach nie wieder unbemerkt eine andere Version. Aktualisiert
|
||||||
|
wird bewusst, siehe [Abschnitt 4](#4-updates).
|
||||||
|
|
||||||
Web-UI: `http://localhost:8502` · REST-API: `http://localhost:5055` (beide nur `127.0.0.1`).
|
Web-UI: `http://localhost:8502` · REST-API: `http://localhost:5055` (beide nur `127.0.0.1`).
|
||||||
|
|
||||||
### 2.4 Firewall öffnen für TTS/STT
|
### 2.4 Firewall öffnen für TTS/STT
|
||||||
|
|
@ -101,19 +106,29 @@ sudo ufw allow 8902/tcp comment 'open-notebook STT (faster-whisper)'
|
||||||
Ohne diesen Schritt bleiben TTS/STT vom Container aus unerreichbar (Timeout), obwohl sie lokal
|
Ohne diesen Schritt bleiben TTS/STT vom Container aus unerreichbar (Timeout), obwohl sie lokal
|
||||||
laufen und funktionieren.
|
laufen und funktionieren.
|
||||||
|
|
||||||
### 2.5 TTS/STT-Server starten
|
### 2.5 TTS/STT-Server einrichten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/start_services.sh
|
./scripts/start_services.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Startet `tts_server.py` und `stt_server.py` als Hintergrundprozesse auf GPU 2 (nicht GPU 0/1,
|
Installiert `services/systemd/*.service` nach `~/.config/systemd/user/`, aktiviert `linger`
|
||||||
um Ollama und die Desktop-GPU nicht zu belasten). Nicht systemd-verwaltet — nach einem Reboot
|
(damit die Dienste auch ohne aktive Login-Session laufen) und startet beide. Sie laufen auf
|
||||||
erneut ausführen. Logs: `services/logs/`.
|
GPU 2 — nicht GPU 0/1, um die Desktop-GPU und Ollamas Chat-Modell nicht zu verdrängen.
|
||||||
|
|
||||||
|
Das Skript ist idempotent und **überlebt Reboots**: die Dienste starten automatisch mit. Status
|
||||||
|
und Logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user status open-notebook-tts open-notebook-stt
|
||||||
|
journalctl --user -u open-notebook-tts -f
|
||||||
|
```
|
||||||
|
|
||||||
|
> Der erste Start dauert bis zu einer Minute, weil whisper `large-v3` geladen wird.
|
||||||
|
|
||||||
Für Stimmklonung mindestens eine Referenz-WAV in `services/voices/default.wav` ablegen (z. B.
|
Für Stimmklonung mindestens eine Referenz-WAV in `services/voices/default.wav` ablegen (z. B.
|
||||||
Symlink auf eine eigene Sprachaufnahme, 10–30s, WAV) — siehe
|
Symlink auf eine eigene Sprachaufnahme, 10–30s, WAV) — siehe
|
||||||
[`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md#stimmklonung).
|
[`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md#5-stimmklonung).
|
||||||
|
|
||||||
### 2.6 KI-Provider und Modelle registrieren
|
### 2.6 KI-Provider und Modelle registrieren
|
||||||
|
|
||||||
|
|
@ -151,6 +166,12 @@ curl -s http://127.0.0.1:8901/health # Chatterbox TTS
|
||||||
curl -s http://127.0.0.1:8902/health # faster-whisper STT
|
curl -s http://127.0.0.1:8902/health # faster-whisper STT
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Gründlicher — prüft API, TTS, STT, YouTube-Extraktion und alle lokalen Anpassungen auf einmal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/smoke_test.sh
|
||||||
|
```
|
||||||
|
|
||||||
UI öffnen (`http://localhost:8502`), ein Notebook anlegen, eine Quelle hinzufügen und chatten —
|
UI öffnen (`http://localhost:8502`), ein Notebook anlegen, eine Quelle hinzufügen und chatten —
|
||||||
siehe [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md) für die weitere Bedienung.
|
siehe [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md) für die weitere Bedienung.
|
||||||
|
|
||||||
|
|
@ -163,12 +184,57 @@ docker compose up -d # starten
|
||||||
docker compose down # stoppen (Daten bleiben erhalten)
|
docker compose down # stoppen (Daten bleiben erhalten)
|
||||||
docker compose logs -f open_notebook # Logs verfolgen
|
docker compose logs -f open_notebook # Logs verfolgen
|
||||||
docker compose ps # Status
|
docker compose ps # Status
|
||||||
./scripts/start_services.sh # TTS/STT (re-)starten, z.B. nach Reboot
|
|
||||||
|
systemctl --user status open-notebook-tts open-notebook-stt # TTS/STT-Status
|
||||||
|
systemctl --user restart open-notebook-tts # z.B. nach neuer Stimme
|
||||||
|
journalctl --user -u open-notebook-tts -f # TTS-Logs
|
||||||
|
./scripts/start_services.sh # Units neu installieren + starten (idempotent)
|
||||||
|
|
||||||
|
./scripts/prune_podcast_data.py # Podcast-Datenmüll anzeigen (Trockenlauf)
|
||||||
|
./scripts/prune_podcast_data.py --yes # ... und löschen
|
||||||
|
|
||||||
|
./scripts/add_video_source.py <url> # Video als Quelle (auch ohne Untertitel)
|
||||||
|
|
||||||
|
./scripts/check_updates.sh # gibt es ein neues Release? (ändert nichts)
|
||||||
|
./scripts/update_stack.sh # Update mit Backup, Rauchtest und Rollback
|
||||||
|
./scripts/smoke_test.sh # tut der Stack noch, worauf wir uns verlassen?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
TTS/STT starten nach einem Reboot von selbst — `start_services.sh` ist nur für die
|
||||||
|
Ersteinrichtung bzw. nach Änderungen an den Unit-Dateien nötig.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Verzeichnisstruktur
|
## 4. Updates
|
||||||
|
|
||||||
|
Die Images sind **auf einen Digest gepinnt** — es läuft immer genau der Stand, der im Repo steht.
|
||||||
|
Weder ein `docker compose up -d` noch ein Reboot tauscht die Anwendung unbemerkt aus.
|
||||||
|
|
||||||
|
Beim Sitzungsstart (oder wann immer Sie mögen):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check_updates.sh # zeigt nur: läuft X, verfügbar ist Y
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn ein neues Release da ist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update_stack.sh # Backup -> Update -> Rauchtest -> bei Fehler Rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
Warum nicht automatisch das Neueste bei jedem Start? Ein Update **migriert die Datenbank**, und
|
||||||
|
eine ältere Anwendung kann eine migrierte Datenbank in der Regel nicht mehr lesen — ein
|
||||||
|
misslungenes Update ohne Backup wäre eine Einbahnstraße. Zudem hängen die lokalen Anpassungen
|
||||||
|
dieses Projekts (Podcast-Vorlagen, content-core-Config, Env-Variablen) an Interna der Anwendung
|
||||||
|
und können still brechen. Genau das prüft der Rauchtest; schlägt er fehl, rollt das Skript
|
||||||
|
Daten und Compose-Datei automatisch zurück.
|
||||||
|
|
||||||
|
Hinweis: `v1-latest` folgt den **Releases**, nicht dem `main`-Branch — der Repository-Stand ist
|
||||||
|
typischerweise Wochen voraus, aber unveröffentlicht. Wer den will, müsste selbst bauen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Verzeichnisstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
open-notebook/
|
open-notebook/
|
||||||
|
|
@ -177,14 +243,23 @@ open-notebook/
|
||||||
├── .env.example # Template
|
├── .env.example # Template
|
||||||
├── surreal_data/ # DB-Daten (gitignored)
|
├── surreal_data/ # DB-Daten (gitignored)
|
||||||
├── notebook_data/ # Notebook-Dateien/Uploads (gitignored)
|
├── notebook_data/ # Notebook-Dateien/Uploads (gitignored)
|
||||||
|
├── config/
|
||||||
|
│ └── content_core.yaml # content-core (u.a. deutsche YouTube-Transkripte)
|
||||||
├── services/
|
├── services/
|
||||||
│ ├── tts_server.py # Chatterbox-Wrapper (OpenAI-API-kompatibel)
|
│ ├── tts_server.py # Chatterbox-Wrapper (OpenAI-API-kompatibel)
|
||||||
│ ├── stt_server.py # faster-whisper-Wrapper
|
│ ├── stt_server.py # faster-whisper-Wrapper
|
||||||
|
│ ├── systemd/ # Unit-Dateien für die beiden Wrapper
|
||||||
│ ├── voices/ # Referenz-WAVs fürs Voice-Cloning (gitignored)
|
│ ├── voices/ # Referenz-WAVs fürs Voice-Cloning (gitignored)
|
||||||
│ └── logs/ # (gitignored)
|
│ └── logs/ # (gitignored, Altlast der früheren nohup-Variante)
|
||||||
|
├── backups/ # Sicherungen vor Image-Updates (gitignored)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── setup_models.sh # Provider/Modelle einrichten (idempotent)
|
│ ├── setup_models.sh # Provider/Modelle einrichten (idempotent)
|
||||||
│ └── start_services.sh # TTS/STT starten
|
│ ├── start_services.sh # TTS/STT als systemd-User-Dienste installieren+starten
|
||||||
|
│ ├── prune_podcast_data.py # verwaiste Podcast-Ordner + Zwischenclips aufräumen
|
||||||
|
│ ├── add_video_source.py # Video als Quelle (Untertitel oder Tonspur+Whisper)
|
||||||
|
│ ├── check_updates.sh # neues Release verfügbar? (ändert nichts)
|
||||||
|
│ ├── update_stack.sh # Update mit Backup/Rauchtest/Rollback
|
||||||
|
│ └── smoke_test.sh # prüft die Annahmen, auf denen die Anpassungen beruhen
|
||||||
├── README.md # diese Datei
|
├── README.md # diese Datei
|
||||||
├── BEDIENUNGSANLEITUNG.md # ausführliche Bedienungsanleitung (Deutsch)
|
├── BEDIENUNGSANLEITUNG.md # ausführliche Bedienungsanleitung (Deutsch)
|
||||||
└── CLAUDE.md # technische Architektur-Doku für Weiterentwicklung
|
└── CLAUDE.md # technische Architektur-Doku für Weiterentwicklung
|
||||||
|
|
@ -192,7 +267,7 @@ open-notebook/
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Bekannte Stolpersteine
|
## 6. Bekannte Stolpersteine
|
||||||
|
|
||||||
Kurzreferenz — Details jeweils in [`CLAUDE.md`](CLAUDE.md):
|
Kurzreferenz — Details jeweils in [`CLAUDE.md`](CLAUDE.md):
|
||||||
|
|
||||||
|
|
@ -204,3 +279,33 @@ Kurzreferenz — Details jeweils in [`CLAUDE.md`](CLAUDE.md):
|
||||||
leere Chat-Antworten bei größerem Kontext, ohne sichtbaren Fehler.
|
leere Chat-Antworten bei größerem Kontext, ohne sichtbaren Fehler.
|
||||||
- **Neue Host-Ports brauchen eine `ufw`-Regel**, sonst kann der Container sie nicht erreichen
|
- **Neue Host-Ports brauchen eine `ufw`-Regel**, sonst kann der Container sie nicht erreichen
|
||||||
(Timeout, keine Fehlermeldung in Open Notebook selbst).
|
(Timeout, keine Fehlermeldung in Open Notebook selbst).
|
||||||
|
- **YouTube-Transkripte brauchen `config/content_core.yaml`.** `content-core` sucht Untertitel
|
||||||
|
standardmäßig nur in `en`/`es`/`pt` — deutsche Videos landen sonst als **leere Quelle** im
|
||||||
|
Notebook (Titel da, Inhalt leer, Fehler nur im Log). Die eingehängte Config setzt Deutsch an
|
||||||
|
erste Stelle. Achtung: `CCORE_CONFIG_PATH` ersetzt die Konfiguration komplett (kein Merge) —
|
||||||
|
die Datei muss vollständig bleiben, siehe Kopfkommentar darin.
|
||||||
|
- **Videos ohne Untertitel** gehen über `./scripts/add_video_source.py <url>` (yt-dlp lädt die
|
||||||
|
Tonspur, der lokale Whisper-Server transkribiert sie). Dafür ist
|
||||||
|
`OPENAI_COMPATIBLE_BASE_URL_STT` in `docker-compose.yml` zwingend: content-core reicht dem
|
||||||
|
STT-Modell sonst keine `base_url` durch, fällt auf OpenAI zurück und scheitert mit
|
||||||
|
„OpenAI API key not found" — das ist **kein** fehlender Key, sondern eine fehlende URL. Keinen
|
||||||
|
`OPENAI_API_KEY` in den Container legen: das schickte Audio in die Cloud, obwohl Whisper lokal
|
||||||
|
auf GPU 2 bereitsteht.
|
||||||
|
- **Episoden löschen räumt nicht auf.** Der Mülleimer-Button (bzw.
|
||||||
|
`DELETE /api/podcasts/episodes/{id}`) entfernt nur die finale MP3 und den DB-Eintrag — der
|
||||||
|
Ordner `notebook_data/podcasts/episodes/<uuid>/` mit den Einzelclips, `outline.json` und
|
||||||
|
`transcript.json` bleibt liegen. Das ist eine Lücke im Upstream-Code, kein lokales
|
||||||
|
Konfigurationsproblem. Abhilfe: `./scripts/prune_podcast_data.py`.
|
||||||
|
- **GPU 2 teilen sich drei Prozesse** (TTS, STT und der separate `chatterbox-tts`-MCP-Dienst auf
|
||||||
|
Port 9999) — es bleiben nur ~12 GB Arbeitsspeicher für die Sprachsynthese. Deshalb serialisiert
|
||||||
|
`tts_server.py` seine Generierung intern und `TTS_BATCH_SIZE=1` steht in `docker-compose.yml`:
|
||||||
|
ohne beides schickt der Podcast-Generator 5 Clips gleichzeitig, die parallel auf der GPU laufen
|
||||||
|
und sie mit `CUDA out of memory` sprengen (sichtbar als `HTTP 500` beim Vertonen).
|
||||||
|
- **Podcast-Sprache** wird über das `language`-Feld des Episode-Profils gesteuert, aber nur
|
||||||
|
dank der gepatchten Prompt-Vorlagen unter `prompts/podcast/` (per Bind-Mount in
|
||||||
|
`docker-compose.yml` eingehängt) — die Original-Vorlagen im Image ignorieren `language`, was
|
||||||
|
englische Podcasts (von der deutsch-fixierten TTS mit Akzent vorgelesen) verursachte. Die
|
||||||
|
Vorlagen enthalten zusätzlich `/no_think`, damit `qwen3.5:27b` bei langem Quellinhalt nicht
|
||||||
|
durch überlange Reasoning-Blöcke ungültiges JSON liefert. Änderst du die Vorlagen, danach
|
||||||
|
`docker compose up -d --force-recreate open_notebook`. Details:
|
||||||
|
[`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md#11-troubleshooting) und [`CLAUDE.md`](CLAUDE.md).
|
||||||
|
|
|
||||||
78
config/content_core.yaml
Normal file
78
config/content_core.yaml
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# content-core Konfiguration fuer dieses Deployment.
|
||||||
|
#
|
||||||
|
# Zweck: YouTube-Quellen mit deutschem Transkript nutzbar machen.
|
||||||
|
#
|
||||||
|
# content-core sucht Transkripte nur in "preferred_languages". Der eingebaute
|
||||||
|
# Standard ist ["en", "es", "pt"] — ohne Deutsch. Ein Video mit ausschliesslich
|
||||||
|
# deutschem Transkript liefert deshalb NoTranscriptFound, und Open Notebook legt
|
||||||
|
# die Quelle mit LEEREM Inhalt an (der Titel wird trotzdem geholt, der Fehler
|
||||||
|
# taucht nur im Log auf).
|
||||||
|
#
|
||||||
|
# Der Schluessel youtube_transcripts steht zwar in der cc_config.yaml des Pakets,
|
||||||
|
# wird von load_config() im Standardfall aber gar nicht eingelesen (dort wird nur
|
||||||
|
# der Block "extraction" uebernommen) — er ist also wirkungslos. Einziger Weg,
|
||||||
|
# ihn zu setzen: CCORE_CONFIG_PATH auf eine eigene Datei zeigen lassen.
|
||||||
|
#
|
||||||
|
# ACHTUNG: CCORE_CONFIG_PATH ERSETZT die Konfiguration vollstaendig, es wird
|
||||||
|
# nicht zusammengefuehrt (content_core/config.py: return yaml.safe_load(file)).
|
||||||
|
# Diese Datei ist deshalb ein vollstaendiger Abzug der effektiven Standardconfig
|
||||||
|
# plus youtube_transcripts. Bei einem Image-Update mit geaenderten Defaults neu
|
||||||
|
# erzeugen:
|
||||||
|
#
|
||||||
|
# docker compose exec -T open_notebook /app/.venv/bin/python -c "
|
||||||
|
# from content_core.config import CONFIG; import yaml, copy
|
||||||
|
# cfg = copy.deepcopy(CONFIG)
|
||||||
|
# cfg['youtube_transcripts'] = {'preferred_languages': ['de','en','es','pt']}
|
||||||
|
# print(yaml.safe_dump(cfg, sort_keys=False, allow_unicode=True))" > config/content_core.yaml
|
||||||
|
#
|
||||||
|
# Die openai/gpt-4o-mini-Eintraege unten sind content-core-interne Defaults; sie
|
||||||
|
# werden hier nicht benutzt (Open Notebook waehlt seine Modelle selbst) und
|
||||||
|
# stehen nur drin, weil die Datei vollstaendig sein muss.
|
||||||
|
speech_to_text:
|
||||||
|
provider: openai
|
||||||
|
model_name: whisper-1
|
||||||
|
timeout: 3600
|
||||||
|
default_model:
|
||||||
|
provider: openai
|
||||||
|
model_name: gpt-4o-mini
|
||||||
|
config:
|
||||||
|
temperature: 0.5
|
||||||
|
top_p: 1
|
||||||
|
max_tokens: 2000
|
||||||
|
timeout: 300
|
||||||
|
cleanup_model:
|
||||||
|
provider: openai
|
||||||
|
model_name: gpt-4o-mini
|
||||||
|
config:
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 8000
|
||||||
|
output_format: json
|
||||||
|
timeout: 600
|
||||||
|
summary_model:
|
||||||
|
provider: openai
|
||||||
|
model_name: gpt-4o-mini
|
||||||
|
config:
|
||||||
|
temperature: 0
|
||||||
|
top_p: 1
|
||||||
|
max_tokens: 2000
|
||||||
|
timeout: 300
|
||||||
|
extraction:
|
||||||
|
document_engine: auto
|
||||||
|
url_engine: auto
|
||||||
|
audio:
|
||||||
|
concurrency: 3
|
||||||
|
firecrawl:
|
||||||
|
api_url: null
|
||||||
|
docling:
|
||||||
|
output_format: markdown
|
||||||
|
pymupdf:
|
||||||
|
enable_formula_ocr: false
|
||||||
|
formula_threshold: 3
|
||||||
|
ocr_fallback: true
|
||||||
|
youtube_transcripts:
|
||||||
|
preferred_languages:
|
||||||
|
- de
|
||||||
|
- en
|
||||||
|
- es
|
||||||
|
- pt
|
||||||
|
|
||||||
|
|
@ -1,8 +1,21 @@
|
||||||
|
# Images sind bewusst auf einen Digest gepinnt, nicht auf einen beweglichen Tag.
|
||||||
|
#
|
||||||
|
# Warum: "v1-latest" ist ein wandernder Zeiger. Mit pull_policy: always zog jedes
|
||||||
|
# `docker compose up -d` unbemerkt eine neue Anwendung — inklusive Schema-Migration der
|
||||||
|
# SurrealDB, die sich nicht ohne Weiteres zurueckdrehen laesst. Gleichzeitig griff das
|
||||||
|
# nur bei `up -d`: nach einem Reboot startete `restart: always` weiter das ALTE Image.
|
||||||
|
# Also beides unzuverlaessig: mal heimlich neu, mal heimlich alt.
|
||||||
|
#
|
||||||
|
# Mit Digest-Pin laeuft immer exakt der Stand, der hier im Repo steht. Aktualisiert wird
|
||||||
|
# bewusst und mit Rueckfallpunkt:
|
||||||
|
# ./scripts/check_updates.sh -> gibt es ein neues Release? (aendert nichts)
|
||||||
|
# ./scripts/update_stack.sh -> Backup, Update, Rauchtest, bei Fehler Rollback
|
||||||
services:
|
services:
|
||||||
surrealdb:
|
surrealdb:
|
||||||
image: surrealdb/surrealdb:v2
|
# surrealdb:v2 (Digest-Pin: auch ein Minor-Sprung kann das Storage-Format anfassen)
|
||||||
|
image: surrealdb/surrealdb:v2@sha256:d653f6c8a89e81f865ee31cd2f587c50f50ace922175e04150b1e385d2f86011
|
||||||
restart: always
|
restart: always
|
||||||
pull_policy: always
|
pull_policy: missing
|
||||||
command: start --log info --user ${SURREAL_USER:-root} --pass ${SURREAL_PASSWORD:-root} rocksdb:/mydata/mydatabase.db
|
command: start --log info --user ${SURREAL_USER:-root} --pass ${SURREAL_PASSWORD:-root} rocksdb:/mydata/mydatabase.db
|
||||||
user: root # Required for bind mounts on Linux
|
user: root # Required for bind mounts on Linux
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -13,9 +26,11 @@ services:
|
||||||
- ./surreal_data:/mydata
|
- ./surreal_data:/mydata
|
||||||
|
|
||||||
open_notebook:
|
open_notebook:
|
||||||
image: lfnovo/open_notebook:v1-latest
|
# Open Notebook v1.10.0 (Release vom 2026-06-18, Image vom selben Tag).
|
||||||
|
# Digest von scripts/update_stack.sh gepflegt — nicht von Hand aendern.
|
||||||
|
image: lfnovo/open_notebook:v1-latest@sha256:c8112fbd4b8fee7f2a20d3bdbea24e7d72267acfc990b803fd0ff4de30899b57
|
||||||
restart: always
|
restart: always
|
||||||
pull_policy: always
|
pull_policy: missing
|
||||||
depends_on:
|
depends_on:
|
||||||
- surrealdb
|
- surrealdb
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -30,7 +45,35 @@ services:
|
||||||
- SURREAL_DATABASE=open_notebook
|
- SURREAL_DATABASE=open_notebook
|
||||||
- OLLAMA_BASE_URL=http://host.docker.internal:11434
|
- OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||||
|
# podcast_creator schickt sonst 5 Audio-Clips gleichzeitig an den TTS-Server.
|
||||||
|
# Die laufen dort echt parallel auf einer GPU -> Aktivierungsspeicher
|
||||||
|
# vervielfacht sich -> CUDA OOM (GPU 2 teilt sich TTS, STT und den
|
||||||
|
# chatterbox-MCP-Dienst). Der TTS-Server serialisiert intern zwar ohnehin,
|
||||||
|
# aber bei Batch 5 warten 4 Requests im Lock und laufen ins 300s-Timeout.
|
||||||
|
- TTS_BATCH_SIZE=1
|
||||||
|
# Kopfraum fuer lange Segmente; Default waere 300s pro Clip.
|
||||||
|
- ESPERANTO_TTS_TIMEOUT=600
|
||||||
|
# content-core sucht YouTube-Transkripte sonst nur in en/es/pt — deutsche
|
||||||
|
# Videos landen dann als LEERE Quelle im Notebook. Siehe config/content_core.yaml.
|
||||||
|
- CCORE_CONFIG_PATH=/app/config/content_core.yaml
|
||||||
|
# Audio-Quellen (Upload, Video-Tonspur): Open Notebook reicht sein STT-Modell
|
||||||
|
# (openai_compatible/faster-whisper-large-v3) an content-core weiter, aber
|
||||||
|
# content-core baut das Modell OHNE base_url (es gibt nur timeout mit). Esperanto
|
||||||
|
# weiss dann nicht, wo der lokale Whisper-Server steht, faellt auf OpenAI zurueck
|
||||||
|
# und scheitert mit "OpenAI API key not found". Diese Variable liefert die URL nach.
|
||||||
|
- OPENAI_COMPATIBLE_BASE_URL_STT=http://host.docker.internal:8902
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./notebook_data:/app/data
|
- ./notebook_data:/app/data
|
||||||
|
# Podcast-Prompt-Vorlagen mit expliziter Sprachanweisung ({{ language }})
|
||||||
|
# und abgeschaltetem Thinking-Modus (/no_think), damit Podcasts in der
|
||||||
|
# eingestellten Sprache erzeugt werden (statt Englisch) und das JSON bei
|
||||||
|
# langen Segmenten nicht durch riesige Reasoning-Blöcke abgeschnitten wird.
|
||||||
|
# Verzeichnis-Mount (nicht Einzeldateien), damit Edits ohne Inode-Probleme
|
||||||
|
# nach einem Container-Neustart greifen. Der Ordner im Image enthält nur
|
||||||
|
# genau diese zwei Vorlagen.
|
||||||
|
- ./prompts/podcast:/app/prompts/podcast:ro
|
||||||
|
# content-core-Konfiguration (u.a. deutsche YouTube-Transkripte), per
|
||||||
|
# CCORE_CONFIG_PATH oben referenziert.
|
||||||
|
- ./config:/app/config:ro
|
||||||
|
|
|
||||||
91
prompts/podcast/outline.jinja
Normal file
91
prompts/podcast/outline.jinja
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
/no_think
|
||||||
|
You are an AI assistant specialized in creating podcast outlines. Your task is to create a detailed outline for a podcast episode based on a provided briefing. The outline you create will be used to generate the podcast transcript.
|
||||||
|
|
||||||
|
Here is the briefing for the podcast episode:
|
||||||
|
<briefing>
|
||||||
|
{{ briefing }}
|
||||||
|
</briefing>
|
||||||
|
|
||||||
|
The user has provided content to be used as the context for this podcast episode:
|
||||||
|
<context>
|
||||||
|
{% if context is string %}
|
||||||
|
{{ context }}
|
||||||
|
{% else %}
|
||||||
|
{% for item in context %}
|
||||||
|
<content_piece>
|
||||||
|
{{ item }}
|
||||||
|
</content_piece>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</context>
|
||||||
|
|
||||||
|
The podcast will feature the following speakers:
|
||||||
|
<speakers>
|
||||||
|
{% for speaker in speakers %}
|
||||||
|
- **{{ speaker.name }}**: {{ speaker.backstory }}
|
||||||
|
Personality: {{ speaker.personality }}
|
||||||
|
{% endfor %}
|
||||||
|
</speakers>
|
||||||
|
|
||||||
|
Please create an outline based on this briefing. Your outline should consist of {{ num_segments }} main segments for the podcast episode, along with a description of each segment. Follow these guidelines:
|
||||||
|
|
||||||
|
1. Read the briefing carefully and identify the main topics and themes.
|
||||||
|
2. Create {{ num_segments }} distinct segments that cover the entire scope of the briefing.
|
||||||
|
3. For each segment, provide a clear and concise name that reflects its content.
|
||||||
|
4. Write a detailed description for each segment, explaining what will be discussed and provide suggestions of topics according to the context given. The writer will use your suggestion to design the dialogs.
|
||||||
|
5. Consider the speaker personalities and backstories when planning segments - match content to speaker expertise.
|
||||||
|
6. Ensure that the segments flow logically from one to the next.
|
||||||
|
7. This is a whole podcast so no need to reintroduce speakers or topics on each segment. Segments are just markers for us to know to change the topics, nothing else.
|
||||||
|
8. Include an introduction segment at the beginning and a conclusion or wrap-up segment at the end.
|
||||||
|
|
||||||
|
Format your outline using the following structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"name": "[Segment Name]",
|
||||||
|
"description": "[Description of the segment content]",
|
||||||
|
"size": "short"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[Segment Name]",
|
||||||
|
"description": "[Description of the segment content]",
|
||||||
|
"size": "medium"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[Segment Name]",
|
||||||
|
"description": "[Description of the segment content]",
|
||||||
|
"size": "long"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Formatting instructions:
|
||||||
|
{{ format_instructions}}
|
||||||
|
|
||||||
|
Additional tips:
|
||||||
|
- Make sure the segment names are catchy and informative.
|
||||||
|
- In the descriptions, include key points or questions that will be addressed in each segment.
|
||||||
|
- Consider the target audience mentioned in the briefing when crafting your outline.
|
||||||
|
- If the briefing mentions a guest, include segments for introducing the guest and featuring their expertise.
|
||||||
|
- The size of the segment should be short, medium or long. Think about the content of the segment and how important it is to the episode.
|
||||||
|
|
||||||
|
{% if language %}
|
||||||
|
CRITICAL LANGUAGE REQUIREMENT:
|
||||||
|
- Write the ENTIRE outline — every segment name and every segment description — exclusively in {{ language }}.
|
||||||
|
- Do NOT use English or any other language, not even for technical terms or proper concepts where a {{ language }} wording exists.
|
||||||
|
- These instructions are written in English, but your output MUST be in {{ language }}.
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
IMPORTANT OUTPUT FORMAT:
|
||||||
|
- If you use extended thinking with <think> tags, put ALL your reasoning inside <think></think> tags
|
||||||
|
- Put the final JSON output OUTSIDE and AFTER any <think> tags
|
||||||
|
- Do NOT wrap the JSON in ```json code blocks - return the raw JSON object only
|
||||||
|
- Example correct format:
|
||||||
|
<think>Let me analyze the briefing...</think>
|
||||||
|
{"segments": [...]}
|
||||||
|
|
||||||
|
Please provide your outline now, following the format and guidelines provided above.
|
||||||
142
prompts/podcast/transcript.jinja
Normal file
142
prompts/podcast/transcript.jinja
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
/no_think
|
||||||
|
You are an AI assistant specialized in creating podcast transcripts.
|
||||||
|
Your task is to generate a transcript for a specific segment of a podcast episode based on a provided briefing and outline.
|
||||||
|
The transcript will be used to generate podcast audio. Follow these instructions carefully:
|
||||||
|
|
||||||
|
First, review the briefing for the podcast episode:
|
||||||
|
<briefing>
|
||||||
|
{{ briefing }}
|
||||||
|
</briefing>
|
||||||
|
|
||||||
|
The user has provided content to be used as the context for this podcast episode:
|
||||||
|
<context>
|
||||||
|
{% if context is string %}
|
||||||
|
{{ context }}
|
||||||
|
{% else %}
|
||||||
|
{% for item in context %}
|
||||||
|
<content_piece>
|
||||||
|
{{ item }}
|
||||||
|
</content_piece>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</context>
|
||||||
|
|
||||||
|
The podcast features the following speakers:
|
||||||
|
<speakers>
|
||||||
|
{% for speaker in speakers %}
|
||||||
|
- **{{ speaker.name }}**: {{ speaker.backstory }}
|
||||||
|
Personality: {{ speaker.personality }}
|
||||||
|
{% endfor %}
|
||||||
|
</speakers>
|
||||||
|
|
||||||
|
Next, examine the outline produced by our director:
|
||||||
|
<outline>
|
||||||
|
{{ outline }}
|
||||||
|
</outline>
|
||||||
|
|
||||||
|
{% if transcript %}
|
||||||
|
Here is the current transcript so far:
|
||||||
|
<transcript>
|
||||||
|
{{ transcript }}
|
||||||
|
</transcript>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_final %}
|
||||||
|
{% if speakers|length == 1 %}
|
||||||
|
This is the final segment of the podcast. Make sure to wrap up the presentation and provide a conclusion.
|
||||||
|
{% else %}
|
||||||
|
This is the final segment of the podcast. Make sure to wrap up the conversation and provide a conclusion.
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
You will focus on creating the dialogue for the following segment ONLY:
|
||||||
|
<segment>
|
||||||
|
{{ segment }}
|
||||||
|
</segment>
|
||||||
|
|
||||||
|
{% if speakers|length == 1 %}
|
||||||
|
IMPORTANT: This is a SOLO podcast with only ONE speaker ({{ speaker_names[0] }}). Do NOT invent or add any other speakers.
|
||||||
|
All dialogue entries must use "{{ speaker_names[0] }}" as the speaker name.
|
||||||
|
|
||||||
|
Follow these format requirements strictly:
|
||||||
|
- Use ONLY the speaker name "{{ speaker_names[0] }}" for all dialogue entries.
|
||||||
|
- Do NOT create or invent any additional speakers.
|
||||||
|
- Stick to the segment, do not go further than what's requested. Other agents will do the rest of the podcast.
|
||||||
|
- The transcript must have at least {{ turns }} dialogue segments from the speaker.
|
||||||
|
- The speaker should present the content in an engaging, educational manner.
|
||||||
|
{% else %}
|
||||||
|
Follow these format requirements strictly:
|
||||||
|
- Use the actual speaker names ({{ speaker_names|join(', ') }}) to denote speakers.
|
||||||
|
- Choose which speaker should speak based on their personality, backstory, and the content being discussed.
|
||||||
|
- Stick to the segment, do not go further than what's requested. Other agents will do the rest of the podcast.
|
||||||
|
- The transcript must have at least {{ turns }} turns of messages between the speakers.
|
||||||
|
- Each speaker should contribute meaningfully based on their expertise and personality.
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transcript": [
|
||||||
|
{
|
||||||
|
"speaker": "[Actual Speaker Name]",
|
||||||
|
"dialogue": "[Speaker's dialogue based on their personality and expertise]"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Formatting instructions:
|
||||||
|
{{ format_instructions}}
|
||||||
|
|
||||||
|
|
||||||
|
{% if speakers|length == 1 %}
|
||||||
|
Guidelines for creating the transcript:
|
||||||
|
- Ensure the presentation flows naturally and covers all points in the outline.
|
||||||
|
- Ensure you return the root "transcript" key in your response.
|
||||||
|
- Make the content sound engaging and educational.
|
||||||
|
- Include relevant details from the briefing.
|
||||||
|
- Break up the content into digestible segments with natural transitions.
|
||||||
|
- Use appropriate transitions between topics.
|
||||||
|
- Match the speaker's dialogue to their personality and expertise.
|
||||||
|
- This is a whole podcast so no need to reintroduce the speaker or topics on each segment. Segments are just markers for us to know to change the topics, nothing else.
|
||||||
|
- CRITICAL: There is only ONE speaker. Use ONLY: {{ speaker_names[0] }}. Do NOT invent additional speakers.
|
||||||
|
{% else %}
|
||||||
|
Guidelines for creating the transcript:
|
||||||
|
- Ensure the conversation flows naturally and covers all points in the outline.
|
||||||
|
- Ensure you return the root "transcript" key in your response.
|
||||||
|
- Make the dialogue sound conversational and engaging.
|
||||||
|
- Include relevant details from the briefing.
|
||||||
|
- Avoid long monologues; keep exchanges between speakers balanced.
|
||||||
|
- Use appropriate transitions between topics.
|
||||||
|
- Match each speaker's dialogue to their personality and expertise.
|
||||||
|
- Choose speakers strategically based on who would naturally contribute to each topic.
|
||||||
|
- This is a whole podcast so no need to reintroduce speakers or topics on each segment. Segments are just markers for us to know to change the topics, nothing else.
|
||||||
|
- IMPORTANT: Only use the provided speaker names: {{ speaker_names|join(', ') }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if language %}
|
||||||
|
CRITICAL LANGUAGE REQUIREMENT:
|
||||||
|
- Write ALL dialogue lines exclusively in {{ language }}.
|
||||||
|
- Do NOT use English or any other language, not even for technical terms or proper concepts where a {{ language }} wording exists.
|
||||||
|
- These instructions are written in English, but every spoken line you produce MUST be in {{ language }}.
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
IMPORTANT OUTPUT FORMAT:
|
||||||
|
- If you use extended thinking with <think> tags, put ALL your reasoning inside <think></think> tags
|
||||||
|
- Put the final JSON output OUTSIDE and AFTER any <think> tags
|
||||||
|
- Do NOT wrap the JSON in ```json code blocks - return the raw JSON object only
|
||||||
|
- Example correct format:
|
||||||
|
<think>Let me plan the dialogue...</think>
|
||||||
|
{"transcript": [...]}
|
||||||
|
|
||||||
|
When you're ready, provide the transcript.
|
||||||
|
{% if speakers|length == 1 %}
|
||||||
|
Remember, you are creating a realistic solo podcast presentation based on the given information.
|
||||||
|
Make it informative, engaging, and natural-sounding while adhering to the format requirements.
|
||||||
|
There is only ONE speaker - do not add any other speakers.
|
||||||
|
{% else %}
|
||||||
|
Remember, you are creating a realistic podcast conversation based on the given information.
|
||||||
|
Make it informative, engaging, and natural-sounding while adhering to the format requirements.
|
||||||
|
{% endif %}
|
||||||
260
scripts/add_video_source.py
Executable file
260
scripts/add_video_source.py
Executable file
|
|
@ -0,0 +1,260 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fuegt ein Video (YouTube & alles, was yt-dlp kennt) als Quelle in ein Notebook ein —
|
||||||
|
auch ohne Untertitel.
|
||||||
|
|
||||||
|
Zwei Wege, automatisch gewaehlt:
|
||||||
|
|
||||||
|
Untertitel vorhanden -> die URL wird direkt als Link-Quelle uebergeben. Open Notebook
|
||||||
|
holt das Transkript selbst (schnell, kein Download).
|
||||||
|
keine Untertitel -> yt-dlp laedt nur die Tonspur, die wird als Audio-Quelle
|
||||||
|
hochgeladen. Open Notebook transkribiert sie mit dem
|
||||||
|
eingestellten Speech-to-Text-Modell (hier: der lokale
|
||||||
|
faster-whisper-Server auf :8902) und bettet sie ein.
|
||||||
|
|
||||||
|
Der zweite Weg ist noetig, weil Open Notebook fuer Videos nur die vorhandenen Untertitel
|
||||||
|
liest — gibt es keine, bleibt die Quelle leer. yt-dlp laeuft nur auf dem Host (nicht im
|
||||||
|
Container), deshalb diese Bruecke.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
./scripts/add_video_source.py https://www.youtube.com/watch?v=...
|
||||||
|
./scripts/add_video_source.py <url> --notebook notebook:abc --language de
|
||||||
|
./scripts/add_video_source.py <url> --force-audio # Untertitel ignorieren
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
API = "http://127.0.0.1:5055"
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Sprachen, deren Untertitel wir akzeptieren. Deckungsgleich mit
|
||||||
|
# config/content_core.yaml (youtube_transcripts.preferred_languages) — sonst wuerde
|
||||||
|
# dieses Skript den Link-Weg waehlen, obwohl Open Notebook die Sprache gar nicht sucht.
|
||||||
|
CAPTION_LANGS = ["de", "en", "es", "pt"]
|
||||||
|
|
||||||
|
# Whisper braucht nicht mehr: mono, 16 kHz. Haelt den Upload klein (~15 MB/Stunde).
|
||||||
|
AUDIO_POSTPROC = "-ac 1 -ar 16000 -b:a 32k"
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg: str) -> int:
|
||||||
|
print(f"FEHLER: {msg}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def api_get(path: str) -> dict | list:
|
||||||
|
with urllib.request.urlopen(f"{API}{path}", timeout=30) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
def probe(url: str) -> dict:
|
||||||
|
"""Metadaten holen, ohne etwas herunterzuladen."""
|
||||||
|
try:
|
||||||
|
out = run(["yt-dlp", "--dump-single-json", "--no-warnings", "--skip-download", url])
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise SystemExit(die(f"yt-dlp kann die URL nicht lesen:\n{e.stderr.strip()[:500]}"))
|
||||||
|
return json.loads(out.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
YOUTUBE_ID = re.compile(
|
||||||
|
r"(?:youtu\.be/|youtube\.com/(?:embed/|v/|watch\?v=|watch\?.+&v=))([\w-]{11})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def caption_langs(url: str) -> list[str]:
|
||||||
|
"""Untertitelsprachen, die Open Notebook tatsaechlich verwenden kann.
|
||||||
|
|
||||||
|
Bewusst NICHT ueber yt-dlp: dessen "automatic_captions" listet auch YouTubes
|
||||||
|
Auto-Uebersetzungen (>100 Sprachen, u.a. immer "de"), die von
|
||||||
|
youtube-transcript-api gar nicht als Transkript gefunden werden. Wer sich darauf
|
||||||
|
verlaesst, waehlt bei einem z.B. rein japanischen Video den Link-Weg — und Open
|
||||||
|
Notebook legt wieder eine leere Quelle an. Also fragen wir genau die Bibliothek,
|
||||||
|
die im Container auch die Entscheidung trifft.
|
||||||
|
|
||||||
|
Nicht-YouTube-Quellen: leere Liste — fuer die kennt content-core keinen
|
||||||
|
Untertitel-Weg, da fuehrt ohnehin nur die Tonspur zum Ziel.
|
||||||
|
"""
|
||||||
|
m = YOUTUBE_ID.search(url)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
probe_code = (
|
||||||
|
"from youtube_transcript_api import YouTubeTranscriptApi as A;"
|
||||||
|
f"print(' '.join(t.language_code for t in A().list('{m.group(1)}')))"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
out = run(["docker", "compose", "exec", "-T", "open_notebook",
|
||||||
|
"/app/.venv/bin/python", "-c", probe_code], cwd=REPO)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return [] # kein Transkript vorhanden (oder Abruf blockiert) -> Tonspur
|
||||||
|
have = out.stdout.split()
|
||||||
|
return [lang for lang in CAPTION_LANGS
|
||||||
|
if any(h == lang or h.startswith(f"{lang}-") for h in have)]
|
||||||
|
|
||||||
|
|
||||||
|
def download_audio(url: str, dest_dir: Path, stem: str) -> Path:
|
||||||
|
"""Tonspur laden. `stem` macht den Dateinamen eindeutig — Open Notebook legt den
|
||||||
|
Upload unter seinem Originalnamen in data/uploads/ ab, ein fixer Name wie
|
||||||
|
"audio.mp3" wuerde beim naechsten Video kollidieren."""
|
||||||
|
print("Lade Tonspur (yt-dlp)...")
|
||||||
|
run([
|
||||||
|
"yt-dlp", "-x", "--audio-format", "mp3", "--audio-quality", "5",
|
||||||
|
"--postprocessor-args", f"ExtractAudio:{AUDIO_POSTPROC}",
|
||||||
|
"--no-warnings", "--no-playlist",
|
||||||
|
"-o", str(dest_dir / f"{stem}.%(ext)s"), url,
|
||||||
|
])
|
||||||
|
files = list(dest_dir.glob(f"{stem}.mp3"))
|
||||||
|
if not files:
|
||||||
|
raise SystemExit(die("yt-dlp hat keine Audiodatei erzeugt."))
|
||||||
|
f = files[0]
|
||||||
|
print(f" {f.name}: {f.stat().st_size / 1e6:.1f} MB")
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def post_source(fields: dict[str, str], file: Path | None = None) -> dict:
|
||||||
|
"""POST /api/sources als multipart/form-data (das erwartet die API)."""
|
||||||
|
cmd = ["curl", "-sS", "-X", "POST", f"{API}/api/sources"]
|
||||||
|
for k, v in fields.items():
|
||||||
|
cmd += ["-F", f"{k}={v}"]
|
||||||
|
if file is not None:
|
||||||
|
cmd += ["-F", f"file=@{file}"]
|
||||||
|
out = run(cmd).stdout
|
||||||
|
try:
|
||||||
|
return json.loads(out)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise SystemExit(die(f"Unerwartete Antwort der API: {out[:300]}"))
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_upload(filename: str) -> None:
|
||||||
|
"""Die hochgeladene MP3 nach dem Transkribieren wegraeumen.
|
||||||
|
|
||||||
|
Eigentlich ist dafuer das API-Flag delete_source=true gedacht — das ist in dieser
|
||||||
|
content-core-Version aber wirkungslos: extract_content verliert das Feld beim
|
||||||
|
Aufbau des Graph-State (der zurueckgegebene State zeigt delete_source=None), der
|
||||||
|
delete_file-Knoten sieht es nie und ueberspringt das Loeschen. Ohne diesen Schritt
|
||||||
|
blieben pro Video ein paar MB in notebook_data/uploads/ liegen, die niemand mehr
|
||||||
|
braucht — der Text steckt ja laengst in der Quelle.
|
||||||
|
|
||||||
|
Loeschen im Container, weil die Datei dort als root angelegt wird.
|
||||||
|
"""
|
||||||
|
path = f"/app/data/uploads/{filename}"
|
||||||
|
try:
|
||||||
|
run(["docker", "compose", "exec", "-T", "open_notebook", "rm", "-f", "--", path],
|
||||||
|
cwd=REPO)
|
||||||
|
print(f"Aufgeraeumt: uploads/{filename}")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Hinweis: uploads/{filename} konnte nicht geloescht werden "
|
||||||
|
f"({e.stderr.strip()[:100]}) — stoert nicht, belegt nur Platz.",
|
||||||
|
file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_content(source_id: str, timeout_s: int) -> dict | None:
|
||||||
|
"""Auf die asynchrone Verarbeitung warten (Transkription kann Minuten dauern)."""
|
||||||
|
quoted = urllib.parse.quote(source_id, safe="")
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
last = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
last = api_get(f"/api/sources/{quoted}")
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
pass
|
||||||
|
if last:
|
||||||
|
text = last.get("full_text") or ""
|
||||||
|
if text.strip():
|
||||||
|
return last
|
||||||
|
print(" ... wird verarbeitet", end="\r", flush=True)
|
||||||
|
time.sleep(10)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("url")
|
||||||
|
ap.add_argument("--notebook", help="Notebook-ID (Default: das einzige vorhandene)")
|
||||||
|
ap.add_argument("--language", help="Sprache fuers Transkribieren erzwingen, z.B. de")
|
||||||
|
ap.add_argument("--force-audio", action="store_true",
|
||||||
|
help="Untertitel ignorieren und immer die Tonspur transkribieren")
|
||||||
|
ap.add_argument("--timeout", type=int, default=1800, metavar="S",
|
||||||
|
help="Wartezeit auf die Verarbeitung (Default: 1800s)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
notebooks = api_get("/api/notebooks")
|
||||||
|
except (urllib.error.URLError, TimeoutError) as e:
|
||||||
|
return die(f"Open-Notebook-API unter {API} nicht erreichbar ({e}).")
|
||||||
|
|
||||||
|
notebook_id = args.notebook
|
||||||
|
if not notebook_id:
|
||||||
|
if len(notebooks) != 1:
|
||||||
|
return die("Mehrere (oder keine) Notebooks vorhanden — bitte --notebook angeben:\n"
|
||||||
|
+ "\n".join(f" {n['id']} {n.get('name')}" for n in notebooks))
|
||||||
|
notebook_id = notebooks[0]["id"]
|
||||||
|
print(f"Notebook: {notebooks[0].get('name')} ({notebook_id})")
|
||||||
|
|
||||||
|
meta = probe(args.url)
|
||||||
|
title = meta.get("title") or args.url
|
||||||
|
dur = meta.get("duration")
|
||||||
|
langs = [] if args.force_audio else caption_langs(args.url)
|
||||||
|
print(f"Video : {title}" + (f" ({dur // 60}:{dur % 60:02d} min)" if dur else ""))
|
||||||
|
|
||||||
|
use_audio = args.force_audio or not langs
|
||||||
|
if args.force_audio:
|
||||||
|
print("Untertitel werden ignoriert (--force-audio) — Tonspur wird transkribiert.")
|
||||||
|
elif langs:
|
||||||
|
print(f"Untertitel vorhanden ({', '.join(langs)}) — nutze den direkten Weg.")
|
||||||
|
else:
|
||||||
|
print("Keine brauchbaren Untertitel — Tonspur wird transkribiert.")
|
||||||
|
|
||||||
|
common = {
|
||||||
|
"notebook_id": notebook_id,
|
||||||
|
"embed": "true",
|
||||||
|
"async_processing": "true",
|
||||||
|
"title": title,
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded_name: str | None = None
|
||||||
|
if not use_audio:
|
||||||
|
src = post_source({**common, "type": "link", "url": args.url})
|
||||||
|
else:
|
||||||
|
stem = meta.get("id") or "video"
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
audio = download_audio(args.url, Path(td), stem)
|
||||||
|
print("Lade zu Open Notebook hoch; die Transkription laeuft dort auf dem "
|
||||||
|
"lokalen Whisper-Server...")
|
||||||
|
src = post_source({**common, "type": "upload"}, file=audio)
|
||||||
|
uploaded_name = audio.name
|
||||||
|
|
||||||
|
source_id = src.get("id")
|
||||||
|
if not source_id:
|
||||||
|
return die(f"Quelle wurde nicht angelegt: {json.dumps(src)[:300]}")
|
||||||
|
print(f"Quelle angelegt: {source_id}")
|
||||||
|
|
||||||
|
done = wait_for_content(source_id, args.timeout)
|
||||||
|
if not done:
|
||||||
|
return die(f"Nach {args.timeout}s kein Inhalt. Status pruefen mit:\n"
|
||||||
|
f" docker compose logs --tail 50 open_notebook")
|
||||||
|
|
||||||
|
text = done.get("full_text") or ""
|
||||||
|
print(f"\nFertig: {len(text)} Zeichen Inhalt.")
|
||||||
|
print(f"Anfang: {text[:160].strip()}...")
|
||||||
|
|
||||||
|
if uploaded_name:
|
||||||
|
cleanup_upload(uploaded_name)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
57
scripts/check_updates.sh
Executable file
57
scripts/check_updates.sh
Executable file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Zeigt, ob es ein neues Open-Notebook-Release gibt. Aendert NICHTS.
|
||||||
|
#
|
||||||
|
# Gedacht fuer den Sitzungsstart: Du siehst, worauf du laeufst und ob etwas Neues da
|
||||||
|
# ist — und entscheidest selbst, wann du migrierst (./scripts/update_stack.sh).
|
||||||
|
# Bewusst kein Auto-Update: ein Image-Update migriert die Datenbank und kann unsere
|
||||||
|
# Anpassungen brechen (siehe scripts/smoke_test.sh).
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO"
|
||||||
|
|
||||||
|
TAG="lfnovo/open_notebook:v1-latest"
|
||||||
|
|
||||||
|
# Gezielt aus der open_notebook-Zeile: ein blindes grep auf den ersten Digest der Datei
|
||||||
|
# erwischt surrealdb (steht weiter oben) und vergleicht dann Aepfel mit Birnen.
|
||||||
|
pinned="$(grep -oP 'lfnovo/open_notebook\S*@\Ksha256:[0-9a-f]+' docker-compose.yml | head -1)"
|
||||||
|
running_ver="$(docker compose exec -T open_notebook grep -m1 '^version' /app/pyproject.toml 2>/dev/null |
|
||||||
|
grep -oP '(?<=")[^"]+' || echo '?')"
|
||||||
|
|
||||||
|
echo "Laeuft gerade"
|
||||||
|
echo " Version : ${running_ver}"
|
||||||
|
echo " Digest : ${pinned:-(nicht gepinnt!)}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Verfuegbar"
|
||||||
|
latest_rel="$(curl -sf -m 10 https://api.github.com/repos/lfnovo/open-notebook/releases/latest |
|
||||||
|
python3 -c "import json,sys;d=json.load(sys.stdin);print(d['tag_name'],'vom',d['published_at'][:10])" 2>/dev/null)"
|
||||||
|
echo " Release : ${latest_rel:-(GitHub nicht erreichbar)}"
|
||||||
|
|
||||||
|
remote="$(docker buildx imagetools inspect "$TAG" --format '{{.Manifest.Digest}}' 2>/dev/null)"
|
||||||
|
if [ -z "$remote" ]; then
|
||||||
|
echo " Image : (Registry nicht erreichbar)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo " Image : $remote"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$remote" = "$pinned" ]; then
|
||||||
|
echo "Du laeufst auf dem aktuellen Release. Nichts zu tun."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
NEUES IMAGE VERFUEGBAR.
|
||||||
|
|
||||||
|
gepinnt : $pinned
|
||||||
|
neu : $remote
|
||||||
|
|
||||||
|
Update mit Backup, Rauchtest und Rollback:
|
||||||
|
./scripts/update_stack.sh
|
||||||
|
|
||||||
|
Vorher lohnt ein Blick in die Release-Notes — vor allem auf Aenderungen an den
|
||||||
|
Podcast-Vorlagen, an content-core und an der Datenbank:
|
||||||
|
https://github.com/lfnovo/open-notebook/releases
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
209
scripts/prune_podcast_data.py
Executable file
209
scripts/prune_podcast_data.py
Executable file
|
|
@ -0,0 +1,209 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Raeumt Podcast-Daten auf, die Open Notebook selbst liegen laesst.
|
||||||
|
|
||||||
|
Zwei Arten von Muell entstehen unter notebook_data/podcasts/episodes/<uuid>/:
|
||||||
|
|
||||||
|
1. Verwaiste Episodenordner. Beim Loeschen einer Episode entfernt Open Notebook
|
||||||
|
nur die finale MP3 (api/routers/podcasts.py: audio_path.unlink()), nicht den
|
||||||
|
Ordner mit clips/, outline.json und transcript.json. Auch fehlgeschlagene und
|
||||||
|
per /retry ersetzte Laeufe lassen ihren Ordner zurueck.
|
||||||
|
|
||||||
|
2. clips/ fertiger Episoden. Die Einzelclips sind nur Zwischenprodukt; sobald die
|
||||||
|
finale MP3 existiert, werden sie nicht mehr gebraucht.
|
||||||
|
|
||||||
|
Standardmaessig Trockenlauf — es wird nur angezeigt, was wegfiele. Erst mit --yes
|
||||||
|
wird geloescht.
|
||||||
|
|
||||||
|
./scripts/prune_podcast_data.py # anzeigen
|
||||||
|
./scripts/prune_podcast_data.py --yes # loeschen
|
||||||
|
./scripts/prune_podcast_data.py --keep-clips --yes # nur Verwaiste loeschen
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
API = "http://127.0.0.1:5055"
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
EPISODES_DIR = REPO / "notebook_data" / "podcasts" / "episodes"
|
||||||
|
|
||||||
|
# Pfade in der DB sind Container-Pfade (/app/data/...), auf dem Host liegen sie
|
||||||
|
# unter notebook_data/ (Bind-Mount aus docker-compose.yml).
|
||||||
|
CONTAINER_PREFIX = "/app/data/"
|
||||||
|
HOST_PREFIX = REPO / "notebook_data"
|
||||||
|
|
||||||
|
# Ein laufender Job hat noch keine audio_file, sein Ordner ist also nicht als
|
||||||
|
# "in Benutzung" erkennbar. Frisch angefasste Ordner deshalb grundsaetzlich in
|
||||||
|
# Ruhe lassen.
|
||||||
|
DEFAULT_MIN_AGE_MIN = 60
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg: str) -> None:
|
||||||
|
print(f"FEHLER: {msg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_episodes() -> list[dict]:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{API}/api/podcasts/episodes", timeout=10) as r:
|
||||||
|
return json.load(r)
|
||||||
|
except (urllib.error.URLError, TimeoutError) as e:
|
||||||
|
die(f"API unter {API} nicht erreichbar ({e}). Laeuft der Container? "
|
||||||
|
"Ohne die Episodenliste laesst sich nicht sicher unterscheiden, "
|
||||||
|
"welche Ordner noch gebraucht werden.")
|
||||||
|
|
||||||
|
|
||||||
|
def to_host_path(audio_file: str) -> Path | None:
|
||||||
|
"""Container-Pfad der DB -> Pfad auf dem Host."""
|
||||||
|
if audio_file.startswith("file://"):
|
||||||
|
audio_file = audio_file[len("file://"):]
|
||||||
|
if audio_file.startswith(CONTAINER_PREFIX):
|
||||||
|
return HOST_PREFIX / audio_file[len(CONTAINER_PREFIX):]
|
||||||
|
p = Path(audio_file)
|
||||||
|
return p if p.is_absolute() and p.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def to_container_path(host_path: Path) -> str:
|
||||||
|
"""Pfad auf dem Host -> Container-Pfad (Umkehrung von to_host_path)."""
|
||||||
|
rel = host_path.resolve().relative_to(HOST_PREFIX.resolve())
|
||||||
|
return CONTAINER_PREFIX + str(rel)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_in_container(paths: list[Path]) -> None:
|
||||||
|
"""Loescht im Container statt auf dem Host.
|
||||||
|
|
||||||
|
Der open_notebook-Container laeuft als root und legt die Episodenordner
|
||||||
|
entsprechend root:root an — der Host-User darf sie nicht entfernen. Statt
|
||||||
|
dafuer sudo zu verlangen, loeschen wir dort, wo die Rechte ohnehin stimmen.
|
||||||
|
"""
|
||||||
|
targets = [to_container_path(p) for p in paths]
|
||||||
|
guard = CONTAINER_PREFIX + "podcasts/episodes/"
|
||||||
|
for t in targets: # Sicherheitsnetz gegen Pfade ausserhalb der Episoden.
|
||||||
|
if not t.startswith(guard):
|
||||||
|
die(f"Abbruch: {t} liegt ausserhalb von {guard}")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "compose", "exec", "-T", "open_notebook", "rm", "-rf", "--", *targets],
|
||||||
|
cwd=REPO, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dir_size_mb(path: Path) -> float:
|
||||||
|
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1e6
|
||||||
|
|
||||||
|
|
||||||
|
def age_minutes(path: Path) -> float:
|
||||||
|
newest = max((f.stat().st_mtime for f in path.rglob("*")), default=path.stat().st_mtime)
|
||||||
|
return (time.time() - newest) / 60
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--yes", action="store_true",
|
||||||
|
help="wirklich loeschen (ohne dies nur Trockenlauf)")
|
||||||
|
ap.add_argument("--keep-clips", action="store_true",
|
||||||
|
help="clips/ fertiger Episoden behalten, nur verwaiste Ordner loeschen")
|
||||||
|
ap.add_argument("--min-age", type=int, default=DEFAULT_MIN_AGE_MIN, metavar="MIN",
|
||||||
|
help=f"Ordner juenger als MIN Minuten nie anfassen (Default: {DEFAULT_MIN_AGE_MIN})")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not EPISODES_DIR.is_dir():
|
||||||
|
print(f"Nichts zu tun: {EPISODES_DIR} existiert nicht.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
episodes = fetch_episodes()
|
||||||
|
|
||||||
|
# Laufende Jobs schreiben gerade in ihren Ordner, den wir nicht zuordnen
|
||||||
|
# koennen (noch keine audio_file). Dann lieber gar nichts loeschen.
|
||||||
|
running = [e for e in episodes if e.get("job_status") in ("running", "pending", "submitted")]
|
||||||
|
if running:
|
||||||
|
die(f"{len(running)} Podcast-Job(s) laufen gerade "
|
||||||
|
f"({', '.join(e.get('name') or e['id'] for e in running)}). "
|
||||||
|
"Bitte warten, bis sie fertig sind — sonst wuerde ihr Arbeitsverzeichnis geloescht.")
|
||||||
|
|
||||||
|
# Ordner, die zu einer existierenden Episode gehoeren.
|
||||||
|
live: dict[Path, dict] = {}
|
||||||
|
for e in episodes:
|
||||||
|
af = e.get("audio_file")
|
||||||
|
if not af:
|
||||||
|
continue
|
||||||
|
host = to_host_path(af)
|
||||||
|
if host:
|
||||||
|
live[host.parent.parent.resolve()] = e # <uuid>/audio/<uuid>.mp3 -> <uuid>/
|
||||||
|
|
||||||
|
orphans: list[tuple[Path, float]] = []
|
||||||
|
clip_dirs: list[tuple[Path, float, str]] = []
|
||||||
|
skipped_young: list[str] = []
|
||||||
|
|
||||||
|
for d in sorted(EPISODES_DIR.iterdir()):
|
||||||
|
if not d.is_dir():
|
||||||
|
continue
|
||||||
|
d = d.resolve()
|
||||||
|
if d.parent != EPISODES_DIR.resolve(): # Symlink-Ausbruch o.ae.
|
||||||
|
continue
|
||||||
|
if age_minutes(d) < args.min_age:
|
||||||
|
skipped_young.append(d.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
ep = live.get(d)
|
||||||
|
if ep is None:
|
||||||
|
orphans.append((d, dir_size_mb(d)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
clips = d / "clips"
|
||||||
|
final_mp3 = to_host_path(ep["audio_file"])
|
||||||
|
if not args.keep_clips and clips.is_dir() and final_mp3 and final_mp3.exists():
|
||||||
|
n = len(list(clips.glob("*")))
|
||||||
|
if n:
|
||||||
|
clip_dirs.append((clips, dir_size_mb(clips), ep.get("name") or ep["id"]))
|
||||||
|
|
||||||
|
if orphans:
|
||||||
|
print(f"Verwaiste Episodenordner (kein DB-Eintrag mehr) — {len(orphans)}:")
|
||||||
|
for d, mb in orphans:
|
||||||
|
print(f" {d.name} {mb:6.1f} MB")
|
||||||
|
if clip_dirs:
|
||||||
|
print(f"\nZwischenclips fertiger Episoden (finale MP3 existiert) — {len(clip_dirs)}:")
|
||||||
|
for c, mb, name in clip_dirs:
|
||||||
|
print(f" {c.parent.name}/clips {mb:6.1f} MB ({name})")
|
||||||
|
if skipped_young:
|
||||||
|
print(f"\nUebersprungen (juenger als {args.min_age} min): {', '.join(skipped_young)}")
|
||||||
|
|
||||||
|
total = sum(mb for _, mb in orphans) + sum(mb for _, mb, _ in clip_dirs)
|
||||||
|
if not orphans and not clip_dirs:
|
||||||
|
print("Nichts aufzuraeumen.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"\nFreizugeben: {total:.1f} MB")
|
||||||
|
if not args.yes:
|
||||||
|
print("Trockenlauf — nichts geloescht. Mit --yes wirklich loeschen.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
victims = [d for d, _ in orphans] + [c for c, _, _ in clip_dirs]
|
||||||
|
try:
|
||||||
|
delete_in_container(victims)
|
||||||
|
except FileNotFoundError:
|
||||||
|
die("docker nicht gefunden — das Loeschen laeuft im Container "
|
||||||
|
"(die Ordner gehoeren root).")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
die(f"Loeschen im Container fehlgeschlagen: {e.stderr.decode().strip()}")
|
||||||
|
|
||||||
|
for d, _ in orphans:
|
||||||
|
print(f"geloescht: {d.name}")
|
||||||
|
for c, _, _ in clip_dirs:
|
||||||
|
print(f"geloescht: {c.parent.name}/clips")
|
||||||
|
|
||||||
|
rest = [p for p in victims if p.exists()]
|
||||||
|
if rest:
|
||||||
|
die(f"{len(rest)} Pfad(e) existieren noch: {', '.join(p.name for p in rest)}")
|
||||||
|
print(f"\n{total:.1f} MB freigegeben.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
161
scripts/smoke_test.sh
Executable file
161
scripts/smoke_test.sh
Executable file
|
|
@ -0,0 +1,161 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rauchtest fuer den laufenden Stack.
|
||||||
|
#
|
||||||
|
# Prueft nicht "laeuft der Container", sondern genau die Dinge, die bei diesem
|
||||||
|
# Deployment schon einmal kaputt waren — inklusive der Annahmen, auf denen unsere
|
||||||
|
# Anpassungen beruhen. Genau die koennen ein Image-Update stillschweigend brechen:
|
||||||
|
# der Bind-Mount ueber prompts/podcast/ verdeckt neue Dateien, config/content_core.yaml
|
||||||
|
# friert alte Defaults ein (CCORE_CONFIG_PATH ersetzt, statt zu mergen), und die
|
||||||
|
# Env-Var-Workarounds haengen an Interna von content-core/esperanto/podcast_creator.
|
||||||
|
#
|
||||||
|
# ./scripts/smoke_test.sh # alles
|
||||||
|
# SKIP_NET=1 ./scripts/smoke_test.sh # ohne Tests, die ins Internet muessen
|
||||||
|
#
|
||||||
|
# Exit 0 = alles gruen. Exit 1 = mindestens ein Test rot.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO"
|
||||||
|
|
||||||
|
API="http://127.0.0.1:5055"
|
||||||
|
IMAGE="$(grep -oP '(?<=image: )lfnovo/open_notebook\S+' docker-compose.yml | head -1)"
|
||||||
|
PY=/app/.venv/bin/python
|
||||||
|
fails=0
|
||||||
|
|
||||||
|
ok() { printf ' \033[32mOK\033[0m %s\n' "$1"; }
|
||||||
|
fail() { printf ' \033[31mFEHL\033[0m %s\n' "$1"; fails=$((fails + 1)); }
|
||||||
|
sec() { printf '\n%s\n' "$1"; }
|
||||||
|
|
||||||
|
inapp() { docker compose exec -T open_notebook "$@" 2>/dev/null; }
|
||||||
|
|
||||||
|
sec "Grundfunktion"
|
||||||
|
|
||||||
|
if curl -sf -m 10 "$API/api/models/providers" -o /tmp/.st_prov 2>/dev/null; then
|
||||||
|
if grep -q ollama /tmp/.st_prov && grep -q openrouter /tmp/.st_prov; then
|
||||||
|
ok "API erreichbar, Provider ollama + openrouter verfuegbar"
|
||||||
|
else
|
||||||
|
fail "API erreichbar, aber Provider fehlen: $(cat /tmp/.st_prov)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "API $API nicht erreichbar"
|
||||||
|
fi
|
||||||
|
rm -f /tmp/.st_prov
|
||||||
|
|
||||||
|
sec "Lokale Sprachdienste (vom Container aus)"
|
||||||
|
|
||||||
|
if inapp "$PY" -c "
|
||||||
|
import httpx,sys
|
||||||
|
r = httpx.post('http://host.docker.internal:8901/audio/speech',
|
||||||
|
json={'model':'chatterbox','voice':'default','input':'Test.','response_format':'wav'}, timeout=180)
|
||||||
|
sys.exit(0 if r.status_code == 200 and len(r.content) > 1000 else 1)"; then
|
||||||
|
ok "TTS antwortet mit Audio (host.docker.internal:8901)"
|
||||||
|
else
|
||||||
|
fail "TTS liefert kein Audio — Dienst tot (ConnectError) oder VRAM voll (HTTP 500)?
|
||||||
|
systemctl --user status open-notebook-tts; journalctl --user -u open-notebook-tts -n 30"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if inapp "$PY" -c "
|
||||||
|
import httpx,sys
|
||||||
|
r = httpx.get('http://host.docker.internal:8902/health', timeout=30)
|
||||||
|
sys.exit(0 if r.status_code == 200 else 1)"; then
|
||||||
|
ok "STT erreichbar (host.docker.internal:8902)"
|
||||||
|
else
|
||||||
|
fail "STT nicht erreichbar — systemctl --user status open-notebook-stt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sec "Annahmen unserer Anpassungen (brechen leise bei Image-Updates)"
|
||||||
|
|
||||||
|
# 1) prompts/podcast: Verzeichnis-Mount verdeckt das Image-Verzeichnis komplett.
|
||||||
|
# Kommt upstream eine dritte Vorlage dazu, waere sie unsichtbar.
|
||||||
|
img_tpl="$(docker run --rm --entrypoint ls "$IMAGE" /app/prompts/podcast 2>/dev/null | sort | tr '\n' ' ')"
|
||||||
|
our_tpl="$(ls prompts/podcast | sort | tr '\n' ' ')"
|
||||||
|
if [ "$img_tpl" = "$our_tpl" ]; then
|
||||||
|
ok "prompts/podcast: Dateiliste deckt sich mit dem Image ($our_tpl)"
|
||||||
|
else
|
||||||
|
fail "prompts/podcast weicht ab — unser Mount verdeckt Image-Dateien!
|
||||||
|
Image: $img_tpl
|
||||||
|
wir : $our_tpl"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) Unsere Patches in den Vorlagen sind noch drin.
|
||||||
|
if grep -q '{{ language }}' prompts/podcast/outline.jinja prompts/podcast/transcript.jinja &&
|
||||||
|
head -1 prompts/podcast/outline.jinja | grep -q '/no_think'; then
|
||||||
|
ok "Vorlagen enthalten Sprachanweisung ({{ language }}) und /no_think"
|
||||||
|
else
|
||||||
|
fail "Vorlagen-Patches fehlen — Podcasts kaemen wieder auf Englisch / mit kaputtem JSON"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) podcast_creator reicht `language` ueberhaupt noch in die Vorlagen durch.
|
||||||
|
if inapp grep -q "language" /app/.venv/lib/python3.12/site-packages/podcast_creator/nodes.py; then
|
||||||
|
ok "podcast_creator kennt weiterhin ein language-Feld"
|
||||||
|
else
|
||||||
|
fail "podcast_creator reicht kein language mehr durch — Vorlagen-Patch wirkungslos"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4) TTS_BATCH_SIZE existiert noch als Stellschraube (sonst wieder 5 parallele Clips -> OOM).
|
||||||
|
if inapp grep -q "TTS_BATCH_SIZE" /app/.venv/lib/python3.12/site-packages/podcast_creator/nodes.py; then
|
||||||
|
ok "podcast_creator liest weiterhin TTS_BATCH_SIZE"
|
||||||
|
else
|
||||||
|
fail "TTS_BATCH_SIZE wird nicht mehr gelesen — Gefahr paralleler Clips (CUDA OOM)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5) content-core sucht YouTube-Untertitel weiterhin ueber preferred_languages.
|
||||||
|
if inapp grep -q "preferred_languages" /app/.venv/lib/python3.12/site-packages/content_core/processors/youtube.py; then
|
||||||
|
ok "content-core nutzt weiterhin preferred_languages (deutsche Untertitel)"
|
||||||
|
else
|
||||||
|
fail "content-core sucht Untertitel anders — config/content_core.yaml pruefen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6) content-core baut das STT-Modell weiterhin ohne base_url (deshalb die Env-Var).
|
||||||
|
if inapp grep -q "OPENAI_COMPATIBLE_BASE_URL" /app/.venv/lib/python3.12/site-packages/esperanto/providers/stt/openai_compatible.py; then
|
||||||
|
ok "esperanto akzeptiert weiterhin OPENAI_COMPATIBLE_BASE_URL_STT (Audio-Transkription)"
|
||||||
|
else
|
||||||
|
fail "esperanto liest die Env-Var nicht mehr — Audio-Quellen fielen auf OpenAI zurueck"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7) CCORE_CONFIG_PATH ersetzt die Config komplett. Bringt ein Update neue
|
||||||
|
# Top-Level-Schluessel mit, frieren wir die alten ein — hier faellt das auf.
|
||||||
|
img_keys="$(docker run --rm --entrypoint "$PY" -e CCORE_CONFIG_PATH= "$IMAGE" \
|
||||||
|
-c "from content_core.config import load_config; print(' '.join(sorted(load_config())))" 2>/dev/null)"
|
||||||
|
our_keys="$(inapp "$PY" -c "
|
||||||
|
import yaml; print(' '.join(sorted(yaml.safe_load(open('/app/config/content_core.yaml')))))")"
|
||||||
|
missing="$(comm -23 <(tr ' ' '\n' <<<"$img_keys" | sort -u) <(tr ' ' '\n' <<<"$our_keys" | sort -u) | tr '\n' ' ')"
|
||||||
|
if [ -z "${missing// /}" ] && [ -n "$img_keys" ]; then
|
||||||
|
ok "config/content_core.yaml enthaelt alle Default-Schluessel des Images"
|
||||||
|
else
|
||||||
|
fail "config/content_core.yaml fehlen Schluessel aus dem Image: ${missing:-(Image-Defaults nicht lesbar)}
|
||||||
|
Neu erzeugen — siehe Kopfkommentar in config/content_core.yaml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 8) Episode-Profile haben eine Sprache (sonst wieder englische Podcasts).
|
||||||
|
if curl -sf -m 10 "$API/api/episode-profiles" | python3 -c "
|
||||||
|
import json,sys
|
||||||
|
p=json.load(sys.stdin)
|
||||||
|
sys.exit(0 if p and all(x.get('language') for x in p) else 1)" 2>/dev/null; then
|
||||||
|
ok "alle Episode-Profile haben ein language-Feld gesetzt"
|
||||||
|
else
|
||||||
|
fail "mindestens ein Episode-Profil ohne language — Podcast kaeme auf Englisch"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${SKIP_NET:-0}" != "1" ]; then
|
||||||
|
sec "Ende-zu-Ende (braucht Internet)"
|
||||||
|
if inapp "$PY" -c "
|
||||||
|
import asyncio,sys
|
||||||
|
from content_core import extract_content
|
||||||
|
async def m():
|
||||||
|
r = await extract_content({'url':'https://www.youtube.com/watch?v=hzxiegk9QAg'})
|
||||||
|
sys.exit(0 if len((r.content or '')) > 1000 else 1)
|
||||||
|
asyncio.run(m())"; then
|
||||||
|
ok "YouTube-Quelle liefert Transkript (deutsche Untertitel werden gefunden)"
|
||||||
|
else
|
||||||
|
fail "YouTube-Extraktion liefert keinen Text — CCORE_CONFIG_PATH/Sprachen pruefen"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
sec "Ergebnis"
|
||||||
|
if [ "$fails" -eq 0 ]; then
|
||||||
|
echo " Alles gruen."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo " $fails Test(s) fehlgeschlagen."
|
||||||
|
exit 1
|
||||||
|
|
@ -1,31 +1,57 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Startet die lokalen TTS/STT-Wrapper-Server (services/tts_server.py,
|
# Installiert und startet die lokalen TTS/STT-Wrapper-Server (services/tts_server.py,
|
||||||
# services/stt_server.py) als Hintergrundprozesse auf GPU 2. Überspringt
|
# services/stt_server.py) als systemd-User-Dienste auf GPU 2.
|
||||||
# einen Service, falls er unter seinem Port bereits antwortet.
|
#
|
||||||
|
# Frueher liefen die beiden als nohup-Hintergrundprozesse — die ueberleben keinen
|
||||||
|
# Reboot, und ein Podcast scheitert dann in der Vertonungsphase mit
|
||||||
|
# "Failed to generate speech: All connection attempts failed". Als systemd-Units
|
||||||
|
# starten sie automatisch mit (dank enable-linger auch ohne Login).
|
||||||
|
#
|
||||||
|
# Idempotent: mehrfach ausfuehrbar, aktualisiert die Units und startet sie neu.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../services" && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
mkdir -p "$DIR/logs"
|
UNIT_SRC="$REPO/services/systemd"
|
||||||
cd "$DIR"
|
UNIT_DST="$HOME/.config/systemd/user"
|
||||||
|
UNITS=(open-notebook-tts.service open-notebook-stt.service)
|
||||||
|
|
||||||
start_if_needed() {
|
mkdir -p "$UNIT_DST"
|
||||||
local name="$1" port="$2" cmd="$3"
|
for unit in "${UNITS[@]}"; do
|
||||||
if curl -sf -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then
|
install -m 644 "$UNIT_SRC/$unit" "$UNIT_DST/$unit"
|
||||||
echo "$name läuft bereits auf Port $port"
|
done
|
||||||
return
|
systemctl --user daemon-reload
|
||||||
fi
|
|
||||||
echo "Starte $name auf Port $port..."
|
# Ohne Linger laufen User-Dienste nur waehrend einer aktiven Login-Session und
|
||||||
eval "$cmd"
|
# starten nicht beim Boot.
|
||||||
disown
|
if [ "$(loginctl show-user "$USER" --property=Linger --value 2>/dev/null)" != "yes" ]; then
|
||||||
for _ in $(seq 1 15); do
|
echo "Aktiviere linger (Dienste sollen auch ohne Login laufen)..."
|
||||||
curl -sf -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && { echo "$name ist bereit."; return; }
|
loginctl enable-linger "$USER"
|
||||||
sleep 2
|
fi
|
||||||
|
|
||||||
|
echo "Starte TTS/STT-Dienste..."
|
||||||
|
systemctl --user enable --quiet "${UNITS[@]}"
|
||||||
|
systemctl --user restart "${UNITS[@]}"
|
||||||
|
|
||||||
|
# Modelle werden beim Start geladen (whisper large-v3 braucht ~1 Minute).
|
||||||
|
wait_healthy() {
|
||||||
|
local name="$1" port="$2"
|
||||||
|
for _ in $(seq 1 45); do
|
||||||
|
if curl -sf -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then
|
||||||
|
echo " $name (Port $port) ist bereit."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 4
|
||||||
done
|
done
|
||||||
echo "WARNUNG: $name antwortet nach 30s nicht auf /health — siehe logs/${name}.log" >&2
|
echo "WARNUNG: $name antwortet nach 180s nicht auf /health." >&2
|
||||||
|
echo " Logs: journalctl --user -u open-notebook-${name} -n 50" >&2
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
start_if_needed "tts" 8901 \
|
rc=0
|
||||||
'CUDA_VISIBLE_DEVICES=2 TTS_PORT=8901 nohup ~/miniforge3/envs/chatterbox/bin/python tts_server.py > logs/tts_server.log 2>&1 &'
|
wait_healthy tts 8901 || rc=1
|
||||||
|
wait_healthy stt 8902 || rc=1
|
||||||
|
|
||||||
start_if_needed "stt" 8902 \
|
if [ "$rc" -eq 0 ]; then
|
||||||
'CUDA_VISIBLE_DEVICES=2 STT_PORT=8902 nohup python3 stt_server.py > logs/stt_server.log 2>&1 &'
|
echo "Fertig. Status: systemctl --user status ${UNITS[*]}"
|
||||||
|
fi
|
||||||
|
exit "$rc"
|
||||||
|
|
|
||||||
104
scripts/update_stack.sh
Executable file
104
scripts/update_stack.sh
Executable file
|
|
@ -0,0 +1,104 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Aktualisiert Open Notebook auf das neueste veroeffentlichte Image — mit Rueckfallpunkt.
|
||||||
|
#
|
||||||
|
# Ablauf:
|
||||||
|
# 1. Neuen Digest aus der Registry holen (nichts zu tun, wenn schon aktuell).
|
||||||
|
# 2. Stack stoppen und Daten sichern (surreal_data + notebook_data + docker-compose.yml).
|
||||||
|
# 3. Digest in docker-compose.yml eintragen, Stack starten.
|
||||||
|
# 4. Rauchtest (scripts/smoke_test.sh).
|
||||||
|
# 5. Rot -> alles zurueckrollen (Daten UND Compose-Datei). Gruen -> fertig.
|
||||||
|
#
|
||||||
|
# Der Rollback der Daten ist der Grund fuer das Backup: Open Notebook migriert die
|
||||||
|
# SurrealDB beim Start, und ein Downgrade der Anwendung kann eine migrierte Datenbank
|
||||||
|
# nicht wieder lesen. Ohne Backup waere ein missgluecktes Update eine Einbahnstrasse.
|
||||||
|
#
|
||||||
|
# ./scripts/update_stack.sh # auf neuestes Image
|
||||||
|
# ./scripts/update_stack.sh --dry-run # nur zeigen, was passieren wuerde
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO"
|
||||||
|
|
||||||
|
TAG="lfnovo/open_notebook:v1-latest"
|
||||||
|
BACKUP_DIR="$REPO/backups"
|
||||||
|
KEEP_BACKUPS=3
|
||||||
|
DRY_RUN=0
|
||||||
|
[ "${1:-}" = "--dry-run" ] && DRY_RUN=1
|
||||||
|
|
||||||
|
say() { printf '\n\033[1m%s\033[0m\n' "$1"; }
|
||||||
|
|
||||||
|
# Gezielt aus der open_notebook-Zeile. Ein blindes grep auf den ersten Digest der Datei
|
||||||
|
# erwischt surrealdb (steht weiter oben) — das sed weiter unten wuerde dann den falschen
|
||||||
|
# Digest ersetzen und die Datenbank austauschen statt der Anwendung.
|
||||||
|
pinned="$(grep -oP 'lfnovo/open_notebook\S*@\Ksha256:[0-9a-f]+' docker-compose.yml | head -1)"
|
||||||
|
[ -n "$pinned" ] || { echo "FEHLER: kein open_notebook-Digest in docker-compose.yml gepinnt." >&2; exit 1; }
|
||||||
|
|
||||||
|
say "1/5 Registry abfragen"
|
||||||
|
remote="$(docker buildx imagetools inspect "$TAG" --format '{{.Manifest.Digest}}')"
|
||||||
|
echo " gepinnt: $pinned"
|
||||||
|
echo " neu : $remote"
|
||||||
|
if [ "$remote" = "$pinned" ]; then
|
||||||
|
echo " Bereits aktuell — nichts zu tun."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$DRY_RUN" = 1 ]; then
|
||||||
|
echo " (--dry-run: hier wuerde das Update starten)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ts="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
backup="$BACKUP_DIR/$ts"
|
||||||
|
mkdir -p "$backup"
|
||||||
|
|
||||||
|
say "2/5 Stack stoppen und sichern -> backups/$ts"
|
||||||
|
docker compose down
|
||||||
|
cp docker-compose.yml "$backup/docker-compose.yml"
|
||||||
|
# Die Datenverzeichnisse gehoeren root (der Container schreibt als root), deshalb wird
|
||||||
|
# im Container gepackt statt auf dem Host — spart sudo.
|
||||||
|
docker run --rm -v "$REPO:/work" -w /work --entrypoint tar \
|
||||||
|
"lfnovo/open_notebook@$pinned" -czf "backups/$ts/data.tgz" surreal_data notebook_data
|
||||||
|
echo " $(du -sh "$backup/data.tgz" | cut -f1) gesichert"
|
||||||
|
|
||||||
|
restore() {
|
||||||
|
say "ROLLBACK"
|
||||||
|
docker compose down || true
|
||||||
|
cp "$backup/docker-compose.yml" docker-compose.yml
|
||||||
|
docker run --rm -v "$REPO:/work" -w /work --entrypoint sh \
|
||||||
|
"lfnovo/open_notebook@$pinned" -c \
|
||||||
|
"rm -rf surreal_data notebook_data && tar -xzf backups/$ts/data.tgz"
|
||||||
|
docker compose up -d
|
||||||
|
echo " Alter Stand ($pinned) ist wiederhergestellt, Daten aus backups/$ts."
|
||||||
|
echo " Das neue Image bleibt lokal liegen — Ursache pruefen, dann erneut versuchen."
|
||||||
|
}
|
||||||
|
|
||||||
|
say "3/5 Auf neues Image umstellen"
|
||||||
|
sed -i "s|@$pinned|@$remote|" docker-compose.yml
|
||||||
|
docker compose up -d
|
||||||
|
echo " gestartet, warte auf die API..."
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -sf -m 3 http://127.0.0.1:5055/api/models/providers >/dev/null 2>&1 && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
say "4/5 Rauchtest"
|
||||||
|
if ./scripts/smoke_test.sh; then
|
||||||
|
say "5/5 Fertig"
|
||||||
|
newver="$(docker compose exec -T open_notebook grep -m1 '^version' /app/pyproject.toml |
|
||||||
|
grep -oP '(?<=")[^"]+' || echo '?')"
|
||||||
|
echo " Update auf $newver erfolgreich, Rauchtest gruen."
|
||||||
|
echo " Backup: backups/$ts (bleibt liegen)"
|
||||||
|
echo
|
||||||
|
echo " Bitte den neuen Digest committen:"
|
||||||
|
echo " git add docker-compose.yml && git commit -m 'Update Open Notebook auf $newver'"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo " Rauchtest ROT — das Update wird zurueckgenommen."
|
||||||
|
restore
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Alte Backups aufraeumen (Platte ist knapp).
|
||||||
|
ls -1dt "$BACKUP_DIR"/*/ 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | while read -r old; do
|
||||||
|
echo " raeume altes Backup weg: $(basename "$old")"
|
||||||
|
rm -rf "$old"
|
||||||
|
done
|
||||||
21
services/systemd/open-notebook-stt.service
Normal file
21
services/systemd/open-notebook-stt.service
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Open Notebook STT (faster-whisper, OpenAI-compatible wrapper on :8902)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/open-notebook/services
|
||||||
|
ExecStart=%h/miniforge3/bin/python3 stt_server.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
Environment="STT_HOST=0.0.0.0"
|
||||||
|
Environment="STT_PORT=8902"
|
||||||
|
# GPU 2 eindeutig per UUID, NICHT per Index — siehe Kommentar in
|
||||||
|
# open-notebook-tts.service.
|
||||||
|
Environment="CUDA_VISIBLE_DEVICES=GPU-83ba6d1f-ebbe-f8c8-1433-153e0ebc3866"
|
||||||
|
Environment="PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True"
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
23
services/systemd/open-notebook-tts.service
Normal file
23
services/systemd/open-notebook-tts.service
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Open Notebook TTS (chatterbox, OpenAI-compatible wrapper on :8901)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/open-notebook/services
|
||||||
|
ExecStart=%h/miniforge3/envs/chatterbox/bin/python tts_server.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
Environment="TTS_HOST=0.0.0.0"
|
||||||
|
Environment="TTS_PORT=8901"
|
||||||
|
# GPU 2 eindeutig per UUID, NICHT per Index: CUDA sortiert standardmaessig
|
||||||
|
# "fastest first" statt nach PCI-Reihenfolge, dadurch kann Index 2 auf der
|
||||||
|
# T600 landen. UUID pruefen mit:
|
||||||
|
# nvidia-smi --query-gpu=index,name,uuid --format=csv
|
||||||
|
Environment="CUDA_VISIBLE_DEVICES=GPU-83ba6d1f-ebbe-f8c8-1433-153e0ebc3866"
|
||||||
|
Environment="PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True"
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
|
@ -25,6 +25,7 @@ import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path.home() / "chatterbox-tts-cli"))
|
sys.path.insert(0, str(Path.home() / "chatterbox-tts-cli"))
|
||||||
|
|
@ -40,6 +41,14 @@ app = FastAPI(title="Chatterbox TTS (OpenAI-compatible, voice cloning)", version
|
||||||
_DEVICE = tts.get_device(None)
|
_DEVICE = tts.get_device(None)
|
||||||
_model_cache: dict[str, tuple] = {}
|
_model_cache: dict[str, tuple] = {}
|
||||||
|
|
||||||
|
# Serializes model load + inference. FastAPI runs sync handlers in a threadpool, so
|
||||||
|
# without this every concurrent request generates in parallel on the same GPU and the
|
||||||
|
# activation memory multiplies — podcast_creator sends TTS_BATCH_SIZE (default 5) clips
|
||||||
|
# at once, which OOM'd this server (peak 16.7 GB) since GPU 2 is shared with the STT
|
||||||
|
# server and the chatterbox MCP service. Generation is GPU-bound, so serializing costs
|
||||||
|
# no real throughput; it just bounds peak VRAM to a single clip.
|
||||||
|
_GPU_LOCK = threading.Lock()
|
||||||
|
|
||||||
VOICES_DIR = Path(__file__).parent / "voices"
|
VOICES_DIR = Path(__file__).parent / "voices"
|
||||||
CLONE_LANG = "de" # language used for every cloned reference voice
|
CLONE_LANG = "de" # language used for every cloned reference voice
|
||||||
VOICE_LANG_OVERRIDES: dict[str, str] = {} # e.g. {"john": "en"} if you add an English clip
|
VOICE_LANG_OVERRIDES: dict[str, str] = {} # e.g. {"john": "en"} if you add an English clip
|
||||||
|
|
@ -124,12 +133,18 @@ def speech(req: SpeechRequest):
|
||||||
if not chunks:
|
if not chunks:
|
||||||
raise HTTPException(status_code=422, detail="Kein synthetisierbarer Text übrig.")
|
raise HTTPException(status_code=422, detail="Kein synthetisierbarer Text übrig.")
|
||||||
|
|
||||||
model, model_kind, sr = _get_model(lang)
|
with _GPU_LOCK:
|
||||||
|
model, model_kind, sr = _get_model(lang)
|
||||||
wavs = []
|
try:
|
||||||
for chunk in chunks:
|
wavs = []
|
||||||
wavs.append(tts.generate_chunk(model, model_kind, chunk, lang, voice_path))
|
for chunk in chunks:
|
||||||
final = wavs[0] if len(wavs) == 1 else torch.cat(wavs, dim=-1)
|
wavs.append(tts.generate_chunk(model, model_kind, chunk, lang, voice_path))
|
||||||
|
final = wavs[0] if len(wavs) == 1 else torch.cat(wavs, dim=-1)
|
||||||
|
finally:
|
||||||
|
# GPU 2 is shared; hand cached blocks back so a neighbour process can't be
|
||||||
|
# starved by fragmentation we're holding on to.
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as wav_tmp:
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as wav_tmp:
|
||||||
wav_path = wav_tmp.name
|
wav_path = wav_tmp.name
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue