synops/docs/concepts/podcastfabrikken.md
vegard 7eae02eeb5 Fullfør oppgave 7.5: Segmenttabell-migrasjon og SRT-pipeline
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>
2026-03-17 18:19:00 +01:00

12 KiB

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 tekstSELECT 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.

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.