Oppretter transcription_segments-tabellen i PostgreSQL som master-kopi for alle transkripsjoner. transcribe.rs er oppdatert fra verbose_json til SRT-format med full parse → segment-innsetting pipeline. Endringer: - Migration 005: transcription_segments med GIN fulltekstsøk (norsk) - transcribe.rs: SRT-parser, segment-innsetting, node-oppdatering - Miljøvariabler: WHISPER_MODEL (default "medium"), WHISPER_INITIAL_PROMPT - Docker-compose: nye env vars for maskinrommet-containeren - Docs: oppdatert podcastfabrikken, arkitektur, primitiver, CLAUDE.md Tabellen kjørt på server, maskinrommet restartet med nye env vars. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
171 lines
12 KiB
Markdown
171 lines
12 KiB
Markdown
# Konsept: Podcastfabrikken (Lyd & Publiserings-Pipeline)
|
|
**Filsti:** `docs/concepts/podcastfabrikken.md`
|
|
|
|
## 1. Konsept
|
|
Den automatiserte "samlebåndet" som tar over når en ferdigklippet episode er klar, samt verktøyet for å **oppdatere eksisterende episoder** (f.eks. en rullerende intro-episode). Målet er at maskinen gjør 90 % av grovarbeidet (transkripsjon, metadata, kapittelinndeling), men at redaksjonen alltid kan overstyre resultatet manuelt før publisering.
|
|
|
|
## 2. Arkitektur & Dataflyt
|
|
Dette er en asynkron arbeidsflyt som kombinerer CAS, AI og databaser.
|
|
|
|
1. **Trigger (Opplasting/Oppdatering):** Brukeren laster opp en `.mp3`-fil via SvelteKit-grensesnittet. Dette rutes enten som en *ny* episode (`INSERT`), eller en *oppdatering* av en eksisterende (`UPDATE`).
|
|
2. **Kø-system (PostgreSQL jobbkø):** Siden lydprosessering tar tid (CPU-intensivt), legges oppgaven i den felles jobbkøen (se `docs/infra/jobbkø.md`). Opplastingen oppretter to jobber i sekvens: først `whisper_transcribe`, deretter `openrouter_analyze` (som trigges automatisk ved fullført transkripsjon).
|
|
3. **Transkripsjon (faster-whisper):** Maskinrommet kaller faster-whisper-server (OpenAI-kompatibelt API, `POST /v1/audio/transcriptions`) med `response_format=srt` og mottar SRT direkte. Modell: `medium` med `initial_prompt` (navneliste). Modellvalg er en sentral serverinnstilling — null konfigurasjon per samling.
|
|
4. **Lagring av transkripsjon (PostgreSQL):** Maskinrommet parser SRT-responsen og skriver segmenter til `transcription_segments`-tabellen. Hver kjøring grupperes med `transcribed_at`-tidsstempel. Medianoden oppdateres med sammenhengende tekst i `content`-feltet.
|
|
5. **Avledede formater:** Genereres direkte fra segmenttabellen:
|
|
* **Ren tekst** — `SELECT content FROM transcription_segments WHERE node_id = $1 ORDER BY seq` → sammenhengende tekst
|
|
* **Full-text søkeindeks** — GIN-indeks på `content`-kolonnen for oppslag på tvers av episoder
|
|
* **Tidsoppslag** — "hva ble sagt ved 05:23?" er en range-query på `start_ms`/`end_ms`
|
|
6. **AI-Analyse (OpenRouter):** Transkripsjonen sendes til OpenRouter (Claude-modell). Resultatet lagres som **noder med edges** til episoden — ikke som metadata-felt:
|
|
* Tittel-forslag → node med `title`-edge (`variant: "ai"`)
|
|
* Sammendrag → node med `summary`-edge (`variant: "ai"`)
|
|
* Show notes → node med `show_notes`-edge (`variant: "ai"`)
|
|
* Kapittler → noder med `chapter`-edge (metadata: `{ at: "00:05:23" }`)
|
|
Se `docs/concepts/publisering.md` § "Presentasjonselementer er noder".
|
|
7. **Manuell Godkjenning & Fletting (SvelteKit):**
|
|
* *For nye episoder:* AI-genererte noder presenteres som forslag. Redaksjonen kan godkjenne, redigere, eller opprette egne varianter (nye noder med `variant: "editorial"`).
|
|
* *For oppdateringer:* Viser AI-ens nye forslag side-om-side med eksisterende noder. Redaksjonen velger hvilke som skal være aktive.
|
|
* Flere varianter av tittel/sammendrag kan A/B-testes automatisk i RSS-feed og episodeside.
|
|
8. **Publisering (PostgreSQL):** Ved "Godkjenn" markeres aktive varianter. Maskinrommet rendrer episodeside og oppdaterer RSS basert på aktive noder.
|
|
9. **RSS-Generering:** SvelteKit-appen genererer en oppdatert `/feed.xml`.
|
|
|
|
### 2.1 Episodeside (publisert visning)
|
|
Hver publisert episode får en side med:
|
|
* Lydavspiller + sammendrag + kapitler + stikkord
|
|
* Personreferanser og artikler (koblet via kunnskapsgrafen)
|
|
* Fane: **Transkripsjon** (lesbart dokument med avspillingsknapp per segment)
|
|
* Nedlastbar SRT-fil (generert fra segmenttabellen)
|
|
|
|
### 2.2 Transkripsjonsvisning (universell)
|
|
Transkripsjonsvisningen er universell — brukes for podcast-episoder, møter, voice memos og alle andre lydnoder med transkripsjon. Visningen tilbyr:
|
|
|
|
* **Segmenter med tidsstempler** — hvert segment viser start/slutt-tid
|
|
* **Avspillingsknapp per segment** — klikk hopper til riktig sted i lydfilen (CAS-URL fra medianoden, `audio.currentTime = start_ms / 1000`)
|
|
* **Redigerbare tekstfelt** — brukeren kan korrigere feil, `edited`-flagget settes automatisk
|
|
* **Re-transkripsjon:** Ved ny transkribering (ny modell, redigert lyd) vises begge versjonene side-om-side. Manuelt redigerte segmenter (`edited = true`) fra forrige versjon highlightes — brukeren velger per segment om forrige redigering eller ny transkripsjon skal gjelde.
|
|
|
|
### 2.3 Live-to-Archive (Studio/Møterom → Episode)
|
|
|
|
Mye av innholdet som ender i podcastfabrikken starter som live-innspilling
|
|
i Studioet eller Møterommet. Denne flyten unngår manuell opplasting:
|
|
|
|
1. Under innspilling i LiveKit produserer Whisper chunks i sanntid
|
|
2. Når innspillingen stoppes → automatisk jobb `studio_to_episode`:
|
|
- Konsoliderer live-chunks til segmenter i `transcription_segments`
|
|
- Oppretter episode-node med storyboard basert på markører satt under innspilling
|
|
- Trigger AI-analyse (samme pipeline som ved vanlig opplasting)
|
|
3. Lydfilen lagres i CAS med edge til episode-noden
|
|
4. Episoden dukker opp i podcastfabrikken som et utkast — klar for
|
|
godkjenning, redigering og publisering
|
|
|
|
Fordel: aldri behov for «last opp MP3 etter innspilling» — flyten er
|
|
live → arkiv → publisering.
|
|
|
|
## 3. Transkripsjonssegmenter (PostgreSQL)
|
|
|
|
Master-kopien av alle transkripsjoner lever i `transcription_segments`-tabellen.
|
|
SRT og ren tekst er avledede eksportformater.
|
|
|
|
```sql
|
|
CREATE TABLE transcription_segments (
|
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
|
transcribed_at TIMESTAMPTZ NOT NULL, -- grupperer segmenter fra samme kjøring
|
|
seq INT NOT NULL,
|
|
start_ms INT NOT NULL,
|
|
end_ms INT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
edited BOOLEAN DEFAULT false,
|
|
|
|
UNIQUE (node_id, transcribed_at, seq)
|
|
);
|
|
|
|
CREATE INDEX idx_segments_node ON transcription_segments (node_id, transcribed_at, seq);
|
|
CREATE INDEX idx_segments_fts ON transcription_segments
|
|
USING gin(to_tsvector('norwegian', content));
|
|
```
|
|
|
|
### 3.1 Universell tjeneste
|
|
Transkribering er en tjeneste fra maskinrommet med null konfigurasjon per samling.
|
|
Modellvalg (`medium` for asynkron, evt. annen for synkron/live) er en sentral
|
|
serverinnstilling. Alle lydnoder — podcast-episoder, møter, voice memos —
|
|
transkriberes via samme pipeline og lagres i samme tabell.
|
|
|
|
### 3.2 Tilhørighet og tilgang
|
|
Tilgang styres gjennom `node_id` — den som har tilgang til medianoden (via
|
|
`node_access`) har tilgang til segmentene. Ingen RLS på segmenttabellen,
|
|
ingen duplisering av tilgangslogikk.
|
|
|
|
### 3.3 Re-transkripsjon
|
|
Ny transkripsjon (ny modell, redigert lyd) gir nye rader med nytt
|
|
`transcribed_at`-tidsstempel. Forrige versjon beholdes inntil brukeren
|
|
har vurdert. "Gjeldende transkripsjon" = siste `transcribed_at` per `node_id`.
|
|
|
|
### 3.4 SRT-eksport
|
|
SRT rekonstrueres trivielt fra segmenttabellen:
|
|
```
|
|
{seq}
|
|
{start_ms → HH:MM:SS,mmm} --> {end_ms → HH:MM:SS,mmm}
|
|
{content}
|
|
```
|
|
|
|
## 4. Spesialhåndtering: Oppdatering av eksisterende episoder (Cache-busting)
|
|
Podcast-apper (Apple, Spotify) og CDN-er cacher innhold aggressivt. For at en endring i f.eks. "Introepisoden" skal slå gjennom hos lytterne, MÅ følgende tekniske regler følges:
|
|
|
|
* **Filnavn-versjonering (Viktigst!):** Den nye lydfilen skal *aldri* overskrive det gamle filnavnet på disken. Systemet må legge til en hash, UUID eller et tidsstempel (f.eks. `intro_v2_1710289000.mp3`). Dette tvinger appene til å laste ned filen på nytt.
|
|
* **RSS `<guid>` (Global Unique Identifier):** Denne taggen MÅ forbli 100% statisk/uendret fra originalepisoden. Den forteller appene at "Dette er fortsatt samme episode, ikke lag en duplikat".
|
|
* **RSS `<enclosure>`:** URL-en i `enclosure`-taggen (som peker på `.mp3`-filen) oppdateres i databasen til å reflektere det *nye* filnavnet.
|
|
* **RSS `<pubDate>`:** SvelteKit-grensesnittet skal gi redaksjonen en toggle-knapp ved oppdatering:
|
|
* Alternativ A: "Behold opprinnelig dato" (Episoden oppdateres i det stille for nye lyttere).
|
|
* Alternativ B: "Sett dato til NÅ" (Episoden spretter til toppen av feeden som en ny utgivelse).
|
|
|
|
## 5. Whisper-konfigurasjon
|
|
* **Tjeneste:** `fedirz/faster-whisper-server` (Docker, OpenAI-kompatibelt API)
|
|
* **Endepunkt:** `POST /v1/audio/transcriptions` med `response_format=srt`
|
|
* **Beslutning:** SRT direkte fra Whisper. SRT gir tidsstempler + tekst som er trivielt å parse til segmenter. Maskinrommet parser SRT og skriver til `transcription_segments`-tabellen.
|
|
* **Modell:** `medium` med `initial_prompt`. Sentral serverinnstilling, ikke per samling.
|
|
* **Modeller (benchmarket med E277.mp3, 32:45 norsk tale, CPU i7-13900K):**
|
|
|
|
| Konfigurasjon | Tid (CPU) | Seg | Tegn | Kommentar |
|
|
|---|---|---|---|---|
|
|
| `small` | ~6 min | 777 | 25851 | Rask, men hyppige feil i egennavn |
|
|
| `medium` | ~18 min | 442 | 26938 | God balanse, noen navnefeil |
|
|
| `medium` + prompt | ~17 min | 455 | 26957 | Riktige egennavn, anbefalt standard |
|
|
| `large-v3` | ~24 min | 520 | 14559 | Hallusinerer uten VAD — IKKE bruk |
|
|
| `large-v3` + VAD | ~31 min | 964 | 28291 | God kvalitet, men noen navnefeil |
|
|
| `large-v3` + VAD + prompt | ~31 min | 964 | 28295 | Best kvalitet, men for treg |
|
|
|
|
* **Valg:** `medium` + `initial_prompt`. God nok kvalitet, rask nok for produksjon.
|
|
* **Språk:** Sett `language=no` eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk.
|
|
|
|
### 5.1 initial_prompt (navneliste)
|
|
`initial_prompt` primes Whisper med ordforråd som forbedrer gjenkjenning av egennavn. Effekten er tydelig:
|
|
* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja"
|
|
* Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig)
|
|
|
|
Prompten bygges automatisk av maskinrommet fra en statisk navneliste (miljøvariabel) + aktører i kunnskapsgrafen (senere):
|
|
```
|
|
Sidelinja podcast med Vegard Nøtnæs, Trond Sørensen, Arne Eidshagen,
|
|
Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie
|
|
```
|
|
|
|
## 6. Per samlings-node konfigurasjon
|
|
Hver samlings-node (f.eks. Sidelinja) har sin egen podcast-konfigurasjon, lagret som JSONB-metadata på noden:
|
|
|
|
### 6.1 Mediefiler
|
|
Lydfiler lagres i CAS (content-addressable store) med edges til episode-noder. Caddy ruter trafikk basert på domene (fra samlings-nodens metadata) til riktig innhold.
|
|
|
|
### 6.2 AI-prompts
|
|
* **LLM system-prompts:** OpenRouter-prompts for metadata-uttrekk lagres i metadata (`llm_prompts`) slik at AI-en kjenner konteksten og vertene for akkurat den podcasten.
|
|
|
|
### 6.3 RSS-feed
|
|
SvelteKit genererer `/feed.xml` dynamisk basert på domenet forespørselen kommer fra (matcher samlings-nodens domene-metadata), eller node-slug som fallback.
|
|
|
|
### 6.4 Statistikk
|
|
Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig samlings-node basert på domene i loggen.
|
|
|
|
## 7. Instruks for Claude Code
|
|
* **Lydfiler:** Håndter filopplasting i SvelteKit strømmende (streaming) for filer >100MB for å unngå minne-lekkasjer.
|
|
* **Feilhåndtering:** Hvis OpenRouter timer ut eller Whisper feiler, må oppgaven flagges med status `error` i databasen slik at brukeren kan trigge jobben på nytt manuelt via UI.
|
|
* **Opprydding (Disk):** Når en fil oppdateres vellykket, skal den gamle/foreldede `.mp3`-filen enten slettes fra Hetzner-serveren automatisk, eller flyttes til en `/archive/`-mappe basert på en miljøvariabel.
|
|
* **Transkripsjoner:** Master-kopi i `transcription_segments`-tabellen. SRT og ren tekst er eksportformater som genereres fra tabellen. Aldri rediger avledede formater — segmenttabellen er kilden.
|
|
* **Tilhørighet:** Alle jobber, mediefiler og metadata knyttes til riktig samlings-node via edges. Hent config (prompts, domene) fra samlings-nodens JSONB-metadata.
|