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:
- `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:**

View file

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

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.
## 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`).
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.
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:* 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.
* *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: **SRT** (nedlastbar undertekstfil — master-kopi fra Git)
* Fane: **Ren tekst** (lesbart transkripsjonsdokument — avledet fra SRT, lagret i PG)
* Fane: **Transkripsjon** (lesbart dokument med avspillingsknapp per segment)
* 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
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
- 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
@ -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
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:
* **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 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)
* **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):**
| 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 |
| `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` | ~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, 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.
* **Viktig:** `large-v3` KREVER `vad_filter=true` — uten hallusinerer modellen repeterende tekst.
* **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.
### 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:
* 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:
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
```
## 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:
### 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.
### 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.
### 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.
### 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.
### 5.5 Statistikk
### 6.4 Statistikk
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.
* **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.
* **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.

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

View file

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

View file

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

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. |
| [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)

View file

@ -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());
}
}

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