diff --git a/.gitignore b/.gitignore index c1efe99..03c8c44 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,3 @@ surreal_data/ notebook_data/ services/logs/ services/voices/ - -# Python -__pycache__/ -*.pyc - -# Artefakt versehentlicher Shell-Redirects -/3 - -# Backups vor Image-Updates (scripts/update_stack.sh) -backups/ diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index f86ed80..44f63e4 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -15,10 +15,7 @@ siehe [`CLAUDE.md`](CLAUDE.md). 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) +8. [Troubleshooting](#8-troubleshooting) --- @@ -119,36 +116,6 @@ 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, 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// -├── clips/ ← 1 MP3 pro Dialogsegment BLEIBT LIEGEN -├── outline.json BLEIBT LIEGEN -├── transcript.json BLEIBT LIEGEN -└── audio/.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 @@ -167,7 +134,8 @@ geklonten echten Personen, siehe [`CLAUDE.md`](CLAUDE.md)). 2. Datei nach `services/voices/.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 + pkill -f "python tts_server.py" + ./scripts/start_services.sh ``` 4. Prüfen: `curl http://127.0.0.1:8901/health` sollte `` in der `voices`-Liste zeigen. 5. In einem Sprecherprofil (Podcast) den betreffenden Sprecher auf `voice_id: ""` setzen, @@ -237,17 +205,12 @@ Weitere OpenRouter-Modelle lassen sich jederzeit über `POST /api/models` regist 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 (lokale Hintergrundprozesse, nicht in Docker) +./scripts/start_services.sh +pkill -f "python tts_server.py" +pkill -f "python stt_server.py" ``` -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 @@ -257,159 +220,7 @@ 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//`, 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 " -``` - -**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 +## 8. Troubleshooting ### „Chat gibt keine Antwort" (leere Antwort, kein Fehler) @@ -450,8 +261,8 @@ Such-/Ask-Funktion oder „nur Erkenntnisse" nach einer Zusammenfassung nutzen. 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`). +Bei Timeout: `ufw`-Regeln fehlen (siehe README 2.4) oder die Server laufen nicht +(`./scripts/start_services.sh`). ### Podcast-Profil zeigt „Einrichtung erforderlich" @@ -461,114 +272,6 @@ Das Episode- oder Sprecherprofil verweist auf ein Modell ohne konfiguriertes Cre `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 --notebook notebook:abc --language de -./scripts/add_video_source.py --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 Siehe [`CLAUDE.md`](CLAUDE.md) — dort stehen alle bisher aufgetretenen Probleme mit exakter diff --git a/CLAUDE.md b/CLAUDE.md index aac3bc6..a01c6f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,80 +4,25 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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. 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. +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. ## Commands ```bash -docker compose up -d # start (images are digest-pinned; no implicit upgrade) +docker compose up -d # start (pulls images per pull_policy: always) docker compose down # stop and remove containers (data volumes persist) docker compose logs -f open_notebook # follow app logs 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 ``` -`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//` → 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. +No build/lint/test suite exists in this repo — nothing here to run. ## Architecture 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. -- **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`). +- **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`). Data persists in `./surreal_data` and `./notebook_data` (bind mounts, gitignored). @@ -96,117 +41,6 @@ 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. -**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 `` 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//` 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 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 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: @@ -214,61 +48,15 @@ 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/stt_server.py` — wraps `faster-whisper` (`large-v3` model), run with the base `python3`. -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): +Both are plain background processes (`nohup ... &`), **not** systemd units — they don't survive a reboot; restart manually if needed: ```bash -systemctl --user status open-notebook-tts open-notebook-stt -systemctl --user restart open-notebook-tts # after editing tts_server.py or adding voices -journalctl --user -u open-notebook-tts -f +cd ~/open-notebook/services +CUDA_VISIBLE_DEVICES=2 TTS_PORT=8901 nohup ~/miniforge3/envs/chatterbox/bin/python tts_server.py > logs/tts_server.log 2>&1 & +CUDA_VISIBLE_DEVICES=2 STT_PORT=8902 nohup python3 stt_server.py > logs/stt_server.log 2>&1 & ``` -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. +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. 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). @@ -286,7 +74,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" ``` -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 `.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 `` 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 `.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 `` via `PUT /api/speaker-profiles/{id}`. ## Secrets diff --git a/README.md b/README.md index 4dcdbf7..fc3eb86 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ Weiterentwicklung: siehe [`CLAUDE.md`](CLAUDE.md). | `surrealdb` | Docker | Datenbank (nur `127.0.0.1:8000`) | | `open_notebook` | Docker | Web-UI (`:8502`) + REST-API (`:5055`) | | Ollama | Host (systemd) | Chat-/Embedding-Modelle, GPU 1+2 | -| `services/tts_server.py` | Host (systemd-User-Dienst) | Chatterbox TTS, GPU 2, Port `8901` | -| `services/stt_server.py` | Host (systemd-User-Dienst) | faster-whisper STT, GPU 2, Port `8902` | +| `services/tts_server.py` | Host (Hintergrundprozess) | Chatterbox TTS, GPU 2, Port `8901` | +| `services/stt_server.py` | Host (Hintergrundprozess) | faster-whisper STT, GPU 2, Port `8902` | Der `open_notebook`-Container erreicht Ollama und die TTS/STT-Wrapper über `host.docker.internal` (Linux-Route via `extra_hosts: host-gateway`). @@ -49,8 +49,6 @@ Der `open_notebook`-Container erreicht Ollama und die TTS/STT-Wrapper über in Schritt 2) - **`ufw`** (falls als Firewall aktiv) — Standardrichtlinie „deny incoming“ wird vorausgesetzt - **`openssl`**, **`ffmpeg`**, **`curl`**, **`python3`** -- **`yt-dlp`** — nur für `scripts/add_video_source.py` (Videos ohne Untertitel); der übrige Stack - läuft auch ohne --- @@ -82,14 +80,11 @@ chmod 600 .env ### 2.3 Container starten ```bash +docker compose pull docker compose up -d 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`). ### 2.4 Firewall öffnen für TTS/STT @@ -106,29 +101,19 @@ 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 laufen und funktionieren. -### 2.5 TTS/STT-Server einrichten +### 2.5 TTS/STT-Server starten ```bash ./scripts/start_services.sh ``` -Installiert `services/systemd/*.service` nach `~/.config/systemd/user/`, aktiviert `linger` -(damit die Dienste auch ohne aktive Login-Session laufen) und startet beide. Sie laufen auf -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. +Startet `tts_server.py` und `stt_server.py` als Hintergrundprozesse auf GPU 2 (nicht GPU 0/1, +um Ollama und die Desktop-GPU nicht zu belasten). Nicht systemd-verwaltet — nach einem Reboot +erneut ausführen. Logs: `services/logs/`. Für Stimmklonung mindestens eine Referenz-WAV in `services/voices/default.wav` ablegen (z. B. Symlink auf eine eigene Sprachaufnahme, 10–30s, WAV) — siehe -[`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md#5-stimmklonung). +[`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md#stimmklonung). ### 2.6 KI-Provider und Modelle registrieren @@ -166,12 +151,6 @@ curl -s http://127.0.0.1:8901/health # Chatterbox TTS 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 — siehe [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md) für die weitere Bedienung. @@ -184,57 +163,12 @@ docker compose up -d # starten docker compose down # stoppen (Daten bleiben erhalten) docker compose logs -f open_notebook # Logs verfolgen docker compose ps # Status - -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 # 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? +./scripts/start_services.sh # TTS/STT (re-)starten, z.B. nach Reboot ``` -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. 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 +## 4. Verzeichnisstruktur ``` open-notebook/ @@ -243,23 +177,14 @@ open-notebook/ ├── .env.example # Template ├── surreal_data/ # DB-Daten (gitignored) ├── notebook_data/ # Notebook-Dateien/Uploads (gitignored) -├── config/ -│ └── content_core.yaml # content-core (u.a. deutsche YouTube-Transkripte) ├── services/ │ ├── tts_server.py # Chatterbox-Wrapper (OpenAI-API-kompatibel) │ ├── stt_server.py # faster-whisper-Wrapper -│ ├── systemd/ # Unit-Dateien für die beiden Wrapper │ ├── voices/ # Referenz-WAVs fürs Voice-Cloning (gitignored) -│ └── logs/ # (gitignored, Altlast der früheren nohup-Variante) -├── backups/ # Sicherungen vor Image-Updates (gitignored) +│ └── logs/ # (gitignored) ├── scripts/ │ ├── setup_models.sh # Provider/Modelle einrichten (idempotent) -│ ├── 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 +│ └── start_services.sh # TTS/STT starten ├── README.md # diese Datei ├── BEDIENUNGSANLEITUNG.md # ausführliche Bedienungsanleitung (Deutsch) └── CLAUDE.md # technische Architektur-Doku für Weiterentwicklung @@ -267,7 +192,7 @@ open-notebook/ --- -## 6. Bekannte Stolpersteine +## 5. Bekannte Stolpersteine Kurzreferenz — Details jeweils in [`CLAUDE.md`](CLAUDE.md): @@ -279,33 +204,3 @@ Kurzreferenz — Details jeweils in [`CLAUDE.md`](CLAUDE.md): leere Chat-Antworten bei größerem Kontext, ohne sichtbaren Fehler. - **Neue Host-Ports brauchen eine `ufw`-Regel**, sonst kann der Container sie nicht erreichen (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 ` (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//` 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). diff --git a/config/content_core.yaml b/config/content_core.yaml deleted file mode 100644 index 9728194..0000000 --- a/config/content_core.yaml +++ /dev/null @@ -1,78 +0,0 @@ -# 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 - diff --git a/docker-compose.yml b/docker-compose.yml index 58899f1..d830346 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,8 @@ -# 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: surrealdb: - # surrealdb:v2 (Digest-Pin: auch ein Minor-Sprung kann das Storage-Format anfassen) - image: surrealdb/surrealdb:v2@sha256:d653f6c8a89e81f865ee31cd2f587c50f50ace922175e04150b1e385d2f86011 + image: surrealdb/surrealdb:v2 restart: always - pull_policy: missing + pull_policy: always command: start --log info --user ${SURREAL_USER:-root} --pass ${SURREAL_PASSWORD:-root} rocksdb:/mydata/mydatabase.db user: root # Required for bind mounts on Linux environment: @@ -26,11 +13,9 @@ services: - ./surreal_data:/mydata open_notebook: - # 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 + image: lfnovo/open_notebook:v1-latest restart: always - pull_policy: missing + pull_policy: always depends_on: - surrealdb ports: @@ -45,35 +30,7 @@ services: - SURREAL_DATABASE=open_notebook - OLLAMA_BASE_URL=http://host.docker.internal:11434 - 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: - "host.docker.internal:host-gateway" volumes: - ./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 diff --git a/prompts/podcast/outline.jinja b/prompts/podcast/outline.jinja deleted file mode 100644 index c0ba2f8..0000000 --- a/prompts/podcast/outline.jinja +++ /dev/null @@ -1,91 +0,0 @@ -/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 }} - - -The user has provided content to be used as the context for this podcast episode: - -{% if context is string %} -{{ context }} -{% else %} -{% for item in context %} - -{{ item }} - -{% endfor %} -{% endif %} - - -The podcast will feature the following speakers: - -{% for speaker in speakers %} -- **{{ speaker.name }}**: {{ speaker.backstory }} - Personality: {{ speaker.personality }} -{% endfor %} - - -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 tags, put ALL your reasoning inside tags -- Put the final JSON output OUTSIDE and AFTER any tags -- Do NOT wrap the JSON in ```json code blocks - return the raw JSON object only -- Example correct format: - Let me analyze the briefing... - {"segments": [...]} - -Please provide your outline now, following the format and guidelines provided above. diff --git a/prompts/podcast/transcript.jinja b/prompts/podcast/transcript.jinja deleted file mode 100644 index 3b9b75f..0000000 --- a/prompts/podcast/transcript.jinja +++ /dev/null @@ -1,142 +0,0 @@ -/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 }} - - -The user has provided content to be used as the context for this podcast episode: - -{% if context is string %} -{{ context }} -{% else %} -{% for item in context %} - -{{ item }} - -{% endfor %} -{% endif %} - - -The podcast features the following speakers: - -{% for speaker in speakers %} -- **{{ speaker.name }}**: {{ speaker.backstory }} - Personality: {{ speaker.personality }} -{% endfor %} - - -Next, examine the outline produced by our director: - -{{ outline }} - - -{% if transcript %} -Here is the current transcript so far: - -{{ 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 }} - - -{% 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 tags, put ALL your reasoning inside tags -- Put the final JSON output OUTSIDE and AFTER any tags -- Do NOT wrap the JSON in ```json code blocks - return the raw JSON object only -- Example correct format: - Let me plan the dialogue... - {"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 %} diff --git a/scripts/add_video_source.py b/scripts/add_video_source.py deleted file mode 100755 index 9bdc0f4..0000000 --- a/scripts/add_video_source.py +++ /dev/null @@ -1,260 +0,0 @@ -#!/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 --notebook notebook:abc --language de - ./scripts/add_video_source.py --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()) diff --git a/scripts/check_updates.sh b/scripts/check_updates.sh deleted file mode 100755 index 75f44fc..0000000 --- a/scripts/check_updates.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/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 </: - -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 # /audio/.mp3 -> / - - 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()) diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh deleted file mode 100755 index 3e3d892..0000000 --- a/scripts/smoke_test.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/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 diff --git a/scripts/start_services.sh b/scripts/start_services.sh index 4205943..f1030c5 100755 --- a/scripts/start_services.sh +++ b/scripts/start_services.sh @@ -1,57 +1,31 @@ #!/usr/bin/env bash -# Installiert und startet die lokalen TTS/STT-Wrapper-Server (services/tts_server.py, -# services/stt_server.py) als systemd-User-Dienste auf GPU 2. -# -# 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. +# Startet die lokalen TTS/STT-Wrapper-Server (services/tts_server.py, +# services/stt_server.py) als Hintergrundprozesse auf GPU 2. Überspringt +# einen Service, falls er unter seinem Port bereits antwortet. set -euo pipefail -REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -UNIT_SRC="$REPO/services/systemd" -UNIT_DST="$HOME/.config/systemd/user" -UNITS=(open-notebook-tts.service open-notebook-stt.service) +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../services" && pwd)" +mkdir -p "$DIR/logs" +cd "$DIR" -mkdir -p "$UNIT_DST" -for unit in "${UNITS[@]}"; do - install -m 644 "$UNIT_SRC/$unit" "$UNIT_DST/$unit" -done -systemctl --user daemon-reload - -# Ohne Linger laufen User-Dienste nur waehrend einer aktiven Login-Session und -# starten nicht beim Boot. -if [ "$(loginctl show-user "$USER" --property=Linger --value 2>/dev/null)" != "yes" ]; then - echo "Aktiviere linger (Dienste sollen auch ohne Login laufen)..." - loginctl enable-linger "$USER" -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 +start_if_needed() { + local name="$1" port="$2" cmd="$3" + if curl -sf -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then + echo "$name läuft bereits auf Port $port" + return + fi + echo "Starte $name auf Port $port..." + eval "$cmd" + disown + for _ in $(seq 1 15); do + curl -sf -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && { echo "$name ist bereit."; return; } + sleep 2 done - echo "WARNUNG: $name antwortet nach 180s nicht auf /health." >&2 - echo " Logs: journalctl --user -u open-notebook-${name} -n 50" >&2 - return 1 + echo "WARNUNG: $name antwortet nach 30s nicht auf /health — siehe logs/${name}.log" >&2 } -rc=0 -wait_healthy tts 8901 || rc=1 -wait_healthy stt 8902 || rc=1 +start_if_needed "tts" 8901 \ + 'CUDA_VISIBLE_DEVICES=2 TTS_PORT=8901 nohup ~/miniforge3/envs/chatterbox/bin/python tts_server.py > logs/tts_server.log 2>&1 &' -if [ "$rc" -eq 0 ]; then - echo "Fertig. Status: systemctl --user status ${UNITS[*]}" -fi -exit "$rc" +start_if_needed "stt" 8902 \ + 'CUDA_VISIBLE_DEVICES=2 STT_PORT=8902 nohup python3 stt_server.py > logs/stt_server.log 2>&1 &' diff --git a/scripts/update_stack.sh b/scripts/update_stack.sh deleted file mode 100755 index 6cb7c67..0000000 --- a/scripts/update_stack.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/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 diff --git a/services/systemd/open-notebook-stt.service b/services/systemd/open-notebook-stt.service deleted file mode 100644 index 30b31d8..0000000 --- a/services/systemd/open-notebook-stt.service +++ /dev/null @@ -1,21 +0,0 @@ -[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 diff --git a/services/systemd/open-notebook-tts.service b/services/systemd/open-notebook-tts.service deleted file mode 100644 index 4ed7d9a..0000000 --- a/services/systemd/open-notebook-tts.service +++ /dev/null @@ -1,23 +0,0 @@ -[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 diff --git a/services/tts_server.py b/services/tts_server.py index 59795b1..b54340a 100644 --- a/services/tts_server.py +++ b/services/tts_server.py @@ -25,7 +25,6 @@ import os import subprocess import sys import tempfile -import threading from pathlib import Path sys.path.insert(0, str(Path.home() / "chatterbox-tts-cli")) @@ -41,14 +40,6 @@ app = FastAPI(title="Chatterbox TTS (OpenAI-compatible, voice cloning)", version _DEVICE = tts.get_device(None) _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" 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 @@ -133,18 +124,12 @@ def speech(req: SpeechRequest): if not chunks: raise HTTPException(status_code=422, detail="Kein synthetisierbarer Text übrig.") - with _GPU_LOCK: - model, model_kind, sr = _get_model(lang) - try: - wavs = [] - for chunk in chunks: - 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() + model, model_kind, sr = _get_model(lang) + + wavs = [] + for chunk in chunks: + 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) with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as wav_tmp: wav_path = wav_tmp.name