open_notebook/BEDIENUNGSANLEITUNG.md
dschlueter 5b389fae58 Guard against stopping the stack while a podcast is running
Stopping the stack kills an in-flight podcast job, but its status stays "running"
in the database forever: it never finishes, it blocks prune_podcast_data.py (which
deliberately refuses to run while a job is active), and it cannot even be retried —
/retry only accepts episodes in state "failed".

I walked into this myself: I ran `docker compose down` for the non-root switch
without checking for running jobs, and killed a podcast that had already produced
37 clips.

update_stack.sh now refuses to start when a podcast job is running, and says why.
Verified against a genuinely running job: it aborts before touching the stack.

BEDIENUNGSANLEITUNG documents how to recover an existing zombie. The status does not
live on the episode but on the linked `command` record (the episode's own job_status
is null), so the fix is to set that record to 'failed' via SurrealDB, after which the
normal retry works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:29:14 +02:00

611 lines
27 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Bedienungsanleitung — Open Notebook (lokales Deployment)
Diese Anleitung deckt die **tägliche Benutzung** ab. Installation/Neuaufbau:
siehe [`README.md`](README.md). Technische Hintergründe und Architektur:
siehe [`CLAUDE.md`](CLAUDE.md).
---
## Inhalt
1. [Zugriff](#1-zugriff)
2. [Notebooks und Quellen](#2-notebooks-und-quellen)
3. [Chat: die drei Kontext-Modi](#3-chat-die-drei-kontext-modi)
4. [Podcasts erzeugen](#4-podcasts-erzeugen)
5. [Stimmklonung](#5-stimmklonung)
6. [Modelle und Provider verwalten](#6-modelle-und-provider-verwalten)
7. [Dienste starten/stoppen](#7-dienste-startenstoppen)
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)
---
## 1. Zugriff
- **Web-Oberfläche:** `http://localhost:8502`
- **REST-API** (für Automatisierung/Skripte): `http://localhost:5055`, Doku unter
`http://localhost:5055/docs`
Beide sind nur von diesem Rechner aus erreichbar (`127.0.0.1`), nicht aus dem Netzwerk.
---
## 2. Notebooks und Quellen
Ein **Notebook** bündelt Quellen, Notizen, Chats und Podcasts zu einem Thema. Neues Notebook
über „+ New Notebook" in der UI anlegen.
### Quellen hinzufügen
Unterstützt: hochgeladene Dateien (PDF, EPUB, TXT, DOCX, …), URLs, eingefügter Text.
**Wie lang darf eine Datei sein?** Es gibt kein hartes Limit für Upload/Verarbeitung — auch
sehr große PDFs/EPUBs werden vollständig extrahiert und in Textabschnitte („Chunks")
zerlegt, die für die Suche eingebettet werden. Eine Längenbegrenzung greift nur, wenn eine
Quelle im **„Volltext"-Kontextmodus** (siehe Abschnitt 3) oder für eine **Transformation**
verwendet wird — dort geht der komplette Text in einem Stück an das Sprachmodell. Praktischer
Richtwert bei `qwen3.5:27b` (98.304 Tokens Kontext, siehe Abschnitt 6): grob **180350
Buchseiten**, abhängig von Textdichte. Wichtig: Die **Datei­größe in MB sagt nichts über die
Textmenge aus** — ein gescanntes PDF mit vielen Bildern kann riesig sein, aber nach der
Texterkennung kaum Inhalt liefern.
### Verarbeitungsstatus prüfen
Nach dem Hochladen läuft die Verarbeitung (Extraktion → Chunking → Embedding) im Hintergrund.
Bei großen Dateien kann das ein bis mehrere Minuten dauern. Status ist in der UI an der Quelle
sichtbar; per API: `GET /api/sources/{source_id}/status`.
---
## 3. Chat: die drei Kontext-Modi
Beim Chatten mit einem Notebook lässt sich pro Quelle einstellen, wie viel davon in den
Chat-Kontext einfließt (Buttons/Auswahl in der UI, z. B. als Sammel-Aktion für alle Quellen):
| UI-Option | Bedeutung | Geschwindigkeit |
|---|---|---|
| **Alle aus dem Kontext ausschließen** | Quelle wird ignoriert | |
| **Alle aufnehmen (nur Erkenntnisse)** | Nutzt die generierten „Insights" (Zusammenfassungen) der Quelle | schnell (Sekunden) |
| **Alle aufnehmen (vollständiger Inhalt)** | Kompletter Text der Quelle geht an das Sprachmodell | langsam bei langen Dokumenten (bis zu einigen Minuten) |
### Wichtig: „nur Erkenntnisse" braucht vorher eine Transformation
Der Modus „nur Erkenntnisse" ist **nur dann nützlich, wenn für die Quelle bereits eine
Zusammenfassung („Insight") erzeugt wurde**. Ohne das liefert er praktisch nichts (nur Titel/
Metadaten). Eine Zusammenfassung erzeugen:
- In der UI: bei der Quelle eine Transformation anwenden, z. B. **„Simple Summary"**
(für die meisten Fälle passend) oder „Dense Summary", „Key Insights", „Table of Contents".
- Per API:
```bash
curl -X POST http://127.0.0.1:5055/api/sources/{source_id}/insights \
-H "Content-Type: application/json" \
-d '{"transformation_id": "<id aus GET /api/transformations>"}'
```
- Bei langen Dokumenten (z. B. ein ganzes Buch) dauert das einmalig **einige Minuten** (die
gesamte Quelle wird einmal durchs Sprachmodell geschickt) — danach ist „nur Erkenntnisse"
aber dauerhaft schnell nutzbar.
### Faustregel
- **Normale Fragen, Überblick, grobe Zusammenhänge** → „nur Erkenntnisse" (nach einmaliger
Zusammenfassung)
- **Sehr spezifische Detailfrage** (z. B. exaktes Zitat aus einem bestimmten Kapitel), die in
der Zusammenfassung fehlen könnte → „vollständiger Inhalt", dafür Geduld bei langen Quellen
- **Quelle für die aktuelle Frage irrelevant** → ausschließen (spart Zeit bei mehreren Quellen)
Für gezielte Detailsuche über viele/lange Quellen gibt es außerdem eine separate
**Such-/Ask-Funktion**, die per Embedding-Ähnlichkeitssuche automatisch nur die relevanten
Textstellen findet, statt ganze Dokumente einzubeziehen — meist die schnellste und treffsicherste
Option für Detailfragen in langen Werken.
---
## 4. Podcasts erzeugen
Open Notebook kann aus Notebook-Inhalten automatisch einen Podcast (Dialog zwischen ein oder
mehreren Sprechern) erzeugen — Text via Sprachmodell, Audio via TTS (hier: lokal, Chatterbox).
1. **Episode-Profil** wählen (bestimmt Sprecherzahl/-rollen, Anzahl Segmente, Textmodell):
`solo_expert` (1 Sprecher), `tech_discussion` (2 Sprecher), `business_analysis` (3 Sprecher).
2. Notebook/Quelle als Grundlage angeben.
3. Generierung starten — läuft asynchron (Outline → Transkript → Sprachsynthese pro Segment).
Bei mehreren Sprechern und langen Transkripten realistisch **mehrere Minuten**, da jedes
Dialogsegment einzeln per TTS synthetisiert wird.
4. Fertige Episode in der UI abspielbar/herunterladbar.
Alle Episode-/Sprecherprofile sind bereits auf lokale Modelle (`qwen3.5:27b` für Text,
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
Die TTS-Wrapper (`services/tts_server.py`) unterstützt Stimmklonung: Referenz-WAV rein, Stimme
raus. Aktuell hinterlegte Stimmen: `default` (eigene Stimme), `male_thorsten` und
`female_kerstin` (offen lizenzierte, synthetische Piper-Stimmen — bewusst **keine** ungefragt
geklonten echten Personen, siehe [`CLAUDE.md`](CLAUDE.md)).
### Eine weitere Stimme hinzufügen
1. Referenz-Aufnahme besorgen: **1030 Sekunden**, eine Person, saubere Aufnahme, WAV-Format.
Rechtlich sauber ist entweder eine eigene Aufnahme, eine Aufnahme mit ausdrücklicher
Zustimmung der Person, oder eine offen für diesen Zweck lizenzierte Stimme (z. B. weitere
[Piper-Stimmen](https://huggingface.co/rhasspy/piper-voices)).
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):
```bash
systemctl --user restart open-notebook-tts
```
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,
z. B. per API:
```bash
curl -X PUT http://127.0.0.1:5055/api/speaker-profiles/{id} \
-H "Content-Type: application/json" -d '{... "speakers": [{"name": "Elena", ..., "voice_id": "elena"}], ...}'
```
Mit Piper lässt sich auch selbst eine Referenz-Stimme synthetisieren (statt eine echte Aufnahme
zu verwenden):
```bash
echo "Ein Beispielsatz auf Deutsch." | piper --model <stimme>.onnx --output_file services/voices/<name>.wav
```
Weitere deutsche Piper-Stimmen: `huggingface.co/rhasspy/piper-voices/tree/main/de/de_DE`
(Lizenz jeweils im `MODEL_CARD` prüfen).
---
## 6. Modelle und Provider verwalten
Übersicht unter **Settings → AI Providers / Models** in der UI, oder per API:
```bash
curl http://127.0.0.1:5055/api/models | python3 -m json.tool # alle registrierten Modelle
curl http://127.0.0.1:5055/api/models/defaults | python3 -m json.tool # aktuelle Standardmodelle
```
### Standardmodelle ändern
Sieben Rollen lassen sich unabhängig belegen: Chat, Transformation, großer Kontext, Embedding,
Tools, Text-to-Speech, Speech-to-Text.
```bash
curl -X PUT http://127.0.0.1:5055/api/models/defaults \
-H "Content-Type: application/json" \
-d '{"default_tools_model": "<model_id>"}'
```
Der Endpunkt aktualisiert nur die tatsächlich mitgeschickten Felder — nicht angegebene Felder
bleiben unverändert, es muss also nicht der komplette Satz aller sieben Modelle mitgeschickt
werden.
### Kostenübersicht der aktuell registrierten Modelle
| Modell | Provider | Kosten |
|---|---|---|
| `qwen3.5:27b` | Ollama (lokal) | 0 € (Standard für Chat/Tools/großer Kontext) |
| `nomic-embed-text` | Ollama (lokal) | 0 € (Standard-Embedding) |
| `qwen3-coder-30b-128k` | Ollama (lokal) | 0 € (optional, für Coding-Aufgaben) |
| Chatterbox TTS / faster-whisper | lokal | 0 € |
| `anthropic/claude-sonnet-5` | OpenRouter | ~2$/10$ pro Mio. Tokens (Prompt/Antwort) |
| `qwen/qwen3-max` | OpenRouter | ~0,78$/3,90$ pro Mio. Tokens — günstigere Cloud-Alternative |
| `google/gemini-3.5-flash` | OpenRouter | ~1,50$/9$ pro Mio. Tokens — 1M Tokens Kontext |
Weitere OpenRouter-Modelle lassen sich jederzeit über `POST /api/models` registrieren
(`provider: "openrouter"`) — aktuelle Preise: `https://openrouter.ai/api/v1/models`.
---
## 7. Dienste starten/stoppen
```bash
# Open Notebook (Docker)
docker compose up -d
docker compose down
# TTS/STT (auf dem Host, nicht in Docker — systemd-User-Dienste)
systemctl --user status open-notebook-tts open-notebook-stt
systemctl --user restart open-notebook-tts open-notebook-stt
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:
```bash
systemctl status ollama
ollama ps # aktuell geladene Modelle + GPU/CPU-Verteilung
```
---
## 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 (01), 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)
Meist ein zu kleiner Kontext (`num_ctx`) am Ollama-Chat-Credential — siehe README, Abschnitt 2.6.
Prüfen:
```bash
curl -s http://127.0.0.1:5055/api/credentials | python3 -c "
import json,sys
for c in json.load(sys.stdin):
if c['provider']=='ollama': print(c['name'], c['num_ctx'])"
```
Sollte `98304` (oder höher, dann ggf. auf GPU-Auslastung achten) zeigen, nicht `None`/`null`.
### Quelle hängt beim Verarbeiten / Einbetten fest (Endlos-Retry)
Meist Ressourcen-Engpass beim Embedding-Modell (GPU voll, Modell läuft teilweise auf CPU,
Timeout). Prüfen:
```bash
ollama ps # PROCESSOR-Spalte: sollte "100% GPU" zeigen, nicht "xx%/yy% CPU/GPU"
```
Falls CPU-Anteil: Embedding-Modell ist zu groß für den freien GPU-Speicher. Standardmäßig ist
hier das kleine `nomic-embed-text` (274 MB) konfiguriert, das läuft normalerweise problemlos
komplett auf der GPU.
### Antwort dauert sehr lange (mehrere Minuten)
Meist normal bei „vollständiger Inhalt"-Modus auf langen Dokumenten (siehe Abschnitt 3) — das
Sprachmodell muss den kompletten Text lesen. Für Detailfragen auf lange Sicht besser die
Such-/Ask-Funktion oder „nur Erkenntnisse" nach einer Zusammenfassung nutzen.
### TTS/STT vom Container aus nicht erreichbar
```bash
docker compose exec open_notebook curl -sf http://host.docker.internal:8901/health
```
Bei Timeout: `ufw`-Regeln fehlen (siehe README 2.4) oder die Dienste laufen nicht
(`systemctl --user status open-notebook-tts open-notebook-stt`).
### Podcast-Profil zeigt „Einrichtung erforderlich"
Das Episode- oder Sprecherprofil verweist auf ein Modell ohne konfiguriertes Credential
(z. B. nach einem Reset auf die mitgelieferten OpenAI-Standardprofile). Modell-IDs über
`PUT /api/episode-profiles/{id}` (`outline_llm`, `transcript_llm`) bzw.
`PUT /api/speaker-profiles/{id}` (`voice_model`) auf registrierte lokale/OpenRouter-Modelle
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
12 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 hängt ewig auf „running" (Karteileiche nach Container-Neustart)
Wird der Stack gestoppt (`docker compose down`, Update, Reboot), während ein Podcast läuft, ist der
Job weg — aber in der Datenbank steht er **weiterhin auf `running`**. Er wird nie fertig, blockiert
`prune_podcast_data.py` (das bricht bei laufenden Jobs absichtlich ab) und lässt sich auch nicht
wiederholen: `/retry` akzeptiert nur Episoden im Status `failed`.
Erkennen: keine neuen Clips im Episodenordner, GPU 2 im Leerlauf (`nvidia-smi`), nichts im Log
(`docker compose logs --since 5m open_notebook`).
Der Status hängt nicht an der Episode, sondern am verknüpften `command`-Datensatz. Ihn auf
`failed` setzen — danach funktioniert der normale Retry-Knopf:
```bash
set -a; . ./.env; set +a
# 1. Command-ID der Episode holen
curl -s -X POST http://127.0.0.1:8000/sql -u "$SURREAL_USER:$SURREAL_PASSWORD" \
-H "surreal-ns: open_notebook" -H "surreal-db: open_notebook" -H "Accept: application/json" \
-d "SELECT command FROM episode:<ID>;"
# 2. Auf failed setzen
curl -s -X POST http://127.0.0.1:8000/sql -u "$SURREAL_USER:$SURREAL_PASSWORD" \
-H "surreal-ns: open_notebook" -H "surreal-db: open_notebook" -H "Accept: application/json" \
-d "UPDATE command:<COMMAND-ID> SET status = 'failed', error_message = 'Worker beim Neustart beendet';"
# 3. Wiederholen
curl -X POST http://127.0.0.1:5055/api/podcasts/episodes/<ID>/retry
```
**Vorbeugen:** vor `docker compose down` oder `./scripts/update_stack.sh` kurz prüfen, ob ein
Podcast läuft — die Episodenliste in der UI zeigt es, oder:
```bash
curl -s http://127.0.0.1:5055/api/podcasts/episodes | python3 -c "
import json,sys
print([e['name'] for e in json.load(sys.stdin) if e['job_status']=='running'] or 'kein Job läuft')"
```
### 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
Siehe [`CLAUDE.md`](CLAUDE.md) — dort stehen alle bisher aufgetretenen Probleme mit exakter
Ursache (Codestellen, Bibliotheks-Bugs) dokumentiert.