Fullfør oppgave 7.5: Segmenttabell-migrasjon og SRT-pipeline

Oppretter transcription_segments-tabellen i PostgreSQL som master-kopi
for alle transkripsjoner. transcribe.rs er oppdatert fra verbose_json
til SRT-format med full parse → segment-innsetting pipeline.

Endringer:
- Migration 005: transcription_segments med GIN fulltekstsøk (norsk)
- transcribe.rs: SRT-parser, segment-innsetting, node-oppdatering
- Miljøvariabler: WHISPER_MODEL (default "medium"), WHISPER_INITIAL_PROMPT
- Docker-compose: nye env vars for maskinrommet-containeren
- Docs: oppdatert podcastfabrikken, arkitektur, primitiver, CLAUDE.md

Tabellen kjørt på server, maskinrommet restartet med nye env vars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 18:19:00 +01:00
parent af14edd671
commit 7eae02eeb5
13 changed files with 1757 additions and 229 deletions

View file

@ -37,13 +37,15 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
- `docs/primitiver/` — Spesifikasjoner for kjerneprimitivene: - `docs/primitiver/` — Spesifikasjoner for kjerneprimitivene:
- `nodes.md` — Node-skjema, node_kind, visibility, CAS-noder, eierskap - `nodes.md` — Node-skjema, node_kind, visibility, CAS-noder, eierskap
- `edges.md` — Edge-skjema, typer, metadata, systemedges - `edges.md` — Edge-skjema, typer, metadata, systemedges
- `traits.md` — Trait-system: evner/funksjonalitet for samlingsnoder, katalog, pakker
- (kommer: input, mottak, kommunikasjonsnode) - (kommer: input, mottak, kommunikasjonsnode)
- `docs/concepts/` — Brukeropplevelser/produktområder: - `docs/concepts/` — Brukeropplevelser/produktområder:
- `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`, - `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: - `docs/features/` — Tekniske byggeklosser:
- Se individuelle filer for chat, kanban, kalender, meldingsboks, - 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/proposals/` — Idébank med 32+ uimplementerte forslag (se README.md)
- `docs/setup/` — Oppsett og drift: - `docs/setup/` — Oppsett og drift:
- `produksjon.md` — Steg-for-steg oppsett av Hetzner VPS fra scratch - `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 - `auth.sidelinja.org` — Authentik SSO
- `git.sidelinja.org` — Forgejo (SSH port 222) - `git.sidelinja.org` — Forgejo (SSH port 222)
- `vegard.info` — Separat nettsted - `vegard.info` — Separat nettsted
- `synops.no` — Plattformdomene (reservert, ikke i bruk ennå) - `synops.no` — Plattformdomene (placeholder, klar for subdomener)
## Git ## Git
- **Repos i Forgejo:** - **Repos i Forgejo:**

View file

@ -166,6 +166,24 @@ er en cache som regenereres on-demand.
| Reverse proxy | Caddy | Auto-TLS, enkel config | | Reverse proxy | Caddy | Auto-TLS, enkel config |
| Lyd/video | LiveKit | WebRTC, self-hosted | | 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 ## Retninger
Arkitekturen er basert på vedtatte retninger dokumentert i Arkitekturen er basert på vedtatte retninger dokumentert i

View file

@ -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

View file

@ -5,38 +5,52 @@
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. 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 ## 2. Arkitektur & Dataflyt
Dette er en asynkron arbeidsflyt som kombinerer filsystem, AI, databaser og CI/CD. 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`). 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). 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). 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 (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. 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 (PostgreSQL):** Ved commit (via Forgejo webhook) parser en Rust-worker SRT-filen og genererer: 5. **Avledede formater:** Genereres direkte fra segmenttabellen:
* **Ren tekst** — strippes fra SRT (fjern tidsstempler/sekvensnummer) for lesbart publiseringsdokument * **Ren tekst** — `SELECT content FROM transcription_segments WHERE node_id = $1 ORDER BY seq` → sammenhengende tekst
* **Segmenter** — tidsstemplede utdrag koblet til Aktører/Temaer i kunnskapsgrafen * **Full-text søkeindeks** — GIN-indeks på `content`-kolonnen for oppslag på tvers av episoder
* **Full-text søkeindeks** — 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) for uttrekk av forslag til tittel, sammendrag, show notes og kapittler. 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):** 7. **Manuell Godkjenning & Fletting (SvelteKit):**
* *For nye episoder:* Presenteres som et ferskt utkast. * *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 metadata. Redaksjonen kan da velge hva som skal beholdes eller flettes (merge). * *For oppdateringer:* Viser AI-ens nye forslag side-om-side med eksisterende noder. Redaksjonen velger hvilke som skal være aktive.
8. **Publisering (PostgreSQL):** Ved "Godkjenn" lagres metadataene permanent i databasen. * 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`. 9. **RSS-Generering:** SvelteKit-appen genererer en oppdatert `/feed.xml`.
### 2.1 Episodeside (publisert visning) ### 2.1 Episodeside (publisert visning)
Hver publisert episode får en side med: Hver publisert episode får en side med:
* Lydavspiller + sammendrag + kapitler + stikkord * Lydavspiller + sammendrag + kapitler + stikkord
* Personreferanser og artikler (koblet via kunnskapsgrafen) * Personreferanser og artikler (koblet via kunnskapsgrafen)
* Fane: **SRT** (nedlastbar undertekstfil — master-kopi fra Git) * Fane: **Transkripsjon** (lesbart dokument med avspillingsknapp per segment)
* Fane: **Ren tekst** (lesbart transkripsjonsdokument — avledet fra SRT, lagret i PG) * Nedlastbar SRT-fil (generert fra segmenttabellen)
### 2.2 Live-to-Archive (Studio/Møterom → Episode) ### 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 Mye av innholdet som ender i podcastfabrikken starter som live-innspilling
i Studioet eller Møterommet. Denne flyten unngår manuell opplasting: i Studioet eller Møterommet. Denne flyten unngår manuell opplasting:
1. Under innspilling i LiveKit produserer Whisper chunks i sanntid 1. Under innspilling i LiveKit produserer Whisper chunks i sanntid
2. Når innspillingen stoppes → automatisk jobb `studio_to_episode`: 2. Når innspillingen stoppes → automatisk jobb `studio_to_episode`:
- Konsoliderer live-chunks til én SRT-fil - Konsoliderer live-chunks til segmenter i `transcription_segments`
- Oppretter episode-node med storyboard basert på markører satt under innspilling - Oppretter episode-node med storyboard basert på markører satt under innspilling
- Trigger AI-analyse (samme pipeline som ved vanlig opplasting) - Trigger AI-analyse (samme pipeline som ved vanlig opplasting)
3. Lydfilen lagres i CAS med edge til episode-noden 3. Lydfilen lagres i CAS med edge til episode-noden
@ -46,7 +60,55 @@ i Studioet eller Møterommet. Denne flyten unngår manuell opplasting:
Fordel: aldri behov for «last opp MP3 etter innspilling» — flyten er Fordel: aldri behov for «last opp MP3 etter innspilling» — flyten er
live → arkiv → publisering. live → arkiv → publisering.
## 3. Spesialhåndtering: Oppdatering av eksisterende episoder (Cache-busting) ## 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: 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. * **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.
@ -56,10 +118,11 @@ Podcast-apper (Apple, Spotify) og CDN-er cacher innhold aggressivt. For at en en
* Alternativ A: "Behold opprinnelig dato" (Episoden oppdateres i det stille for nye lyttere). * 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). * Alternativ B: "Sett dato til NÅ" (Episoden spretter til toppen av feeden som en ny utgivelse).
## 4. Whisper-konfigurasjon ## 5. Whisper-konfigurasjon
* **Tjeneste:** `fedirz/faster-whisper-server` (Docker, OpenAI-kompatibelt API) * **Tjeneste:** `fedirz/faster-whisper-server` (Docker, OpenAI-kompatibelt API)
* **Endepunkt:** `POST /v1/audio/transcriptions` med `response_format=srt` * **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. * **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):** * **Modeller (benchmarket med E277.mp3, 32:45 norsk tale, CPU i7-13900K):**
| Konfigurasjon | Tid (CPU) | Seg | Tegn | Kommentar | | Konfigurasjon | Tid (CPU) | Seg | Tegn | Kommentar |
@ -67,81 +130,42 @@ Podcast-apper (Apple, Spotify) og CDN-er cacher innhold aggressivt. For at en en
| `small` | ~6 min | 777 | 25851 | Rask, men hyppige feil i egennavn | | `small` | ~6 min | 777 | 25851 | Rask, men hyppige feil i egennavn |
| `medium` | ~18 min | 442 | 26938 | God balanse, noen navnefeil | | `medium` | ~18 min | 442 | 26938 | God balanse, noen navnefeil |
| `medium` + prompt | ~17 min | 455 | 26957 | Riktige egennavn, anbefalt standard | | `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` | ~24 min | 520 | 14559 | Hallusinerer uten VAD — IKKE bruk |
| `large-v3` + VAD | ~31 min | 964 | 28291 | God kvalitet, men noen navnefeil | | `large-v3` + VAD | ~31 min | 964 | 28291 | God kvalitet, men noen navnefeil |
| `large-v3` + VAD + prompt | ~31 min | 964 | 28295 | Best kvalitet, riktige egennavn | | `large-v3` + VAD + prompt | ~31 min | 964 | 28295 | Best kvalitet, men for treg |
* **Anbefaling:** `medium` + `initial_prompt` som standard. `large-v3` + VAD + prompt for best mulig kvalitet der det er verdt ventetiden. * **Valg:** `medium` + `initial_prompt`. God nok kvalitet, rask nok for produksjon.
* **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. * **Språk:** Sett `language=no` eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk.
### 4.1 initial_prompt (navneliste) ### 5.1 initial_prompt (navneliste)
`initial_prompt` primes Whisper med ordforråd som forbedrer gjenkjenning av egennavn. Effekten er tydelig: `initial_prompt` primes Whisper med ordforråd som forbedrer gjenkjenning av egennavn. Effekten er tydelig:
* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja" * Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja"
* Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig) * Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig)
Prompten bygges automatisk av Rust-worker fra en statisk navneliste + aktører i kunnskapsgrafen: 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, Sidelinja podcast med Vegard Nøtnæs, Trond Sørensen, Arne Eidshagen,
Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie
``` ```
## 5. Per samlings-node konfigurasjon ## 6. Per samlings-node konfigurasjon
Hver samlings-node (f.eks. Sidelinja) har sin egen podcast-konfigurasjon, lagret som JSONB-metadata på noden: Hver samlings-node (f.eks. Sidelinja) har sin egen podcast-konfigurasjon, lagret som JSONB-metadata på noden:
### 5.1 Mediefiler ### 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. 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) ### 6.2 AI-prompts
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. * **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 ### 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. 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 ### 6.4 Statistikk
Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig samlings-node basert på domene i loggen. Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig samlings-node basert på domene i loggen.
## 6. Instruks for Claude Code ## 7. Instruks for Claude Code
* **Lydfiler:** Håndter filopplasting i SvelteKit strømmende (streaming) for filer >100MB for å unngå minne-lekkasjer. * **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. * **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. * **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. * **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. * **Tilhørighet:** Alle jobber, mediefiler og metadata knyttes til riktig samlings-node via edges. Hent config (prompts, domene) fra samlings-nodens JSONB-metadata.

View file

@ -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 `<h2>`,
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 `<enclosure>`-tags med lyd-URLer. Samme mønster som eksisterende
podcastfabrikken-konsept.
## SEO og metadata
Ved rendering genererer maskinrommet:
- `<title>` 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

View file

@ -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`).

View file

@ -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? | | 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 | | `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 | | `stats_parse` | Parser Caddy-loggfiler, skriver til PG | Lett I/O |
| `research_clip` | HTTP-kall til AI Gateway | Nei — venter på svar | | `research_clip` | HTTP-kall til AI Gateway | Nei — venter på svar |
| `generate_embeddings` | 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 | | `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 | | `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`) | | `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 | | `stats_parse` | Podcast-Statistikk | Batch-prosesser Caddy-logger |

View file

@ -64,6 +64,15 @@ valideres i maskinrommet.
| `status` | Tilstand | `{ "value": "done" }` | | `status` | Tilstand | `{ "value": "done" }` |
| `tagged` | Merket med | `{ "tag": "urgent" }` | | `tagged` | Merket med | `{ "tag": "urgent" }` |
| `host_of` | Vert i | — | | `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 Listen vokser organisk. Nye typer legges til ved behov uten
skjemaendring. skjemaendring.

View file

@ -52,8 +52,8 @@ Kjente node_kinds:
Listen vokser organisk etter behov. Listen vokser organisk etter behov.
### `title` ### `title`
Det du viser overalt: i lister, søkeresultater, mottaksflaten. Intern visningsnavn brukt i lister, søkeresultater, mottaksflaten.
Universelt — navnet på en person, tittelen på en bloggpost, Universelt — navnet på en person, arbeidstittelen på en bloggpost,
navnet på en podcast. navnet på en podcast.
- Vegard: `'Vegard'` - Vegard: `'Vegard'`
@ -61,6 +61,12 @@ navnet på en podcast.
- Bloggpost: `'Hvorfor noder er sentrum'` - Bloggpost: `'Hvorfor noder er sentrum'`
- Chatmelding: `NULL` (har sjelden tittel) - 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` ### `content`
Ren tekst uten formatering. Brukes til fulltekstsøk og enkel Ren tekst uten formatering. Brukes til fulltekstsøk og enkel
visning. For rike dokumenter (formatert tekst med bilder) genereres visning. For rike dokumenter (formatert tekst med bilder) genereres

205
docs/primitiver/traits.md Normal file
View file

@ -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`

View file

@ -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. | | [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 | | [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 ## Format
- Hva er tesen? - Hva er tesen?
- Hva motiverer den? (observasjoner, frustrasjoner, inspirasjon) - Hva motiverer den? (observasjoner, frustrasjoner, inspirasjon)

View file

@ -1,9 +1,10 @@
// Transkripsjons-pipeline — faster-whisper integrasjon. // Transkripsjons-pipeline — faster-whisper integrasjon.
// //
// Henter lydfil fra CAS, sender til faster-whisper HTTP API, // Henter lydfil fra CAS, sender til faster-whisper HTTP API (SRT-format),
// oppdaterer media-nodens content-felt med transkripsjonen. // 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 sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@ -12,27 +13,13 @@ use crate::cas::CasStore;
use crate::jobs::JobRow; use crate::jobs::JobRow;
use crate::stdb::StdbClient; use crate::stdb::StdbClient;
/// Whisper API-respons (verbose_json format). /// Et parset SRT-segment.
#[derive(serde::Deserialize, Debug)] #[derive(Debug)]
struct WhisperResponse { struct SrtSegment {
text: String, seq: i32,
#[serde(default)] start_ms: i32,
segments: Vec<WhisperSegment>, end_ms: i32,
#[serde(default)] content: String,
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,
} }
/// Handler for whisper_transcribe-jobber. /// Handler for whisper_transcribe-jobber.
@ -42,6 +29,7 @@ struct WhisperSegment {
/// - cas_hash: String — CAS-nøkkel til lydfilen /// - cas_hash: String — CAS-nøkkel til lydfilen
/// - mime: String — MIME-type (brukes for filnavn-hint) /// - mime: String — MIME-type (brukes for filnavn-hint)
/// - language: String (valgfritt, default "no") /// - language: String (valgfritt, default "no")
/// - initial_prompt: String (valgfritt — navneliste for bedre egennavn)
pub async fn handle_whisper_job( pub async fn handle_whisper_job(
job: &JobRow, job: &JobRow,
db: &PgPool, db: &PgPool,
@ -67,6 +55,16 @@ pub async fn handle_whisper_job(
.as_str() .as_str()
.unwrap_or("no"); .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 // 1. Les lydfil fra CAS
let file_path = cas.path_for(cas_hash); let file_path = cas.path_for(cas_hash);
let file_data = tokio::fs::read(&file_path) let file_data = tokio::fs::read(&file_path)
@ -77,10 +75,11 @@ pub async fn handle_whisper_job(
media_node_id = %media_node_id, media_node_id = %media_node_id,
cas_hash = %cas_hash, cas_hash = %cas_hash,
size = file_data.len(), size = file_data.len(),
model = %model,
"Sender lydfil til Whisper" "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_ext = mime_to_extension(mime);
let file_name = format!("audio.{file_ext}"); let file_name = format!("audio.{file_ext}");
@ -89,11 +88,15 @@ pub async fn handle_whisper_job(
.mime_str(mime) .mime_str(mime)
.map_err(|e| format!("Kunne ikke bygge multipart: {e}"))?; .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) .part("file", file_part)
.text("model", "large-v3") .text("model", model.clone())
.text("language", language.to_string()) .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 client = reqwest::Client::new();
let url = format!("{whisper_url}/v1/audio/transcriptions"); 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}")); return Err(format!("Whisper returnerte {status}: {body}"));
} }
let whisper_result: WhisperResponse = response let srt_text = response
.json() .text()
.await .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!( tracing::info!(
media_node_id = %media_node_id, media_node_id = %media_node_id,
duration = whisper_result.duration, segments = segments.len(),
segments = whisper_result.segments.len(), "SRT parset"
language = %whisper_result.language,
"Transkripsjon fullført"
); );
// 3. Filtrer segmenter med høy no_speech_prob (hallusinering) if segments.is_empty() {
let filtered_segments: Vec<&WhisperSegment> = whisper_result return Err("Whisper returnerte tom SRT — ingen segmenter".to_string());
.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"
);
} }
// 4. Oppdater media-nodens content-felt og metadata // 4. Skriv segmenter til transcription_segments-tabellen
update_node_with_transcript(db, stdb, media_node_id, &transcript_text, &whisper_result).await?; 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!({ Ok(serde_json::json!({
"duration": whisper_result.duration, "segments": segments.len(),
"language": whisper_result.language,
"segments": whisper_result.segments.len(),
"filtered_segments": filtered_count,
"transcript_length": transcript_text.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 /// Parser SRT-tekst til en liste med segmenter.
/// segmenter i metadata.transcription. ///
/// 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( async fn update_node_with_transcript(
db: &PgPool, db: &PgPool,
stdb: &StdbClient, stdb: &StdbClient,
node_id: Uuid, node_id: Uuid,
transcript: &str, transcript: &str,
whisper: &WhisperResponse, transcribed_at: chrono::DateTime<chrono::Utc>,
segment_count: usize,
duration_ms: i32,
) -> Result<(), String> { ) -> Result<(), String> {
// Hent eksisterende node fra PG for å merge metadata // Hent eksisterende node fra PG for å merge metadata
let existing = sqlx::query_as::<_, NodeMetadataRow>( let existing = sqlx::query_as::<_, NodeMetadataRow>(
@ -185,10 +314,9 @@ async fn update_node_with_transcript(
// Merge transcription-data inn i eksisterende metadata // Merge transcription-data inn i eksisterende metadata
let mut metadata = existing.metadata.clone(); let mut metadata = existing.metadata.clone();
metadata["transcription"] = serde_json::json!({ metadata["transcription"] = serde_json::json!({
"duration": whisper.duration, "duration_ms": duration_ms,
"language": whisper.language, "segment_count": segment_count,
"segment_count": whisper.segments.len(), "transcribed_at": transcribed_at.to_rfc3339(),
"transcribed_at": chrono::Utc::now().to_rfc3339(),
}); });
let metadata_str = metadata.to_string(); let metadata_str = metadata.to_string();
@ -257,3 +385,51 @@ fn mime_to_extension(mime: &str) -> &str {
_ => "wav", _ => "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());
}
}

View file

@ -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.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.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. - [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. - [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.
> Påbegynt: 2026-03-17T18:10
- [ ] 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.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.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. - [ ] 7.8 SRT-eksport: generer nedlastbar SRT-fil fra `transcription_segments`-tabellen.