From 7eae02eeb531fed2d7b087735431fcf561caf6ff Mon Sep 17 00:00:00 2001 From: vegard Date: Tue, 17 Mar 2026 18:19:00 +0100 Subject: [PATCH] =?UTF-8?q?Fullf=C3=B8r=20oppgave=207.5:=20Segmenttabell-m?= =?UTF-8?q?igrasjon=20og=20SRT-pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 8 +- docs/arkitektur.md | 18 + docs/concepts/adminpanelet.md | 121 +++++ docs/concepts/podcastfabrikken.md | 318 +++++++------ docs/concepts/publisering.md | 762 ++++++++++++++++++++++++++++++ docs/features/ressursforbruk.md | 201 ++++++++ docs/infra/jobbkø.md | 5 +- docs/primitiver/edges.md | 9 + docs/primitiver/nodes.md | 10 +- docs/primitiver/traits.md | 205 ++++++++ docs/retninger/README.md | 6 + maskinrommet/src/transcribe.rs | 320 ++++++++++--- tasks.md | 3 +- 13 files changed, 1757 insertions(+), 229 deletions(-) create mode 100644 docs/concepts/adminpanelet.md create mode 100644 docs/concepts/publisering.md create mode 100644 docs/features/ressursforbruk.md create mode 100644 docs/primitiver/traits.md diff --git a/CLAUDE.md b/CLAUDE.md index 94e908a..2167cb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,13 +37,15 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`: - `docs/primitiver/` — Spesifikasjoner for kjerneprimitivene: - `nodes.md` — Node-skjema, node_kind, visibility, CAS-noder, eierskap - `edges.md` — Edge-skjema, typer, metadata, systemedges + - `traits.md` — Trait-system: evner/funksjonalitet for samlingsnoder, katalog, pakker - (kommer: input, mottak, kommunikasjonsnode) - `docs/concepts/` — Brukeropplevelser/produktområder: - `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`, - `kunnskapsgrafen.md`, `valgomaten.md`, `den_asynkrone_gjesten.md` + `kunnskapsgrafen.md`, `valgomaten.md`, `den_asynkrone_gjesten.md`, + `publisering.md`, `adminpanelet.md` - `docs/features/` — Tekniske byggeklosser: - Se individuelle filer for chat, kanban, kalender, meldingsboks, - kunnskapsgraf, whiteboard, live transkripsjon, m.fl. + kunnskapsgraf, whiteboard, live transkripsjon, ressursforbruk, m.fl. - `docs/proposals/` — Idébank med 32+ uimplementerte forslag (se README.md) - `docs/setup/` — Oppsett og drift: - `produksjon.md` — Steg-for-steg oppsett av Hetzner VPS fra scratch @@ -83,7 +85,7 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`: - `auth.sidelinja.org` — Authentik SSO - `git.sidelinja.org` — Forgejo (SSH port 222) - `vegard.info` — Separat nettsted - - `synops.no` — Plattformdomene (reservert, ikke i bruk ennå) + - `synops.no` — Plattformdomene (placeholder, klar for subdomener) ## Git - **Repos i Forgejo:** diff --git a/docs/arkitektur.md b/docs/arkitektur.md index bff2e89..52d5570 100644 --- a/docs/arkitektur.md +++ b/docs/arkitektur.md @@ -166,6 +166,24 @@ er en cache som regenereres on-demand. | Reverse proxy | Caddy | Auto-TLS, enkel config | | Lyd/video | LiveKit | WebRTC, self-hosted | +## Traits — samlingsnoder med evner + +Samlingsnoder berikes med **traits** — navngitte evner som aktiverer +spesifikk funksjonalitet i frontend og backend. En samling med +`publishing`-trait blir et nettmagasin. Legg til `podcast` og den +blir et podcaststudio. Fjern `chat` og diskusjonsfunksjonen forsvinner. + +Traits er komposisjon, ikke typer. Forhåndsdefinerte pakker +(nettmagasin, podcaststudio, redaksjon osv.) er bare snarveier for +vanlige kombinasjoner — brukeren kan tilpasse fritt etterpå. + +Rendret innhold (HTML for publiserte artikler, feeds, OG-bilder) +lagres i CAS som avledede representasjoner. Caddy serverer direkte +fra disk uten å treffe applikasjonslagene. + +Full spesifikasjon: `docs/primitiver/traits.md` +Publiseringsflyt: `docs/concepts/publisering.md` + ## Retninger Arkitekturen er basert på vedtatte retninger dokumentert i diff --git a/docs/concepts/adminpanelet.md b/docs/concepts/adminpanelet.md new file mode 100644 index 0000000..ff67ae6 --- /dev/null +++ b/docs/concepts/adminpanelet.md @@ -0,0 +1,121 @@ +# Adminpanelet — Serveradministrasjon + +## Oversikt + +Sentralt administrasjonspanel for hele Synops-instansen. Tilgjengelig +kun for server-admins (Vegard). Dekker alt som ikke er per-samling: +AI-konfigurasjon, ressursstyring, systemvarsler og serverhelse. + +Adminpanelet er en del av SvelteKit-frontenden, bak egen +tilgangskontroll (admin-edge til server-noden eller Authentik-gruppe). + +## Moduler + +### 1. AI Gateway-styring + +Konfigurasjon av LiteLLM og modellruting. Ref: `docs/infra/ai_gateway.md`. + +- **Modelloversikt:** Liste over tilgjengelige modeller med status, kostnad per token, latens-snitt +- **API-nøkler:** Legg til, rotér og deaktiver nøkler for OpenRouter, Anthropic, Google, xAI osv. Nøkler vises aldri i klartekst etter lagring +- **Ruting-regler:** Hvilken modell brukes for hvilken jobbtype (transkripsjonsanalyse, oppsummering, tagging, diktat-cleanup osv.) +- **Fallback-kjeder:** Primærmodell → fallback → siste utvei. Per jobbtype +- **Forbruksoversikt:** Aggregert ressursforbruk per samling, per jobbtype, per tidsperiode. Dekker AI-tokens, Whisper-tid, TTS-tegn, CAS-lagring, båndbredde og LiveKit-tid. Ref: `docs/features/ressursforbruk.md` +- **Prompt Lab-tilgang:** Snarvei til testing av prompts mot faktisk data. Ref: `docs/features/prompt_lab.md` + +### 2. Ressursstyring + +Kontroll over CPU, minne og prioritering av bakgrunnsjobber. + +- **Jobbkø-oversikt:** Aktive, ventende og feilede jobber. Filtrer på type, samling, status. Manuell retry/avbryt +- **Prioritetsregler:** Konfigurer relativ prioritet mellom jobbtyper (f.eks. live transkripsjon > batch-transkripsjon > embedding-generering) +- **Ressursgrenser:** Maks samtidige jobber per type, CPU/minne-grenser per worker-container +- **Ressurs-governor:** Automatisk nedprioritering av tunge jobber (Whisper, embedding) under aktive LiveKit-sesjoner. Konfigurerbar terskel +- **Disk-status:** CAS-lagring, PG-størrelse, mediefiler. Visuell oversikt med varsling ved terskelverdier (ref: pruning-logikk i `docs/retninger/maskinrommet.md`) + +### 3. Systemvarsler og vedlikeholdsmodus + +Varsle brukere om planlagt nedetid, oppdateringer eller hendelser. +Kritisk for å unngå avbrudd midt i møter, podcast-opptak eller +publisering. + +#### Varslingsmekanisme + +- **Sanntidsvarsel via STDB:** Maskinrommet skriver en varslingsnode + som frontend abonnerer på. Vises som banner/toast i alle aktive + klienter umiddelbart +- **Varslingstyper:** + - `info` — generell melding (f.eks. "Ny funksjonalitet tilgjengelig") + - `warning` — planlagt vedlikehold med nedtelling + - `critical` — umiddelbar handling kreves +- **Nedtelling:** Admin setter tidspunkt for vedlikehold. Frontend + viser nedtelling: "Serveren restartes om 15 minutter" +- **Aktive sesjoner-sjekk:** Før vedlikehold, vis oversikt over pågående + aktivitet: + - Aktive LiveKit-rom (møter, opptak) + - Brukere med ulagrede endringer (collaboration-sesjoner) + - Pågående jobbkø-jobber +- **Graceful shutdown-sekvens:** + 1. Varsel sendes X minutter i forveien (konfigurerbart, default 15 min) + 2. Nye LiveKit-rom blokkeres etter varsling + 3. Påminnelse ved T-5 min og T-1 min + 4. Jobbkøen stopper å plukke nye jobber + 5. Vent til aktive jobber fullføres (med timeout) + 6. Restart + +#### Varslingsnode + +```jsonc +{ + "node_kind": "system_announcement", + "title": "Planlagt vedlikehold", + "content": "Serveren oppdateres kl. 22:00. Forventet nedetid: 10 minutter.", + "metadata": { + "announcement_type": "warning", + "scheduled_at": "2026-03-17T22:00:00Z", + "expires_at": "2026-03-17T22:30:00Z", + "blocks_new_sessions": true + } +} +``` + +Vises for alle med `visibility: open`. Forsvinner automatisk etter +`expires_at`. + +### 4. Serverhelse + +Sanntidsoversikt over systemtilstand. + +- **Tjeneste-status:** PG, STDB, Caddy, Authentik, LiteLLM, Whisper, LiveKit — oppe/nede/degradert +- **Metrikker:** CPU, minne, disk, nettverkstrafikk +- **PG-helse:** Tilkoblingspool, aktive spørringer, replikerings-lag (fremtidig) +- **STDB-helse:** Minnebruk, antall abonnenter, graf-størrelse +- **Logg-tilgang:** Siste feil og advarsler fra alle tjenester, filtrerbart +- **Backup-status:** Siste vellykkede backup per type, neste planlagte kjøring + +### 5. Bruker- og tilgangsoversikt + +- **Aktive brukere:** Hvem er pålogget nå, siste aktivitet +- **Authentik-integrasjon:** Snarvei til Authentik admin for brukerhåndtering +- **Samlingsoversikt:** Alle samlinger med eier, traits, størrelse, aktivitetsnivå + +## Tilgang + +Adminpanelet er *ikke* en trait — det er en plattformfunksjon som +eksisterer utenfor samlings-modellen. Tilgang styres via: + +- Authentik-gruppe `synops-admin` +- Eller `admin`-edge til en dedikert server-node + +Vanlige brukere ser aldri adminpanelet. Ruten er skjult og +tilgangskontrollert server-side. + +## Implementeringsstrategi + +Adminpanelet bygges inkrementelt. Første prioritet er det som trengs +for daglig drift: + +1. **Systemvarsler** — kritisk for å unngå avbrudd +2. **Jobbkø-oversikt** — nødvendig for feilsøking +3. **AI Gateway-konfigurasjon** — nødvendig når AI-features aktiveres +4. **Serverhelse** — nyttig men ikke blokkerende +5. **Ressursstyring** — optimalisering, kan vente diff --git a/docs/concepts/podcastfabrikken.md b/docs/concepts/podcastfabrikken.md index b46da86..bab7e32 100644 --- a/docs/concepts/podcastfabrikken.md +++ b/docs/concepts/podcastfabrikken.md @@ -1,147 +1,171 @@ -# 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 filsystem, AI, databaser og CI/CD. - -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):** Rust-worker kaller faster-whisper-server (OpenAI-kompatibelt API, `POST /v1/audio/transcriptions`) med `response_format=srt` og mottar SRT direkte. Modell: `Systran/faster-whisper-medium` med `initial_prompt` (navneliste). -4. **Lagring av transkripsjon (Git):** Rust-worker committer SRT-filen til Forgejo. SRT er master-formatet — redigerbart, tidsstemplet, og et etablert standardformat. Git gir diff, historikk og sporbarhet. Redaksjonen kan redigere SRT direkte. -5. **Avledede formater (PostgreSQL):** Ved commit (via Forgejo webhook) parser en Rust-worker SRT-filen og genererer: - * **Ren tekst** — strippes fra SRT (fjern tidsstempler/sekvensnummer) for lesbart publiseringsdokument - * **Segmenter** — tidsstemplede utdrag koblet til Aktører/Temaer i kunnskapsgrafen - * **Full-text søkeindeks** — for oppslag på tvers av episoder -6. **AI-Analyse (OpenRouter):** Transkripsjonen sendes til OpenRouter (Claude-modell) for uttrekk av forslag til tittel, sammendrag, show notes og kapittler. -7. **Manuell Godkjenning & Fletting (SvelteKit):** - * *For nye episoder:* Presenteres som et ferskt utkast. - * *For oppdateringer:* Viser AI-ens nye forslag side-om-side med eksisterende metadata. Redaksjonen kan da velge hva som skal beholdes eller flettes (merge). -8. **Publisering (PostgreSQL):** Ved "Godkjenn" lagres metadataene permanent i databasen. -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: **SRT** (nedlastbar undertekstfil — master-kopi fra Git) -* Fane: **Ren tekst** (lesbart transkripsjonsdokument — avledet fra SRT, lagret i PG) - -### 2.2 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 én SRT-fil - - 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. 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 `` (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 ``:** URL-en i `enclosure`-taggen (som peker på `.mp3`-filen) oppdateres i databasen til å reflektere det *nye* filnavnet. -* **RSS ``:** 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). - -## 4. 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, ikke verbose JSON. Verbose JSON inneholder diagnostikk (tokens, logprob, temperatur) som ikke har verdi for oss. SRT gir tidsstempler + tekst i et etablert format som er redigerbart, diffbart i Git, og trivielt å parse til ren tekst og segmenter. -* **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 uten VAD | -| `large-v3` + VAD | ~31 min | 964 | 28291 | God kvalitet, men noen navnefeil | -| `large-v3` + VAD + prompt | ~31 min | 964 | 28295 | Best kvalitet, riktige egennavn | - -* **Anbefaling:** `medium` + `initial_prompt` som standard. `large-v3` + VAD + prompt for best mulig kvalitet der det er verdt ventetiden. -* **Viktig:** `large-v3` KREVER `vad_filter=true` — uten hallusinerer modellen repeterende tekst. -* **Språk:** Sett `language=no` eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk. - -### 4.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 Rust-worker fra en statisk navneliste + aktører i kunnskapsgrafen: -``` -Sidelinja podcast med Vegard Nøtnæs, Trond Sørensen, Arne Eidshagen, -Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie -``` - -## 5. Per samlings-node konfigurasjon -Hver samlings-node (f.eks. Sidelinja) har sin egen podcast-konfigurasjon, lagret som JSONB-metadata på noden: - -### 5.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. - -### 5.2 Transkripsjoner (Git-repostruktur) -Det opprettes **ett Forgejo-repo per samlings-node** for SRT-filer, slik at historikk og redigering ikke blandes på tvers av podcaster. - -#### Repo-oppretting -Repoet opprettes **on-demand** ved første transkripsjonsjobb for en samlings-node, via Forgejo API. Ikke alle samlings-noder trenger transkripsjonsrepo. - -#### Filnavnkonvensjon -Flat struktur med prosesseringstidspunkt som filnavn: -``` -20260315_143022.srt -20260401_091500.srt -``` -* **Format:** `YYYYMMDD_HHMMSS.srt` — settes automatisk av Rust-worker ved prosessering -* **Sortering:** Kronologisk i enhver filvisning -* **Unikhet:** Tidsstempel garanterer unikhet uten suffiks-logikk -* **Ingen metadata i filnavn:** Episodenummer, tittel, slug og annen metadata lever i PostgreSQL, ikke i filnavnet. Filnavnet er en stabil identifikator som aldri endres. - -#### Mediefiler matcher Git -Lydfilen i CAS bruker **samme navnekonvensjon** som SRT-filen: `20260315_143022.mp3` matcher `20260315_143022.srt`. Dette kobler mediefil og transkripsjon uten databaseoppslag. - -#### Reprosessering (redigert lyd) -Når en lydfil redigeres og transkriberes på nytt, **beholdes det opprinnelige filnavnet**. Rust-worker overskriver SRT-filen i Git — historikken viser endringene via `git log`/`git diff`. Mediefilen i arkivet døpes om til å matche Git-filnavnet dersom den opprinnelig hadde et annet navn. - -#### Forgejo-bruker -En dedikert servicebruker **"serverassistent"** opprettes i Forgejo med push-tilgang til transkripsjonsrepoer. Ingen admin-rettigheter. - -#### Webhook-flyt -``` -Forgejo push-webhook → SvelteKit POST /api/webhooks/forgejo - → INSERT INTO job_queue (type: 'srt_parse', payload: {repo, commit, node_id}) - → Rust-worker plukker opp jobben og parser SRT → avledede formater i PG -``` -SvelteKit validerer webhook-signatur og legger jobb i køen. Rust-worker forblir en ren kø-consumer uten eget HTTP-endepunkt. - -#### SRT-editor -En enkel SRT-editor bygges i SvelteKit (Lag 3, sammen med Podcastfabrikken): segmenter som redigerbare tekstfelt med tidsstempler, "Lagre" committer tilbake til Git via Forgejo API. Forgejo web-UI fungerer som fallback for power users. - -### 5.3 AI-prompts -* **Whisper `initial_prompt`:** Navnelister og kontekst lagres per samlings-node i metadata (`whisper_prompt`). Rust-worker bygger prompten fra statisk liste + aktører knyttet til samlings-noden via edges. -* **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. - -### 5.4 RSS-feed -SvelteKit genererer `/feed.xml` dynamisk basert på domenet forespørselen kommer fra (matcher samlings-nodens domene-metadata), eller node-slug som fallback. - -### 5.5 Statistikk -Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig samlings-node basert på domene i loggen. - -## 6. 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 alltid i Git. Aldri rediger avledede formater direkte i PG — de regenereres fra Git-kilden. -* **Tilhørighet:** Alle jobber, mediefiler og metadata knyttes til riktig samlings-node via edges. Hent config (prompts, domene) fra samlings-nodens JSONB-metadata. \ No newline at end of file +# 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 `` (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 ``:** URL-en i `enclosure`-taggen (som peker på `.mp3`-filen) oppdateres i databasen til å reflektere det *nye* filnavnet. +* **RSS ``:** 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. diff --git a/docs/concepts/publisering.md b/docs/concepts/publisering.md new file mode 100644 index 0000000..38c5d45 --- /dev/null +++ b/docs/concepts/publisering.md @@ -0,0 +1,762 @@ +# Publisering — Fra privat tanke til offentlig artikkel + +## Kjerneflyt + +En node reiser fra privat til publisert uten kopiering eller konvertering. +Den får bare nye edges til samlinger med stadig rikere traits. + +``` +1. Privat tanke Node uten edges. Bare din. + ↓ (legg til belongs_to → kommunikasjonsnode) +2. Delt med venner Diskutert, kommentert, forbedret. + ↓ (legg til belongs_to → bredere gruppe) +3. Bredere gruppe Flere perspektiver, mer feedback. + ↓ (legg til belongs_to → samling med publishing-trait) +4. Publisert Maskinrommet rendrer HTML, genererer URL. + ↓ +5. Verden leser synops.no/pub/slug/id — Caddy → maskinrommet → CAS. +``` + +Tilbaketrekking: fjern `belongs_to`-edgen → artikkelen forsvinner fra +publikasjonen. Noden lever videre privat. + +## To publiseringsmodeller + +Flyten ovenfor dekker den **personlige** modellen — du eier samlingen og +publiserer direkte. Men en publikasjon med flere bidragsytere trenger +**redaksjonell kontroll**: noen skriver, andre bestemmer hva som publiseres. + +Forskjellen er én innstilling i `publishing`-traiten: + +```jsonc +"publishing": { + "slug": "sidelinja-magasin", + "require_approval": true, + "submission_roles": ["member"] +} +``` + +| Innstilling | Personlig blogg | Redaksjonell publikasjon | +|---|---|---| +| `require_approval` | `false` | `true` | +| `submission_roles` | — (ikke relevant) | `["member"]` eller `["member", "reader"]` | +| Hvem publiserer | Eieren selv | Owner/admin godkjenner | +| Edge ved publisering | `belongs_to` direkte | `submitted_to` → godkjenning → `belongs_to` | + +`submission_roles` styrer hvem som kan sende inn. `member` betyr at du +må være medlem av samlingen. `reader` åpner for at lesere også kan +foreslå innhold — åpen innsending. + +### Personlig publisering (`require_approval: false`) + +``` +Ole eier "Oles blogg" (samling med publishing-trait) +Ole skriver artikkel → legger til belongs_to-edge → publisert +``` + +Ingen mellomsteg. Maskinrommet rendrer HTML til CAS umiddelbart. + +### Redaksjonell publisering (`require_approval: true`) + +``` +Redaktøren eier "Nettmagasinet" (samling med publishing-trait) +Ole er medlem (member-edge til samlingen) + +1. Ole skriver artikkel (privat node) +2. Ole sender inn → submitted_to-edge til Nettmagasinet +3. Redaktøren ser den → visning: noder med submitted_to til min samling +4. Diskusjon → kommunikasjonsnode med edges til artikkel + deltakere +5. Ole reviderer (samme node, nytt innhold) +6. Redaktøren godkjenner → submitted_to erstattes med belongs_to +7. Redaktøren planlegger → publish_at i edge-metadata +8. Maskinrommet rendrer → HTML til CAS ved publish_at +``` + +Artikkelen er alltid én node. Den kopieres aldri. Den reiser gjennom +systemet ved at edges endres. + +## Innsending: `submitted_to`-edge + +Ny edge-type for redaksjonell publisering: + +``` +artikkel ──submitted_to──→ samling (med publishing-trait) +``` + +### Edge-metadata + +```jsonc +{ + "status": "pending", + "submitted_at": "2026-03-17T10:00:00Z" +} +``` + +### Status-verdier + +| Status | Betydning | Hvem endrer | +|---|---|---| +| `pending` | Venter på vurdering | Settes automatisk ved innsending | +| `in_review` | Under vurdering | Redaktør | +| `revision_requested` | Forfatter må revidere | Redaktør (legger til feedback) | +| `rejected` | Avvist | Redaktør | +| `approved` | Godkjent, klar for publisering | Redaktør | + +Metadata ved tilbakemelding: + +```jsonc +{ + "status": "revision_requested", + "submitted_at": "2026-03-17T10:00:00Z", + "feedback": "Trenger sterkere intro. Kan du utdype kilden i avsnitt 3?", + "feedback_by": "uuid-redaktør", + "feedback_at": "2026-03-17T14:30:00Z" +} +``` + +### Fra godkjent til publisert + +Når redaktøren godkjenner: + +1. `submitted_to`-edgen slettes +2. `belongs_to`-edge opprettes fra artikkel → samling +3. Valgfritt: `publish_at` i edge-metadata for planlagt publisering + +```jsonc +// belongs_to-edge ved planlagt publisering +{ + "publish_at": "2026-04-01T08:00:00Z", + "approved_by": "uuid-redaktør", + "approved_at": "2026-03-20T16:00:00Z" +} +``` + +Maskinrommet sjekker periodisk for `belongs_to`-edges med `publish_at` +i fortiden som ikke er rendret ennå. Ved treff: render HTML → CAS → +oppdater RSS. + +Umiddelbar publisering: `publish_at` settes ikke, maskinrommet rendrer +med en gang. + +### Avvisning + +Redaktøren setter status til `rejected`. Artikkelen forblir Oles +private node — den forsvinner fra redaktørens visning, men Ole mister +ingenting. Ole kan revidere og sende inn på nytt (ny `submitted_to`-edge). + +## Redaktørens arbeidsflate + +Redaktørens "innboks" er **ikke** en egen node — det er en **visning**: + +> "Noder med `submitted_to`-edge til min samling, gruppert på status" + +Dette er en spørring mot grafen, konsistent med prinsippet om at +visninger er spørringer, ikke containere. Samlingens `kanban`-trait +(hvis aktiv) kan drive denne visningen som et brett: + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Innkomne │ │ Under │ │ Godkjent │ │ Planlagt │ +│ │ │ vurdering│ │ │ │ │ +│ Artikkel │ │ Artikkel │ │ Artikkel │ │ Artikkel │ +│ fra Ole │ │ fra Lise │ │ fra Kari │ │ fra Arne │ +│ │ │ │ │ │ │ 1. april │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ +``` + +Drag-and-drop mellom kolonner endrer `status` i edge-metadata. +Siste kolonne ("Planlagt") setter `publish_at`. + +## Diskusjon om innsendt artikkel + +Når redaktøren vil gi feedback utover et kort notat i edge-metadata, +opprettes en kommunikasjonsnode — samme primitiv som brukes for chat +og møter: + +``` +Kommunikasjonsnode (node_kind: "communication") + ←── member ── Ole + ←── member ── Redaktøren + ←── belongs_to ── artikkelen (kontekst) +``` + +Samtalen lever som en vanlig tråd. Meldinger er noder med +`belongs_to`-edge til kommunikasjonsnoden. Når artikkelen er publisert +ligger samtalehistorikken igjen som arkiv — nyttig for revisjonshistorikk. + +## Håndhevelse i maskinrommet + +Maskinrommet validerer alle edge-operasjoner. For publiseringssamlinger +med `require_approval: true`: + +- **`belongs_to`-edge til samlingen:** Kun owner/admin kan opprette. + Forsøk fra member/reader avvises. +- **`submitted_to`-edge til samlingen:** Tillatt for roller i + `submission_roles`. Maskinrommet sjekker at brukeren har riktig + rolle-edge til samlingen. +- **Status-endring på `submitted_to`:** Kun owner/admin kan endre + status (godkjenne, avvise, be om revisjon). Forfatter kan kun + trekke tilbake (slette sin `submitted_to`-edge). + +For samlinger med `require_approval: false`: +- `belongs_to`-edge tillatt for owner/admin direkte. +- Ingen `submitted_to`-logikk. + +## Hvorfor edge-metadata, ikke workflow-noder + +En alternativ modell er å gjøre hvert beslutningspunkt til sin egen +node — en "innsending"-node, en "godkjenning"-node, etc. Det ville +følge "alt er noder"-filosofien konsekvent. + +Vi velger edge-metadata i stedet fordi: + +1. **Status er en egenskap ved relasjonen**, ikke en ting som + eksisterer i seg selv. "Oles artikkel er innsendt til magasinet" + beskriver forholdet mellom to noder, ikke en tredje entitet. +2. **Graf-hygiene.** Tre-fire ekstra noder per artikkel for å + representere tilstand skaper bloat uten informasjonsgevinst. +3. **Enklere spørringer.** "Vis innsendte artikler" er én spørring + mot edges. Med workflow-noder trenger du joins. + +Unntaket er når et beslutningspunkt trenger *egen kontekst* — +samtaler, deltakere, historikk. Da opprettes en kommunikasjonsnode +(se "Diskusjon om innsendt artikkel" ovenfor). Men det er en +samtale, ikke en workflow-tilstand. + +## Presentasjonselementer er noder + +En ingress er en tekst. En overskrift er en tekst. Et forsidebilde er +et bilde. Alt som vises *om* en artikkel på forsiden er en *ting med +egen forfatter, eget tidspunkt, og potensielt flere varianter*. Det +er noder, ikke felt. + +``` +Artikkel (innholdsnoden) + ←── title ──── "Trikken kan bli gratis" (node, content) + ←── title ──── "Gratis trikk fra 2027?" (node, content) + ←── summary ── "Oslo bystyre vurderer..." (node, content) + ←── summary ── "Fra 2027 kan trikken..." (node, content, AI-generert) + ←── og_image ── Trikk i solnedgang (node, media) + ←── og_image ── Bystyresalen (node, media) +``` + +### Prinsipper + +1. **Hvis det er en tekst om en annen tekst, er det en node.** + Ingress, undertittel, OG-beskrivelse, tweet-tekst, podcast-intro — + alt som presenterer en annen node er selv en node. +2. **`nodes.title` er det interne displaynavnet.** "Vegard", "Møte + 15. mars", arbeidstittelen i redaksjonen. Det er ikke det som vises + på forsiden til leserne. +3. **Varianter er naturlige.** Flere noder med samme edge-type til + samme artikkel = flere varianter. Ingen spesiell mekanisme. + +### Edge-metadata for varianter + +```jsonc +// title-edge fra tittelnode → artikkel +{ + "variant": "editorial", // "editorial", "ai", "social", "rss" + "language": "no" +} +``` + +| Edge-type | Hva | Eksempel | +|---|---|---| +| `title` | Publisert overskrift | "Trikken kan bli gratis" | +| `subtitle` | Undertittel | "Bystyret splittet om finansiering" | +| `summary` | Ingress / forhåndsvisning | 1-2 setninger | +| `og_image` | Forsidebilde / OpenGraph-bilde | CAS-media-node | +| `og_description` | OG/meta-beskrivelse | Kort tekst for deling | + +### Automatisk A/B-testing + +Når det finnes flere noder med samme edge-type til samme artikkel, +er default å A/B-teste automatisk. + +**Mekanikk:** +- Maskinrommet roterer varianter ved forside-rendering +- Logger hvilken variant som ble vist (impression) og om leseren + klikket videre til artikkelen (konvertering) +- Normaliserer CTR mot tidspunkt — klikk kl. 08 mandag morgen har + annen baseline enn kl. 22 lørdag kveld +- Etter statistisk signifikans → vinneren markeres, taperen + deaktiveres +- Redaktøren kan alltid overstyre — pin en spesifikk variant + +**Edge-metadata under testing:** + +```jsonc +{ + "variant": "editorial", + "ab_status": "testing", // "testing", "winner", "retired" + "impressions": 4821, + "clicks": 312, + "ctr": 0.0647, + "started_at": "2026-04-01T08:00:00Z" +} +``` + +**Oppførsel:** +- Én variant → ingen A/B, bare visning. Null overhead. +- To+ varianter → automatisk testing. Null konfigurasjon. +- Redaktøren trenger ikke vite at dette skjer. Skriver du én tittel + får du én tittel. Skriver du to får du automatisk testing. + +### Hva med podcast-episoder? + +Samme mønster. AI-analyse i podcastfabrikken genererer forslag til +tittel, sammendrag, show notes og kapittler — disse er noder med +edges til episoden: + +``` +Episode #42 (innholdsnode) + ←── title ──── "Klimapolitikkens blinde flekk" (AI-generert) + ←── title ──── "Ep. 42: Klimapolitikk" (redaksjonell) + ←── summary ── AI-sammendrag (node, content) + ←── summary ── Manuelt sammendrag (node, content) + ←── show_notes ── AI-genererte show notes (node, content) + ←── show_notes ── Redigerte show notes (node, content) + ←── chapter ── "00:00 Intro" (node, metadata: { at: "00:00:00" }) + ←── chapter ── "05:23 Intervju" (node, metadata: { at: "00:05:23" }) +``` + +Redaksjonen velger mellom AI-forslag og manuelt skrevne varianter. +A/B-testing gir mening for titler og sammendrag i RSS-feeden. +Kapittler er ikke A/B-testbare, men de er fortsatt noder fordi de +har egne tidsstempler, kan redigeres uavhengig, og kan ha flere +varianter (f.eks. AI-genererte vs. manuelt justerte). + +## Skriveopplevelse vs. forside-kontroll + +### Problemet + +TipTap lar forfatteren formatere fritt — overskrifter, blockquotes, +inline-stiler, bilder. Det er bra for artikkelen. Men forsiden trenger +forutsigbar, enhetlig struktur. Hvis forfatteren åpner med en `

`, +bruker kursiv overalt, eller legger inn en tabell i første avsnitt, +ser forside-kortet rotete ut. + +### Løsningen: forsiden leser aldri fra artikkelens dokument + +Forsiden bruker *kun* presentasjonsnodene — aldri `metadata.document`: + +| Forside-element | Kilde | Format | +|---|---|---| +| Overskrift | `title`-node | Ren tekst, ingen formatering | +| Ingress | `summary`-node | Ren tekst, ~200 tegn | +| Bilde | `og_image`-node | CAS-media-node | +| Forfatter | `created_by` → person-node | `title` på personnoden | +| Dato | `publish_at` i edge-metadata | Tidsstempel | + +Presentasjonsnodene har kun `content`-feltet (ren tekst). Ingen +`metadata.document`, ingen TipTap-formatering. Forfatteren skriver +tittel og ingress i egne, enkle tekstfelt — ikke i rik-editoren. +Det er en bevisst begrensning: forsiden er designerens domene. + +### Artikkelsiden: fri formatering, styrt av tema + +Inne i artikkelen rendres `metadata.document` fritt — TipTap-struktur +er ønsket. Men forfatteren bestemmer *struktur* (overskrifter, +blockquotes, bilder, lister), temaet bestemmer *stil* (fonter, farger, +spacing, bredde). Tera-templaten wrapper dokumentet i tema-CSS som +normaliserer utseendet. + +``` +Forside: title-node.content + summary-node.content + og_image + → ren tekst, tema-styrt layout, alltid forutsigbart + +Artikkelside: metadata.document → Tera-template + tema-CSS + → rik formatering, men temaet styrer utseende +``` + +### Publiseringssteget i frontend + +Når forfatteren publiserer, vises et publiseringspanel ved siden av +(eller over) editoren: + +``` +┌─────────────────────────────────────────────┐ +│ Publiser til: Nettmagasinet │ +├─────────────────────────────────────────────┤ +│ Tittel: [ Trikken kan bli gratis ] │ +│ Ingress: [ Oslo bystyre vurderer gratis ] │ +│ [ kollektivtransport fra 2027. ] │ +│ Bilde: [ 🖼 Dra bilde hit / velg ] │ +│ Slug: [ /om-trikken ] │ +│ Publiser: [ Nå ▾ ] [ Publiser ] │ +├─────────────────────────────────────────────┤ +│ + Legg til variant (for A/B-testing) │ +└─────────────────────────────────────────────┘ +``` + +Hvert felt oppretter en presentasjonsnode med riktig edge-type. +Feltene er *enkle tekstfelt* — ingen rik-editor, ingen formatering. +"Legg til variant" oppretter en ekstra node av samme type. + +Forfatteren som bare vil publisere fyller ut tittel og ingress og +trykker publiser. Forfatteren som vil A/B-teste legger til en variant. +Redaktøren kan overstyre begge deler. + +### Forhåndsvisning med publikasjonens tema + +Forfatteren kan forhåndsvise artikkelen med målpublikasjonens +utseende direkte i editoren: + +``` +┌─ Editor ──────────────────────────────────────┐ +│ Visning: [ Redigering ▾ ] [ Trikkemagasinet ] │ +│ │ +│ (innholdet vises med temaets fonter, farger, │ +│ bredde, spacing — WYSIWYG for publisert │ +│ resultat) │ +└────────────────────────────────────────────────┘ +``` + +Teknisk: frontend henter `theme_config` fra samlingens +`publishing`-trait og appliserer CSS-variablene på editor-containeren. +Ingen rendering, ingen maskinrommet-kall — ren CSS-bytte i frontend. + +Forutsetning: forfatteren har edge til samlingen — enten `member_of` +eller `intended_for` (se nedenfor). + +### `intended_for`-edge: artikkelens reise + +Forfatteren markerer tidlig hvilken publikasjon artikkelen er ment +for. Det er en edge — `intended_for` — som uttrykker intensjon uten +å sende inn: + +``` +intended_for → submitted_to → belongs_to +(arbeidsfase) (vurdering) (publisert) +``` + +Én relasjon mellom artikkel og samling. Edge-typen *er* tilstanden: + +| Edge-type | Fase | Hva skjer | +|---|---|---| +| `intended_for` | Skriving | Tema-forhåndsvisning i editor, samlingen vet ingenting | +| `submitted_to` | Vurdering | Redaktøren ser artikkelen, status-metadata styrer flyten | +| `belongs_to` | Publisert | Maskinrommet rendrer, artikkelen er live | + +`intended_for` gir systemet kontekst fra starten: +- Hvilken tema-CSS som tilbys i editoren +- Hvilke presentasjonselementer samlingen krever (f.eks. Trikkemagasinet + krever ingress + bilde, bloggen bare tittel) +- Hvem som potensielt vil motta artikkelen ved innsending + +Forfatteren kan endre `intended_for` fritt — det er bare en +arbeidshypotese. Først ved eksplisitt innsending konverteres den +til `submitted_to`. + +## Temaer og forside-layout + +### Innebygde temaer + +Hvert tema er et sett med Tera-templates (Jinja2-lignende, Rust-native) +og CSS-variabler. Tema velges i `publishing`-traiten: + +```jsonc +"publishing": { + "slug": "nettmagasinet", + "theme": "avis", + "theme_config": { + "colors": { "primary": "#1a1a2e", "accent": "#e94560" }, + "typography": { + "heading_font": "Georgia, serif", + "body_font": "system-ui, sans-serif" + }, + "layout": { "max_width": "1200px" }, + "logo_hash": "cas://sha256-def456" + }, + "index_mode": "dynamic", + "index_cache_ttl": 300, + "stream_page_size": 20, + "featured_max": 4 +} +``` + +Temaet setter alle defaults. `theme_config` overstyrer spesifikke +verdier via CSS-variabler (`--color-primary`, `--font-heading`, etc.). +Fungerer meningsfullt med bare `"theme": "magasin"` — null konfigurasjon. + +| Tema | Karakter | Forside-layout | +|---|---|---| +| **Avis** | Tett, multi-kolonne, informasjonstung | Hero + sidebar + rutenett | +| **Magasin** | Store bilder, luft, editorial | Hero fullbredde + cards + kronologisk | +| **Blogg** | Enkel, én kolonne, personlig | Kronologisk liste, evt. pinned øverst | +| **Tidsskrift** | Akademisk, tekstdrevet, minimalt | Nummerliste med innholdsfortegnelse | + +Temaer er kode (Tera + CSS) som lever i repoet. Nye temaer er en +utvikleroppgave, ikke en brukeroppgave. Ingen page-builder, ingen +plugin-arkitektur — innebygde temaer som ser bra ut. + +### Redaksjonell prioritering via slots + +Redaktøren styrer forsiden gjennom `slot`-metadata på `belongs_to`-edgen: + +```jsonc +// belongs_to-edge fra artikkel → publikasjon +{ + "publish_at": "2026-04-01T08:00:00Z", + "slot": "hero", + "slot_order": 1, + "pinned": false +} +``` + +Tre plasser med økende automatisering: + +| Plass | Antall | Styring | Visning | +|---|---|---|---| +| `hero` | Maks 1 | Manuell | Stor, dominant øverst | +| `featured` | Konfigurerbart (default 4) | Manuell | Fremhevet, mindre enn hero | +| `null` (strøm) | Ubegrenset | Automatisk | Kronologisk etter `publish_at` | + +**Gode defaults uten kurerering:** Alt som publiseres havner i strømmen, +sortert på dato. Ingen hero, ingen featured — forsiden er en ren +kronologisk flyt som fungerer fra dag én uten redaksjonelt arbeid. + +**Når redaktøren griper inn:** +- Sett hero → maskinrommet setter `slot: "hero"`. Forrige hero flyttes + automatisk tilbake til strøm. +- Sett featured → `slot: "featured"`, `slot_order` bestemmer rekkefølge. + Overstiger antallet `featured_max` → eldste featured faller til strøm + (FIFO). +- Pin → artikkel blir stående i slot uavhengig av alder. + +### Forside-administrasjon i frontend + +``` +┌─────────────────────────────────────────┐ +│ HERO │ +│ [ Drag artikkel hit ] │ +├──────────┬──────────┬──────────┬────────┤ +│ FEATURED │ FEATURED │ FEATURED │ [ + ] │ +├──────────┴──────────┴──────────┴────────┤ +│ STRØM (automatisk, nyeste først) │ +│ • Artikkel 5 — 1. april │ +│ • Artikkel 4 — 28. mars [📌 Pin] │ +│ • Artikkel 3 — 25. mars [⬆ Fremhev]│ +└─────────────────────────────────────────┘ +``` + +Drag-and-drop mellom plasser. Maskinrommet oppdaterer edge-metadata +og regenererer forside (CAS eller cache-invalidering). + +### Sidetyper + +| Side | Kilde | Rendering | +|---|---|---| +| **Forside** | Hero + featured + strøm | Statisk CAS eller dynamisk cache | +| **Artikkelside** | Én node | Statisk CAS (alltid) | +| **Kategori/tag** | Artikler med bestemt tag-edge | Dynamisk, maskinrommet, paginert | +| **Arkiv** | Alle artikler kronologisk | Dynamisk, maskinrommet, paginert | +| **Søk** | Fulltekstsøk i PG | Dynamisk, maskinrommet | +| **Om-side** | Node med `page_role: "about"` | Statisk CAS | + +Enkeltartikler rendres alltid til statisk CAS. Forsiden kan være +statisk CAS (magasin, lav frekvens) eller dynamisk med cache +(nyhetsavis, høy frekvens) — styrt av `index_mode` i trait-konfig. +Kategori-, arkiv- og søkesider er alltid dynamiske med paginering. + +### Skalering for store publikasjoner + +Designet skal håndtere en nettavis med ~30.000 artikler over 30 år +(~3 per dag). Implikasjoner: + +**Enkeltartikler i CAS:** 30.000 HTML-filer á ~80KB = ~2.4 GB. Trivielt. +CAS-pruning beholder kun gjeldende `html_hash` per artikkel — eldre +versjoner prunes automatisk. + +**Forside-spørringer:** Forsiden trenger aldri alle 30.000 artikler. +Tre indekserte spørringer: + +```sql +-- Hero (maks 1) +SELECT n.* FROM nodes n JOIN edges e ON e.source_id = n.id +WHERE e.target_id = $collection AND e.edge_type = 'belongs_to' + AND e.metadata->>'slot' = 'hero'; + +-- Featured (maks N) +SELECT n.* FROM nodes n JOIN edges e ON e.source_id = n.id +WHERE e.target_id = $collection AND e.edge_type = 'belongs_to' + AND e.metadata->>'slot' = 'featured' +ORDER BY (e.metadata->>'slot_order')::int; + +-- Strøm (paginert) +SELECT n.* FROM nodes n JOIN edges e ON e.source_id = n.id +WHERE e.target_id = $collection AND e.edge_type = 'belongs_to' + AND e.metadata->>'slot' IS NULL +ORDER BY (e.metadata->>'publish_at')::timestamptz DESC +LIMIT 20 OFFSET $page; +``` + +Med indeks på `(target_id, edge_type)` og GIN-indeks på `metadata` +er dette raskt uansett samlingsstørrelse. + +**Forside-rendering:** +- `index_mode: "static"` — full HTML rendres til CAS ved publisering. + Passer for magasin/blogg med lav frekvens. +- `index_mode: "dynamic"` — maskinrommet serverer on-demand med + in-memory cache, invalidert ved publisering. `index_cache_ttl` + styrer cachens levetid. Passer for nyhetsavis med høy frekvens. + +**Bulk re-rendering ved temaendring:** Temaendring trigger batch-jobb +via jobbkøen. Maskinrommet paginerer 100 artikler om gangen, rendrer +til CAS, oppdaterer `metadata.rendered.html_hash`. Med ~100ms per +artikkel: ~50 min for 30.000. Ikke blokkerende — artikler serveres +med gammelt tema til de er re-rendret. `renderer_version` i metadata +identifiserer hvilke som gjenstår. + +**RSS-feed:** Inneholder de `rss_max_items` nyeste (default 50). +Regenereres ved publisering. Trivielt uansett samlingsstørrelse. + +## URL-struktur + +### Uten eget domene + +``` +synops.no/pub/{samlings-slug}/{node-short-id} +``` + +Eksempel: `synops.no/pub/mittmagasin/a7f3e2` + +- `samlings-slug` er unik per publiseringssamling +- `node-short-id` er et kort derivat av node-id (stabilt, permanent) +- Valgfri lesbar slug: `synops.no/pub/mittmagasin/om-trikken-og-tanker` + (lagret som metadata på noden, redirect ved endring) + +### Med eget domene + +``` +mittmagasin.no/a7f3e2 +mittmagasin.no/om-trikken-og-tanker +``` + +Domenet kobles i samlingens trait-konfigurasjon. Caddy håndterer TLS og +ruting automatisk. + +## HTML-rendering og CAS + +Rendret HTML lagres i CAS (content-addressable storage), akkurat som +andre avledede representasjoner (transkripsjoner, thumbnails). + +``` +Dokument (metadata.document) → Renderer → HTML (CAS) +Lydfil (CAS) → Whisper → Transkripsjon (content) +``` + +Noden peker på rendret resultat via metadata: + +```jsonc +{ + "metadata": { + "document": { /* TipTap/ProseMirror JSON */ }, + "rendered": { + "html_hash": "cas://sha256-abc123", + "rendered_at": "2026-03-17T14:30:00Z", + "renderer_version": 2 + } + } +} +``` + +### Serving-modell + +Caddy reverse-proxyer publiserings-URLer til maskinrommet. Maskinrommet +slår opp CAS-hash fra node-metadata og streamer filen: + +``` +Leser → Caddy → maskinrommet (slug → hash oppslag) → CAS-fil fra disk + ↑ Cache-Control: public, max-age=31536000, immutable +``` + +Maskinrommet eier mappingen mellom slug og CAS-hash — å duplisere den +til filsystemet (symlinks) eller Caddy-konfig ville vært en ekstra +synkroniseringsbyrde uten reell gevinst. CAS-hashen endres aldri, så +Caddy og nettlesere cacher aggressivt. + +For kategori-, arkiv- og søkesider serverer maskinrommet dynamisk HTML +direkte (ingen CAS), med kortere cache-TTL. + +### Gevinster + +- **Deduplisering** — rediger og angre → hashen peker tilbake til forrige + versjon uten ekstra lagring +- **Immutabel** — en gitt hash er alltid samme HTML. Caches aggressivt, + CDN-vennlig +- **Pruning fungerer** — gammel rendret HTML uten referanser ryddes bort + som alt annet i CAS +- **Revisjonshistorikk gratis** — hver publisering genererer ny hash +- **Bulk-regenerering** — `renderer_version` lar maskinrommet finne alle + noder med eldre versjon og re-rendere ved malendring +- **Én sannhetskilde** — maskinrommet eier slug→hash-mappingen, ingen + symlinks eller filsystem-synkronisering å vedlikeholde + +## Custom domain-mekanisme + +1. Bruker legger til domene i samlingens `publishing`-trait: + ```jsonc + "publishing": { "custom_domain": "mittmagasin.no", ... } + ``` +2. Maskinrommet validerer at DNS peker til serveren +3. Caddy registrerer domenet via on-demand TLS — sertifikat hentes + automatisk ved første besøk +4. Validerings-callback fra Caddy mot maskinrommet bekrefter at domenet + er registrert + +### Caddy on-demand TLS-konfigurasjon + +```caddyfile +# Dynamiske custom domains for publiseringssamlinger +:443 { + tls { + on_demand { + ask http://maskinrommet:3100/internal/verify-domain + } + } + reverse_proxy maskinrommet:3100 +} +``` + +Maskinrommet svarer 200 hvis domenet tilhører en samling med +`publishing`-trait, 404 ellers. Caddy henter kun sertifikat for +verifiserte domener. + +## RSS/Atom + +En samling med `rss`-trait genererer feed automatisk: + +``` +synops.no/pub/{slug}/feed.xml +mittmagasin.no/feed.xml +``` + +Feeden genereres på nytt ved publisering/avpublisering. For podcast-samlinger +inkluderes ``-tags med lyd-URLer. Samme mønster som eksisterende +podcastfabrikken-konsept. + +## SEO og metadata + +Ved rendering genererer maskinrommet: + +- `` fra node-tittel +- `<meta name="description">` fra første avsnitt eller manuell oppsummering +- OpenGraph-tags (tittel, beskrivelse, bilde) +- `<link rel="canonical">` for å unngå duplikat-innhold +- Strukturert data (JSON-LD) for artikler +- `<link rel="alternate" type="application/atom+xml">` for feed + +OG-defaults kan settes på samlingsnivå i `publishing`-traiten, med +mulighet for overstyring per node. + +## Tilgangskontroll + +Publisert innhold serveres *uten autentisering*. Tilgangsmodellen: + +- Samlingen har `visibility: open` (eller spesifikk publishing-logikk) +- Noder med `belongs_to`-edge til publiseringssamlingen rendres som HTML +- Noder uten slik edge er usynlige for offentligheten +- Kommentarer (hvis `comments`-trait er aktiv) kan kreve innlogging + eller tillate anonyme bidrag avhengig av konfigurasjon diff --git a/docs/features/ressursforbruk.md b/docs/features/ressursforbruk.md new file mode 100644 index 0000000..d75e75e --- /dev/null +++ b/docs/features/ressursforbruk.md @@ -0,0 +1,201 @@ +# Ressursforbruk — Måling og synliggjøring + +## Konsept + +Alle ressurskrevende operasjoner logges med naturlige enheter. +Forbruket akkumuleres på tre akser: noden som ble behandlet, +brukeren som utløste det, og samlingen det skjedde i. + +Formålet er synliggjøring og innsikt, ikke fakturering. + +## Ressurstyper + +| Ressurstype | Enhet | Hva måles | +|---|---|---| +| `ai` | tokens inn / tokens ut | LLM-kall via AI Gateway | +| `whisper` | sekunder prosessert lyd | Transkripsjons-pipeline | +| `tts` | tegn | Tekst-til-tale-generering | +| `cas` | bytes | Lagring i CAS (store/delete) | +| `bandwidth` | bytes ut | Servering av mediefiler og publisert innhold | +| `livekit` | deltaker-minutter | WebRTC-sesjoner (møter, opptak) | +| `graph` | noder / edges | Opprettelse av noder og edges i grafen | + +## Logg-skjema + +```sql +CREATE TABLE resource_usage_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + target_node_id UUID NOT NULL REFERENCES nodes(id), + triggered_by UUID REFERENCES nodes(id), -- null for system-jobber + collection_id UUID REFERENCES nodes(id), + resource_type TEXT NOT NULL, -- 'ai', 'whisper', 'tts', 'cas', 'bandwidth', 'livekit' + detail JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_resource_usage_target ON resource_usage_log(target_node_id); +CREATE INDEX idx_resource_usage_triggered ON resource_usage_log(triggered_by); +CREATE INDEX idx_resource_usage_collection ON resource_usage_log(collection_id); +CREATE INDEX idx_resource_usage_type_time ON resource_usage_log(resource_type, created_at); +``` + +## Detail-struktur per type + +### AI (LLM-kall) + +```jsonc +{ + "model_level": "fast", // "fast" | "smart" | "deep" + "model_id": "gemini-2.0-flash", + "tokens_in": 1240, + "tokens_out": 380, + "job_type": "auto_tag" // hva jobben var +} +``` + +Modellnivåer: + +| Nivå | Semantikk | Typiske modeller | +|---|---|---| +| `fast` | Billig, lav latens | Gemini Flash, Haiku | +| `smart` | Balansert | Sonnet, Gemini Pro | +| `deep` | Grundig, dyr | Opus, GPT-4 | + +### Whisper (transkripsjon) + +```jsonc +{ + "model": "medium", // "small" | "medium" | "large-v3" + "duration_seconds": 2520, // lengde på prosessert lyd + "language": "no", + "mode": "batch" // "live" | "batch" +} +``` + +### TTS (tekst-til-tale) + +```jsonc +{ + "provider": "elevenlabs", // "elevenlabs" | "local" + "characters": 8200, + "voice_id": "norwegian_male_1" +} +``` + +### CAS (lagring) + +```jsonc +{ + "hash": "sha256-abc123...", + "size_bytes": 84000000, + "mime": "audio/mp3", + "operation": "store" // "store" | "delete" +} +``` + +### Bandwidth (servering) + +```jsonc +{ + "size_bytes": 84000000, + "path": "/media/podcast/ep47.mp3", + "client": "Apple Podcasts" // parsert fra User-Agent +} +``` + +### LiveKit (sanntid) + +```jsonc +{ + "room_id": "meeting-abc123", + "participant_minutes": 180, + "tracks": 4 // antall aktive lyd/video-spor +} +``` + +### Graph (noder og edges) + +Trenger ikke logges i `resource_usage_log` — kan telles direkte fra +`nodes` og `edges`-tabellene med `COUNT` + `GROUP BY created_by` eller +`GROUP BY collection`. Billig spørring, ingen ekstra lagring. + +Vises i bruker- og samlingsvisning som kontekst: + +``` +Vegard: + 423 noder opprettet + 1 204 edges + +Sidelinja: + 2 891 noder + 8 340 edges +``` + +## Aggregering + +Tre naturlige visninger, alle er GROUP BY-spørringer mot samme tabell: + +### Per node + +Synlig i node-detaljer for eieren. Gir innsikt i hva en spesifikk +node har kostet i ressurser. + +``` +Episode 47: + AI (smart) 12k tokens inn, 3k ut — 4 jobber + Whisper 42 min prosessert (medium) + TTS 8 200 tegn + CAS 84 MB lagret + Båndbredde 2.3 GB servert + LiveKit 180 deltaker-minutter + 12 noder, 34 edges +``` + +### Per bruker + +Synlig for brukeren selv i sin profil/innstillinger. Sum av alle +noder brukeren har utløst arbeid på. + +``` +Vegard denne måneden: + AI fast: 42k / smart: 18k / deep: 3k tokens + Whisper 3.2 timer prosessert + TTS 24k tegn + 423 noder opprettet, 1 204 edges +``` + +### Per samling + +Synlig for samlingens eiere/admins. Sum av alt forbruk i samlingen. +Nyttig for å forstå hvilke samlinger som bruker mest ressurser. + +``` +Sidelinja (mars 2026): + AI 148k tokens totalt + Whisper 12.4 timer prosessert + CAS 2.1 GB lagret + Båndbredde 48 GB servert + 2 891 noder, 8 340 edges +``` + +## Triggered-by-regler + +| Scenario | triggered_by | +|---|---| +| Bruker klikker "oppsummer" | Brukeren | +| Bruker sender melding som trigger auto-tag | Brukeren | +| Nattlig samlings-digest | null (system) | +| Podcast-nedlasting av ekstern lytter | null (system) | + +Når `triggered_by` er null, tilhører forbruket kun samlingen — +det belaster ingen spesifikk bruker. + +## Logging-ansvar + +Maskinrommet logger all ressursbruk. Hver handler (AI, Whisper, TTS, +CAS, LiveKit) skriver til `resource_usage_log` som siste steg etter +vellykket operasjon. Feilede jobber logges ikke — ingen ressurs ble +forbrukt. + +Båndbredde-logging skjer via Caddy-logg-parsing i nattlig batch-jobb +(samme mønster som `docs/features/podcast_statistikk.md`). diff --git a/docs/infra/jobbkø.md b/docs/infra/jobbkø.md index eae8f65..f164810 100644 --- a/docs/infra/jobbkø.md +++ b/docs/infra/jobbkø.md @@ -44,9 +44,8 @@ Workeren gjør lite tung prosessering selv. Den er en **orkestrator** som koordi | Jobbtype | Hva workeren gjør | Tung logikk i workeren? | |---|---|---| -| `whisper_transcribe` | HTTP-kall til faster-whisper-server, commit SRT til Forgejo | Nei — venter på svar | +| `whisper_transcribe` | HTTP-kall til faster-whisper-server (SRT), parse → `transcription_segments` | Lett SRT-parsing | | `openrouter_analyze` | HTTP-kall til AI Gateway | Nei — venter på svar | -| `srt_parse` | Parser SRT-tekst, skriver avledede formater til PG | Lett strengparsing | | `stats_parse` | Parser Caddy-loggfiler, skriver til PG | Lett I/O | | `research_clip` | HTTP-kall til AI Gateway | Nei — venter på svar | | `generate_embeddings` | HTTP-kall til AI Gateway | Nei — venter på svar | @@ -107,7 +106,7 @@ Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på | `job_type` | Konsument | Beskrivelse | |---|---|---| -| `whisper_transcribe` | Podcastfabrikken | Transkriber MP3 via faster-whisper | +| `whisper_transcribe` | Universell lyd-tjeneste | Transkriber lydfil via faster-whisper (SRT) → `transcription_segments` | | `openrouter_analyze` | Podcastfabrikken | Metadata-uttrekk fra transkripsjon | | `ai_text_process` | Editor (AI-knapp) | Rens, oppsummer, trekk ut fakta, skriv om (se `docs/proposals/editor.md`) | | `stats_parse` | Podcast-Statistikk | Batch-prosesser Caddy-logger | diff --git a/docs/primitiver/edges.md b/docs/primitiver/edges.md index bade71a..7cf4b82 100644 --- a/docs/primitiver/edges.md +++ b/docs/primitiver/edges.md @@ -64,6 +64,15 @@ valideres i maskinrommet. | `status` | Tilstand | `{ "value": "done" }` | | `tagged` | Merket med | `{ "tag": "urgent" }` | | `host_of` | Vert i | — | +| `has_media` | Har mediefil (innhold → CAS-node) | — | +| `intended_for` | Ment for publisering i (arbeidsfase) | — | +| `submitted_to` | Innsendt til redaksjonell vurdering | `{ "status": "pending" }` | +| `title` | Publisert overskrift (presentasjonsnode → innhold) | `{ "variant": "editorial" }` | +| `subtitle` | Undertittel | `{ "variant": "editorial" }` | +| `summary` | Ingress / forhåndsvisning | `{ "variant": "ai" }` | +| `og_image` | Forsidebilde / OpenGraph-bilde (media → innhold) | `{ "variant": "editorial" }` | +| `show_notes` | Show notes for episode | `{ "variant": "ai" }` | +| `chapter` | Kapittelmarkør for episode | `{ "at": "00:05:23" }` | Listen vokser organisk. Nye typer legges til ved behov uten skjemaendring. diff --git a/docs/primitiver/nodes.md b/docs/primitiver/nodes.md index 7a77b0b..74890b3 100644 --- a/docs/primitiver/nodes.md +++ b/docs/primitiver/nodes.md @@ -52,8 +52,8 @@ Kjente node_kinds: Listen vokser organisk etter behov. ### `title` -Det du viser overalt: i lister, søkeresultater, mottaksflaten. -Universelt — navnet på en person, tittelen på en bloggpost, +Intern visningsnavn brukt i lister, søkeresultater, mottaksflaten. +Universelt — navnet på en person, arbeidstittelen på en bloggpost, navnet på en podcast. - Vegard: `'Vegard'` @@ -61,6 +61,12 @@ navnet på en podcast. - Bloggpost: `'Hvorfor noder er sentrum'` - Chatmelding: `NULL` (har sjelden tittel) +**Viktig distinksjon:** `title` er det interne displaynavnet. For +publisert innhold er den offentlige overskriften en *egen node* med +`title`-edge til artikkelen — fordi den kan ha varianter, A/B-testes, +og har en egen forfatter/tidspunkt. Se +[publisering](../concepts/publisering.md) § "Presentasjonselementer". + ### `content` Ren tekst uten formatering. Brukes til fulltekstsøk og enkel visning. For rike dokumenter (formatert tekst med bilder) genereres diff --git a/docs/primitiver/traits.md b/docs/primitiver/traits.md new file mode 100644 index 0000000..b3f772c --- /dev/null +++ b/docs/primitiver/traits.md @@ -0,0 +1,205 @@ +# Traits — Evner og funksjonalitet for noder + +**Status:** Vedtatt + +## Konsept + +En **trait** er en navngitt evne som beriker en samlingsnode med spesifikk +funksjonalitet. Traits bestemmer hva en samling *kan gjøre* — hvilke +UI-komponenter som vises i frontend og hvilken backend-oppførsel maskinrommet +aktiverer. + +Traits er mekanismen som gjør at én og samme node-/edge-arkitektur kan +fungere som nettmagasin, podcaststudio, diskusjonsklubb, wiki, eller en +kombinasjon av flere. + +## Designprinsipper + +1. **Komposisjon, ikke typer.** En samling er ikke "et magasin" — den har + traits som *gjør den til* et magasin. Legg til eller fjern traits når + som helst uten migrasjon. + +2. **Traits i metadata, ikke edges.** Traits er egenskaper ved samlingen, + ikke relasjoner mellom entiteter. Edges er for relasjoner (innhold → + samling). Metadata er for konfigurasjon (hva samlingen kan gjøre). + +3. **Lukket katalog, åpen konfigurasjon.** Trait-navn er et kjent sett vi + eier koden for. Konfigurasjonen innenfor hver trait er fleksibel JSONB. + +4. **Uavhengige traits.** Hver trait fungerer alene. Verdien oppstår i + sammensetningen — og det er brukeren som setter sammen, ikke utvikleren. + +5. **To effekter per trait.** Hver trait aktiverer: + - **Frontend:** UI-komponenter, visninger, interaksjoner + - **Backend:** Maskinrommet-oppførsel, validering, jobb-triggering + +## Metadata-struktur + +Traits lever i samlingsnodenes `metadata.traits`-objekt: + +```jsonc +{ + "node_kind": "collection", + "title": "Sidelinja Magasin", + "metadata": { + "traits": { + "publishing": { + "slug": "sidelinja", + "custom_domain": "magasin.sidelinja.org", + "theme": "editorial", + "open_graph_defaults": { "image": "cas://sha256-abc123" } + }, + "editor": { + "preset": "longform", + "allow_collaborators": true + }, + "rss": { + "format": "atom", + "title": "Sidelinja Magasin", + "max_items": 50 + }, + "comments": { + "moderation": "pre-approve", + "anonymous": false + } + } + } +} +``` + +Fravær av en trait betyr at funksjonaliteten er deaktivert. Ingen boolean +`enabled`-flagg — traiten finnes eller finnes ikke. + +## Trait-katalog + +### Innhold & redigering + +| Trait | Frontend | Backend | +|---|---|---| +| `editor` | TipTap med presets (longform, note, chat, code) | Validering av dokumentstruktur | +| `versioning` | Revisjonshistorikk, diff, rollback-knapp | Snapshot ved signifikante endringer | +| `collaboration` | Samtidig redigering, markører, inline-kommentarer | OT/CRDT via STDB | +| `translation` | Språkvelger, side-ved-side-visning | AI-oversettelse via jobbkø | +| `templates` | Mal-velger ved ny node | Mal-noder i samlingen | + +### Publisering & distribusjon + +| Trait | Frontend | Backend | +|---|---|---| +| `publishing` | Publiseringsknapp, forhåndsvisning, URL-visning, SEO-editor | HTML-rendering til CAS, Caddy-ruting, OG-tags | +| `rss` | Feed-URL synlig i UI | Feed-generering ved publisering | +| `newsletter` | Abonnentliste, utsendingsknapp, forhåndsvisning | E-postutsending ved publisering | +| `custom_domain` | Domene-innstilling i admin | Caddy on-demand TLS, DNS-validering | +| `analytics` | Besøksstatistikk-dashbord | Logg-parsing, IAB-filtrering | +| `embed` | Kopier embed-kode-knapp | Generer iframe-snippet med riktige dimensjoner | +| `api` | API-dokumentasjon, nøkkelhåndtering | Offentlig JSON-endepunkt for samlingens innhold | + +### Lyd & video + +| Trait | Frontend | Backend | +|---|---|---| +| `podcast` | Episodeliste, lydavspiller, RSS-lenke | RSS med enclosures, metadata-håndtering | +| `recording` | LiveKit-studio, opptak-kontroller | LiveKit-rom, lydkonsolidering | +| `transcription` | Transkripsjonsvisning, redigerbar SRT | Whisper-pipeline via jobbkø | +| `tts` | "Les opp"-knapp, lydversjon av artikler | Tekst-til-tale via jobbkø | +| `clips` | Klipp-editor, segment-markering | Segmentering, CAS-lagring av klipp | +| `playlist` | Ordnet avspillingsliste, drag-and-drop rekkefølge | Sekvensiell avspilling | + +### Kommunikasjon + +| Trait | Frontend | Backend | +|---|---|---| +| `chat` | Sanntidsmeldinger, tråder, reaksjoner | STDB-synk, TTL-håndtering | +| `forum` | Trådet diskusjon, sortering (nyeste/populære/ubesvarte) | Tråd-indeksering | +| `comments` | Kommentarfelt under publisert innhold | Moderasjonskø, evt. anonym input | +| `guest_input` | Gjeste-lenke-generering, svar-oversikt | Token-generering, CAS-upload, rate limiting | +| `announcements` | Enveis-feed, kun eiere/admins poster | Skrivetilgangsvalidering | +| `polls` | Avstemnings-UI, resultatvisning | Stemmetelling, duplikatdeteksjon | +| `qa` | Spørsmål/svar-format, oppstemming, "godkjent svar" | Sortering, markering | + +### Organisering + +| Trait | Frontend | Backend | +|---|---|---| +| `kanban` | Board med kolonner, drag-and-drop | Statusoverganger, posisjonsberegning | +| `calendar` | Kalendervisning (måned/uke/dag) | Scheduling-edges, ICS-eksport | +| `timeline` | Kronologisk visning | Tidsbasert sortering og filtrering | +| `table` | Strukturert tabellvisning, filtrerbare kolonner | Metadata-indeksering | +| `gallery` | Rutenett av bilde-/medianoder | Thumbnail-generering | +| `bookmarks` | Kuratert lenkesamling med forhåndsvisning | URL-enrichment via maskinrommet | +| `tags` | Tagging, filtrering, tag-sky | Tag-indeksering | + +### Kunnskap + +| Trait | Frontend | Backend | +|---|---|---| +| `knowledge_graph` | Visuell graf, auto-tagging | NER, embedding-generering | +| `wiki` | Slug-baserte sider, kryssreferanser, "finnes ikke ennå"-lenker | Slug-unikhet, backlink-indeks | +| `glossary` | Begrepsliste, hover-definisjoner i annet innhold | Begrep-matching i tekst | +| `faq` | Spørsmål/svar-par med søk | Søkeindeksering | +| `bibliography` | Kildehenvisninger, siteringsformat, referanseliste | Sitats-parsing, DOI-oppslag | + +### Automatisering & AI + +| Trait | Frontend | Backend | +|---|---|---| +| `auto_tag` | Foreslåtte tags ved ny node, godkjenn/avvis | AI-tagging via jobbkø | +| `auto_summarize` | AI-sammendrag synlig, redigerbart | Sammendrag-generering ved publisering/møteslutt | +| `digest` | Periodisk oppsummering i UI | AI-sammendrag av aktivitet på intervall | +| `bridge` | "Også funnet i..."-forslag | pgvector-embedding, krysskontekst-søk | +| `moderation` | Moderasjonskø, flagging | AI-assistert innholdsvurdering | + +### Tilgang & fellesskap + +| Trait | Frontend | Backend | +|---|---|---| +| `membership` | Søknads-/innmeldingsknapp | Søknadskø, godkjenningsflyt | +| `roles` | Rolletildeling i admin | Egendefinerte roller utover owner/admin/member/reader | +| `invites` | Invitasjonslenker med kopiering | Token-generering, utløp, maks bruk | +| `paywall` | Betalingsflyt, tilgangskontroll | Stripe/Vipps-integrasjon | +| `directory` | Medlemsoversikt med profiler | Profilindeksering | + +### Ekstern integrasjon + +| Trait | Frontend | Backend | +|---|---|---| +| `webhook` | Webhook-konfigurasjon i admin | HTTP-varsling ved hendelser | +| `import` | Import-veiviser (WordPress, Markdown, RSS) | Parsing, node-opprettelse, mediaimport | +| `export` | Eksport-knapp (Markdown, EPUB, PDF) | Format-konvertering, CAS-pakking | +| `ical_sync` | Kalender-URL, synk-status | Toveis ICS-synk | + +## Pakker + +Pakker er forhåndsdefinerte kombinasjoner av traits med fornuftige defaults. +En pakke er *ikke* en type eller en låst konfigurasjon — den er et startpunkt. +Brukeren kan legge til eller fjerne traits etterpå. + +| Pakke | Traits | +|---|---| +| **Nettmagasin** | editor(longform), publishing, rss, comments, analytics, custom_domain, newsletter | +| **Podcaststudio** | podcast, recording, transcription, editor(shownotes), rss, analytics, clips, knowledge_graph | +| **Nyhetsbrev** | editor(longform), newsletter, analytics, versioning | +| **Wiki** | wiki, editor(longform), collaboration, versioning, knowledge_graph, glossary | +| **Diskusjonsklubb** | forum, chat, polls, membership, roles, directory | +| **Kursplattform** | editor(longform), playlist, qa, membership, paywall, templates | +| **Møteplass** | recording, chat, kanban, calendar, auto_summarize, guest_input | +| **Fotoblogg** | gallery, publishing, comments, custom_domain, rss | +| **Prosjektstyring** | kanban, calendar, chat, table, tags, roles | +| **Åpen forskning** | editor(longform), versioning, bibliography, publishing, comments, collaboration, api | +| **Community radio** | recording, podcast, chat, polls, membership, clips, playlist | +| **Bokmerke-vegg** | bookmarks, tags, publishing, rss, comments | +| **Redaksjon** | chat, kanban, calendar, editor(longform), knowledge_graph, guest_input | + +## Implementeringsstrategi + +Traits implementeres én og én, ikke alle samtidig. Prioritering følger +brukerbehovene til Sidelinja som første tenant. En trait krever: + +1. **Spesifikasjon** — hva traiten gjør, metadata-skjema, UI-mockup +2. **Backend** — maskinrommet-kode som reagerer på traitens tilstedeværelse +3. **Frontend** — Svelte-komponent(er) som rendres når traiten er aktiv +4. **Dokumentasjon** — oppdater denne filen og evt. `docs/features/` + +Rekkefølge for første bolk (Sidelinja-behov): +`editor` → `chat` → `publishing` → `rss` → `podcast` → `recording` → +`transcription` → `knowledge_graph` → `kanban` → `calendar` diff --git a/docs/retninger/README.md b/docs/retninger/README.md index 81cc62f..450c836 100644 --- a/docs/retninger/README.md +++ b/docs/retninger/README.md @@ -28,6 +28,12 @@ andre dokumenter. En retning kan også forkastes eller parkeres. | [Noder er sentrum](bruker_ikke_workspace.md) | **Besluttet** | Alt er noder (brukere, team, innhold). Edges definerer relasjoner og tilgang. Materialisert tilgangsmatrise for RLS. | | [Datalaget](datalaget.md) | **Besluttet** | SpacetimeDB holder hele grafen, PG er persistent arkiv, CAS for binærdata, AGE ved behov | +### Relaterte spesifikasjoner + +Retningene har ført til konkrete spesifikasjoner: +- `docs/primitiver/traits.md` — Trait-system for samlingsnoder (komposisjon av evner) +- `docs/concepts/publisering.md` — Publiseringsflyt fra privat tanke til offentlig artikkel + ## Format - Hva er tesen? - Hva motiverer den? (observasjoner, frustrasjoner, inspirasjon) diff --git a/maskinrommet/src/transcribe.rs b/maskinrommet/src/transcribe.rs index dd93ba0..cf0cd72 100644 --- a/maskinrommet/src/transcribe.rs +++ b/maskinrommet/src/transcribe.rs @@ -1,9 +1,10 @@ // Transkripsjons-pipeline — faster-whisper integrasjon. // -// Henter lydfil fra CAS, sender til faster-whisper HTTP API, -// oppdaterer media-nodens content-felt med transkripsjonen. +// Henter lydfil fra CAS, sender til faster-whisper HTTP API (SRT-format), +// parser SRT og skriver segmenter til transcription_segments-tabellen. +// Universell tjeneste for all lyd: podcast, møter, voice memos. // -// Ref: docs/erfaringer/faster_whisper_oppsett.md +// Ref: docs/concepts/podcastfabrikken.md use sqlx::PgPool; use uuid::Uuid; @@ -12,27 +13,13 @@ use crate::cas::CasStore; use crate::jobs::JobRow; use crate::stdb::StdbClient; -/// Whisper API-respons (verbose_json format). -#[derive(serde::Deserialize, Debug)] -struct WhisperResponse { - text: String, - #[serde(default)] - segments: Vec<WhisperSegment>, - #[serde(default)] - duration: f64, - #[serde(default)] - language: String, -} - -#[derive(serde::Deserialize, Debug, serde::Serialize)] -struct WhisperSegment { - #[serde(default)] - id: i64, - start: f64, - end: f64, - text: String, - #[serde(default)] - no_speech_prob: f64, +/// Et parset SRT-segment. +#[derive(Debug)] +struct SrtSegment { + seq: i32, + start_ms: i32, + end_ms: i32, + content: String, } /// Handler for whisper_transcribe-jobber. @@ -42,6 +29,7 @@ struct WhisperSegment { /// - cas_hash: String — CAS-nøkkel til lydfilen /// - mime: String — MIME-type (brukes for filnavn-hint) /// - language: String (valgfritt, default "no") +/// - initial_prompt: String (valgfritt — navneliste for bedre egennavn) pub async fn handle_whisper_job( job: &JobRow, db: &PgPool, @@ -67,6 +55,16 @@ pub async fn handle_whisper_job( .as_str() .unwrap_or("no"); + // Hent initial_prompt: payload > miljøvariabel > ingen + let initial_prompt = match job.payload["initial_prompt"].as_str() { + Some(p) => Some(p.to_string()), + None => std::env::var("WHISPER_INITIAL_PROMPT").ok(), + }; + + // Modell: sentral serverinnstilling + let model = std::env::var("WHISPER_MODEL") + .unwrap_or_else(|_| "medium".to_string()); + // 1. Les lydfil fra CAS let file_path = cas.path_for(cas_hash); let file_data = tokio::fs::read(&file_path) @@ -77,10 +75,11 @@ pub async fn handle_whisper_job( media_node_id = %media_node_id, cas_hash = %cas_hash, size = file_data.len(), + model = %model, "Sender lydfil til Whisper" ); - // 2. Send til faster-whisper API + // 2. Send til faster-whisper API (SRT-format) let file_ext = mime_to_extension(mime); let file_name = format!("audio.{file_ext}"); @@ -89,11 +88,15 @@ pub async fn handle_whisper_job( .mime_str(mime) .map_err(|e| format!("Kunne ikke bygge multipart: {e}"))?; - let form = reqwest::multipart::Form::new() + let mut form = reqwest::multipart::Form::new() .part("file", file_part) - .text("model", "large-v3") + .text("model", model.clone()) .text("language", language.to_string()) - .text("response_format", "verbose_json"); + .text("response_format", "srt"); + + if let Some(ref prompt) = initial_prompt { + form = form.text("initial_prompt", prompt.clone()); + } let client = reqwest::Client::new(); let url = format!("{whisper_url}/v1/audio/transcriptions"); @@ -112,65 +115,191 @@ pub async fn handle_whisper_job( return Err(format!("Whisper returnerte {status}: {body}")); } - let whisper_result: WhisperResponse = response - .json() + let srt_text = response + .text() .await - .map_err(|e| format!("Kunne ikke parse Whisper-respons: {e}"))?; + .map_err(|e| format!("Kunne ikke lese Whisper-respons: {e}"))?; + + // 3. Parse SRT til segmenter + let segments = parse_srt(&srt_text)?; tracing::info!( media_node_id = %media_node_id, - duration = whisper_result.duration, - segments = whisper_result.segments.len(), - language = %whisper_result.language, - "Transkripsjon fullført" + segments = segments.len(), + "SRT parset" ); - // 3. Filtrer segmenter med høy no_speech_prob (hallusinering) - let filtered_segments: Vec<&WhisperSegment> = whisper_result - .segments - .iter() - .filter(|s| s.no_speech_prob <= 0.6) - .collect(); - - // Bygg filtrert tekst fra gyldige segmenter - let transcript_text = if filtered_segments.len() < whisper_result.segments.len() { - filtered_segments - .iter() - .map(|s| s.text.trim()) - .collect::<Vec<_>>() - .join(" ") - } else { - whisper_result.text.clone() - }; - - let filtered_count = whisper_result.segments.len() - filtered_segments.len(); - if filtered_count > 0 { - tracing::info!( - filtered = filtered_count, - "Filtrerte bort segmenter med høy no_speech_prob" - ); + if segments.is_empty() { + return Err("Whisper returnerte tom SRT — ingen segmenter".to_string()); } - // 4. Oppdater media-nodens content-felt og metadata - update_node_with_transcript(db, stdb, media_node_id, &transcript_text, &whisper_result).await?; + // 4. Skriv segmenter til transcription_segments-tabellen + let transcribed_at = chrono::Utc::now(); + insert_segments(db, media_node_id, transcribed_at, &segments).await?; + + // 5. Bygg sammenhengende tekst og oppdater node + let transcript_text: String = segments + .iter() + .map(|s| s.content.trim()) + .collect::<Vec<_>>() + .join(" "); + + let duration_ms = segments.last().map(|s| s.end_ms).unwrap_or(0); + + update_node_with_transcript( + db, + stdb, + media_node_id, + &transcript_text, + transcribed_at, + segments.len(), + duration_ms, + ) + .await?; Ok(serde_json::json!({ - "duration": whisper_result.duration, - "language": whisper_result.language, - "segments": whisper_result.segments.len(), - "filtered_segments": filtered_count, + "segments": segments.len(), "transcript_length": transcript_text.len(), + "duration_ms": duration_ms, + "model": model, + "transcribed_at": transcribed_at.to_rfc3339(), })) } -/// Oppdaterer nodens content-felt med transkripsjonen og lagrer -/// segmenter i metadata.transcription. +/// Parser SRT-tekst til en liste med segmenter. +/// +/// SRT-format: +/// ```text +/// 1 +/// 00:00:00,000 --> 00:00:05,230 +/// Hei og velkommen til Sidelinja. +/// +/// 2 +/// 00:00:05,230 --> 00:00:10,500 +/// I dag snakker vi om... +/// ``` +fn parse_srt(srt: &str) -> Result<Vec<SrtSegment>, String> { + let mut segments = Vec::new(); + let mut lines = srt.lines().peekable(); + + while lines.peek().is_some() { + // Hopp over tomme linjer + while lines.peek().map_or(false, |l| l.trim().is_empty()) { + lines.next(); + } + + // Sekvensnummer + let seq_line = match lines.next() { + Some(l) if !l.trim().is_empty() => l.trim().to_string(), + _ => break, + }; + let seq: i32 = seq_line + .parse() + .map_err(|_| format!("Ugyldig SRT-sekvensnummer: '{seq_line}'"))?; + + // Tidslinje: 00:00:00,000 --> 00:00:05,230 + let time_line = lines + .next() + .ok_or_else(|| format!("Mangler tidslinje etter sekvens {seq}"))?; + let (start_ms, end_ms) = parse_srt_time_line(time_line) + .map_err(|e| format!("Ugyldig tidslinje for sekvens {seq}: {e}"))?; + + // Tekstlinjer (frem til tom linje eller slutt) + let mut text_parts = Vec::new(); + while lines.peek().map_or(false, |l| !l.trim().is_empty()) { + text_parts.push(lines.next().unwrap().to_string()); + } + let content = text_parts.join("\n"); + + if !content.is_empty() { + segments.push(SrtSegment { + seq, + start_ms, + end_ms, + content, + }); + } + } + + Ok(segments) +} + +/// Parser en SRT-tidslinje: "00:01:23,456 --> 00:01:30,789" +/// Returnerer (start_ms, end_ms). +fn parse_srt_time_line(line: &str) -> Result<(i32, i32), String> { + let parts: Vec<&str> = line.split("-->").collect(); + if parts.len() != 2 { + return Err(format!("Forventet 'start --> end', fikk: '{line}'")); + } + let start = parse_srt_timestamp(parts[0].trim())?; + let end = parse_srt_timestamp(parts[1].trim())?; + Ok((start, end)) +} + +/// Parser et SRT-tidsstempel: "00:01:23,456" → millisekunder. +fn parse_srt_timestamp(ts: &str) -> Result<i32, String> { + // Format: HH:MM:SS,mmm + let ts = ts.replace(',', "."); + let parts: Vec<&str> = ts.split(':').collect(); + if parts.len() != 3 { + return Err(format!("Ugyldig tidsstempel: '{ts}'")); + } + let hours: f64 = parts[0].parse().map_err(|_| format!("Ugyldig timer: '{}'", parts[0]))?; + let minutes: f64 = parts[1].parse().map_err(|_| format!("Ugyldig minutter: '{}'", parts[1]))?; + let seconds: f64 = parts[2].parse().map_err(|_| format!("Ugyldig sekunder: '{}'", parts[2]))?; + + Ok(((hours * 3_600.0 + minutes * 60.0 + seconds) * 1000.0) as i32) +} + +/// Setter inn segmenter i transcription_segments-tabellen. +async fn insert_segments( + db: &PgPool, + node_id: Uuid, + transcribed_at: chrono::DateTime<chrono::Utc>, + segments: &[SrtSegment], +) -> Result<(), String> { + let mut tx = db.begin().await.map_err(|e| format!("Transaksjon feilet: {e}"))?; + + for seg in segments { + sqlx::query( + r#" + INSERT INTO transcription_segments (node_id, transcribed_at, seq, start_ms, end_ms, content) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + ) + .bind(node_id) + .bind(transcribed_at) + .bind(seg.seq) + .bind(seg.start_ms) + .bind(seg.end_ms) + .bind(&seg.content) + .execute(&mut *tx) + .await + .map_err(|e| format!("Kunne ikke sette inn segment {}: {e}", seg.seq))?; + } + + tx.commit().await.map_err(|e| format!("Commit feilet: {e}"))?; + + tracing::info!( + node_id = %node_id, + segments = segments.len(), + transcribed_at = %transcribed_at, + "Segmenter skrevet til transcription_segments" + ); + + Ok(()) +} + +/// Oppdaterer nodens content-felt med sammenhengende tekst og +/// lagrer transkripsjonsmetadata. async fn update_node_with_transcript( db: &PgPool, stdb: &StdbClient, node_id: Uuid, transcript: &str, - whisper: &WhisperResponse, + transcribed_at: chrono::DateTime<chrono::Utc>, + segment_count: usize, + duration_ms: i32, ) -> Result<(), String> { // Hent eksisterende node fra PG for å merge metadata let existing = sqlx::query_as::<_, NodeMetadataRow>( @@ -185,10 +314,9 @@ async fn update_node_with_transcript( // Merge transcription-data inn i eksisterende metadata let mut metadata = existing.metadata.clone(); metadata["transcription"] = serde_json::json!({ - "duration": whisper.duration, - "language": whisper.language, - "segment_count": whisper.segments.len(), - "transcribed_at": chrono::Utc::now().to_rfc3339(), + "duration_ms": duration_ms, + "segment_count": segment_count, + "transcribed_at": transcribed_at.to_rfc3339(), }); let metadata_str = metadata.to_string(); @@ -257,3 +385,51 @@ fn mime_to_extension(mime: &str) -> &str { _ => "wav", } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_srt_timestamp() { + assert_eq!(parse_srt_timestamp("00:00:00,000").unwrap(), 0); + assert_eq!(parse_srt_timestamp("00:01:23,456").unwrap(), 83456); + assert_eq!(parse_srt_timestamp("01:00:00,000").unwrap(), 3_600_000); + assert_eq!(parse_srt_timestamp("00:00:05,230").unwrap(), 5230); + } + + #[test] + fn test_parse_srt() { + let srt = "\ +1 +00:00:00,000 --> 00:00:05,230 +Hei og velkommen til Sidelinja. + +2 +00:00:05,230 --> 00:00:10,500 +I dag snakker vi om fotball. +"; + let segments = parse_srt(srt).unwrap(); + assert_eq!(segments.len(), 2); + assert_eq!(segments[0].seq, 1); + assert_eq!(segments[0].start_ms, 0); + assert_eq!(segments[0].end_ms, 5230); + assert_eq!(segments[0].content, "Hei og velkommen til Sidelinja."); + assert_eq!(segments[1].seq, 2); + assert_eq!(segments[1].start_ms, 5230); + assert_eq!(segments[1].end_ms, 10500); + } + + #[test] + fn test_parse_srt_time_line() { + let (start, end) = parse_srt_time_line("00:01:23,456 --> 00:01:30,789").unwrap(); + assert_eq!(start, 83456); + assert_eq!(end, 90789); + } + + #[test] + fn test_parse_srt_empty() { + let segments = parse_srt("").unwrap(); + assert!(segments.is_empty()); + } +} diff --git a/tasks.md b/tasks.md index 9805e6a..1f83233 100644 --- a/tasks.md +++ b/tasks.md @@ -97,8 +97,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 7.2 Transkripsjons-pipeline: lydfil i CAS → maskinrommet trigger Whisper → resultat i `content`-feltet. - [x] 7.3 Voice memo i frontend: opptak-knapp i input-komponenten → upload → CAS → transkripsjon. - [x] 7.4 Lyd-avspilling: spiller av original lyd fra CAS-node. Waveform-visning. -- [~] 7.5 Segmenttabell-migrasjon: opprett `transcription_segments`-tabell i PG. Oppdater `transcribe.rs` til SRT-format → parse → skriv segmenter. Miljøvariabler: `WHISPER_MODEL` (default "medium"), `WHISPER_INITIAL_PROMPT`. Ref: `docs/concepts/podcastfabrikken.md` § 3. - > Påbegynt: 2026-03-17T18:10 +- [x] 7.5 Segmenttabell-migrasjon: opprett `transcription_segments`-tabell i PG. Oppdater `transcribe.rs` til SRT-format → parse → skriv segmenter. Miljøvariabler: `WHISPER_MODEL` (default "medium"), `WHISPER_INITIAL_PROMPT`. Ref: `docs/concepts/podcastfabrikken.md` § 3. - [ ] 7.6 Transkripsjonsvisning i frontend: segmenter med tidsstempler, avspillingsknapp per segment (hopper til riktig sted i lydfilen), redigerbare tekstfelt (setter `edited = true`). Universell komponent for podcast, møter, voice memos. - [ ] 7.7 Re-transkripsjonsflyt: ved ny transkripsjon, vis side-om-side med forrige versjon. Highlight manuelt redigerte segmenter fra forrige versjon. Bruker velger per segment. - [ ] 7.8 SRT-eksport: generer nedlastbar SRT-fil fra `transcription_segments`-tabellen.