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>
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.
- 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). - 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ørstwhisper_transcribe, deretteropenrouter_analyze(som trigges automatisk ved fullført transkripsjon). - Transkripsjon (faster-whisper): Maskinrommet kaller faster-whisper-server (OpenAI-kompatibelt API,
POST /v1/audio/transcriptions) medresponse_format=srtog mottar SRT direkte. Modell:mediummedinitial_prompt(navneliste). Modellvalg er en sentral serverinnstilling — null konfigurasjon per samling. - Lagring av transkripsjon (PostgreSQL): Maskinrommet parser SRT-responsen og skriver segmenter til
transcription_segments-tabellen. Hver kjøring grupperes medtranscribed_at-tidsstempel. Medianoden oppdateres med sammenhengende tekst icontent-feltet. - 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
- Ren tekst —
- 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" }) Sedocs/concepts/publisering.md§ "Presentasjonselementer er noder".
- Tittel-forslag → node med
- 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.
- For nye episoder: AI-genererte noder presenteres som forslag. Redaksjonen kan godkjenne, redigere, eller opprette egne varianter (nye noder med
- Publisering (PostgreSQL): Ved "Godkjenn" markeres aktive varianter. Maskinrommet rendrer episodeside og oppdaterer RSS basert på aktive noder.
- 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:
- Under innspilling i LiveKit produserer Whisper chunks i sanntid
- 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)
- Konsoliderer live-chunks til segmenter i
- Lydfilen lagres i CAS med edge til episode-noden
- 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 ienclosure-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/transcriptionsmedresponse_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:
mediummedinitial_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=noeksplisitt 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
errori 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.