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:
parent
af14edd671
commit
7eae02eeb5
13 changed files with 1757 additions and 229 deletions
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
121
docs/concepts/adminpanelet.md
Normal file
121
docs/concepts/adminpanelet.md
Normal 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
|
||||
|
|
@ -1,147 +1,171 @@
|
|||
# Konsept: Podcastfabrikken (Lyd & Publiserings-Pipeline)
|
||||
**Filsti:** `docs/concepts/podcastfabrikken.md`
|
||||
|
||||
## 1. Konsept
|
||||
Den automatiserte "samlebåndet" som tar over når en ferdigklippet episode er klar, samt verktøyet for å **oppdatere eksisterende episoder** (f.eks. en rullerende intro-episode). Målet er at maskinen gjør 90 % av grovarbeidet (transkripsjon, metadata, kapittelinndeling), men at redaksjonen alltid kan overstyre resultatet manuelt før publisering.
|
||||
|
||||
## 2. Arkitektur & Dataflyt
|
||||
Dette er en asynkron arbeidsflyt som kombinerer filsystem, AI, databaser og CI/CD.
|
||||
|
||||
1. **Trigger (Opplasting/Oppdatering):** Brukeren laster opp en `.mp3`-fil via SvelteKit-grensesnittet. Dette rutes enten som en *ny* episode (`INSERT`), eller en *oppdatering* av en eksisterende (`UPDATE`).
|
||||
2. **Kø-system (PostgreSQL jobbkø):** Siden lydprosessering tar tid (CPU-intensivt), legges oppgaven i den felles jobbkøen (se `docs/infra/jobbkø.md`). Opplastingen oppretter to jobber i sekvens: først `whisper_transcribe`, deretter `openrouter_analyze` (som trigges automatisk ved fullført transkripsjon).
|
||||
3. **Transkripsjon (faster-whisper):** Rust-worker kaller faster-whisper-server (OpenAI-kompatibelt API, `POST /v1/audio/transcriptions`) med `response_format=srt` og mottar SRT direkte. Modell: `Systran/faster-whisper-medium` med `initial_prompt` (navneliste).
|
||||
4. **Lagring av transkripsjon (Git):** Rust-worker committer SRT-filen til Forgejo. SRT er master-formatet — redigerbart, tidsstemplet, og et etablert standardformat. Git gir diff, historikk og sporbarhet. Redaksjonen kan redigere SRT direkte.
|
||||
5. **Avledede formater (PostgreSQL):** Ved commit (via Forgejo webhook) parser en Rust-worker SRT-filen og genererer:
|
||||
* **Ren tekst** — strippes fra SRT (fjern tidsstempler/sekvensnummer) for lesbart publiseringsdokument
|
||||
* **Segmenter** — tidsstemplede utdrag koblet til Aktører/Temaer i kunnskapsgrafen
|
||||
* **Full-text søkeindeks** — for oppslag på tvers av episoder
|
||||
6. **AI-Analyse (OpenRouter):** Transkripsjonen sendes til OpenRouter (Claude-modell) for uttrekk av forslag til tittel, sammendrag, show notes og kapittler.
|
||||
7. **Manuell Godkjenning & Fletting (SvelteKit):**
|
||||
* *For nye episoder:* Presenteres som et ferskt utkast.
|
||||
* *For oppdateringer:* Viser AI-ens nye forslag side-om-side med eksisterende metadata. Redaksjonen kan da velge hva som skal beholdes eller flettes (merge).
|
||||
8. **Publisering (PostgreSQL):** Ved "Godkjenn" lagres metadataene permanent i databasen.
|
||||
9. **RSS-Generering:** SvelteKit-appen genererer en oppdatert `/feed.xml`.
|
||||
|
||||
### 2.1 Episodeside (publisert visning)
|
||||
Hver publisert episode får en side med:
|
||||
* Lydavspiller + sammendrag + kapitler + stikkord
|
||||
* Personreferanser og artikler (koblet via kunnskapsgrafen)
|
||||
* Fane: **SRT** (nedlastbar undertekstfil — master-kopi fra Git)
|
||||
* Fane: **Ren tekst** (lesbart transkripsjonsdokument — avledet fra SRT, lagret i PG)
|
||||
|
||||
### 2.2 Live-to-Archive (Studio/Møterom → Episode)
|
||||
|
||||
Mye av innholdet som ender i podcastfabrikken starter som live-innspilling
|
||||
i Studioet eller Møterommet. Denne flyten unngår manuell opplasting:
|
||||
|
||||
1. Under innspilling i LiveKit produserer Whisper chunks i sanntid
|
||||
2. Når innspillingen stoppes → automatisk jobb `studio_to_episode`:
|
||||
- Konsoliderer live-chunks til én SRT-fil
|
||||
- Oppretter episode-node med storyboard basert på markører satt under innspilling
|
||||
- Trigger AI-analyse (samme pipeline som ved vanlig opplasting)
|
||||
3. Lydfilen lagres i CAS med edge til episode-noden
|
||||
4. Episoden dukker opp i podcastfabrikken som et utkast — klar for
|
||||
godkjenning, redigering og publisering
|
||||
|
||||
Fordel: aldri behov for «last opp MP3 etter innspilling» — flyten er
|
||||
live → arkiv → publisering.
|
||||
|
||||
## 3. Spesialhåndtering: Oppdatering av eksisterende episoder (Cache-busting)
|
||||
Podcast-apper (Apple, Spotify) og CDN-er cacher innhold aggressivt. For at en endring i f.eks. "Introepisoden" skal slå gjennom hos lytterne, MÅ følgende tekniske regler følges:
|
||||
|
||||
* **Filnavn-versjonering (Viktigst!):** Den nye lydfilen skal *aldri* overskrive det gamle filnavnet på disken. Systemet må legge til en hash, UUID eller et tidsstempel (f.eks. `intro_v2_1710289000.mp3`). Dette tvinger appene til å laste ned filen på nytt.
|
||||
* **RSS `<guid>` (Global Unique Identifier):** Denne taggen MÅ forbli 100% statisk/uendret fra originalepisoden. Den forteller appene at "Dette er fortsatt samme episode, ikke lag en duplikat".
|
||||
* **RSS `<enclosure>`:** URL-en i `enclosure`-taggen (som peker på `.mp3`-filen) oppdateres i databasen til å reflektere det *nye* filnavnet.
|
||||
* **RSS `<pubDate>`:** SvelteKit-grensesnittet skal gi redaksjonen en toggle-knapp ved oppdatering:
|
||||
* Alternativ A: "Behold opprinnelig dato" (Episoden oppdateres i det stille for nye lyttere).
|
||||
* Alternativ B: "Sett dato til NÅ" (Episoden spretter til toppen av feeden som en ny utgivelse).
|
||||
|
||||
## 4. Whisper-konfigurasjon
|
||||
* **Tjeneste:** `fedirz/faster-whisper-server` (Docker, OpenAI-kompatibelt API)
|
||||
* **Endepunkt:** `POST /v1/audio/transcriptions` med `response_format=srt`
|
||||
* **Beslutning:** SRT direkte fra Whisper, ikke verbose JSON. Verbose JSON inneholder diagnostikk (tokens, logprob, temperatur) som ikke har verdi for oss. SRT gir tidsstempler + tekst i et etablert format som er redigerbart, diffbart i Git, og trivielt å parse til ren tekst og segmenter.
|
||||
* **Modeller (benchmarket med E277.mp3, 32:45 norsk tale, CPU i7-13900K):**
|
||||
|
||||
| Konfigurasjon | Tid (CPU) | Seg | Tegn | Kommentar |
|
||||
|---|---|---|---|---|
|
||||
| `small` | ~6 min | 777 | 25851 | Rask, men hyppige feil i egennavn |
|
||||
| `medium` | ~18 min | 442 | 26938 | God balanse, noen navnefeil |
|
||||
| `medium` + prompt | ~17 min | 455 | 26957 | Riktige egennavn, anbefalt standard |
|
||||
| `large-v3` | ~24 min | 520 | 14559 | Hallusinerer uten VAD — IKKE bruk uten VAD |
|
||||
| `large-v3` + VAD | ~31 min | 964 | 28291 | God kvalitet, men noen navnefeil |
|
||||
| `large-v3` + VAD + prompt | ~31 min | 964 | 28295 | Best kvalitet, riktige egennavn |
|
||||
|
||||
* **Anbefaling:** `medium` + `initial_prompt` som standard. `large-v3` + VAD + prompt for best mulig kvalitet der det er verdt ventetiden.
|
||||
* **Viktig:** `large-v3` KREVER `vad_filter=true` — uten hallusinerer modellen repeterende tekst.
|
||||
* **Språk:** Sett `language=no` eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk.
|
||||
|
||||
### 4.1 initial_prompt (navneliste)
|
||||
`initial_prompt` primes Whisper med ordforråd som forbedrer gjenkjenning av egennavn. Effekten er tydelig:
|
||||
* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja"
|
||||
* Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig)
|
||||
|
||||
Prompten bygges automatisk av Rust-worker fra en statisk navneliste + aktører i kunnskapsgrafen:
|
||||
```
|
||||
Sidelinja podcast med Vegard Nøtnæs, Trond Sørensen, Arne Eidshagen,
|
||||
Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie
|
||||
```
|
||||
|
||||
## 5. Per samlings-node konfigurasjon
|
||||
Hver samlings-node (f.eks. Sidelinja) har sin egen podcast-konfigurasjon, lagret som JSONB-metadata på noden:
|
||||
|
||||
### 5.1 Mediefiler
|
||||
Lydfiler lagres i CAS (content-addressable store) med edges til episode-noder. Caddy ruter trafikk basert på domene (fra samlings-nodens metadata) til riktig innhold.
|
||||
|
||||
### 5.2 Transkripsjoner (Git-repostruktur)
|
||||
Det opprettes **ett Forgejo-repo per samlings-node** for SRT-filer, slik at historikk og redigering ikke blandes på tvers av podcaster.
|
||||
|
||||
#### Repo-oppretting
|
||||
Repoet opprettes **on-demand** ved første transkripsjonsjobb for en samlings-node, via Forgejo API. Ikke alle samlings-noder trenger transkripsjonsrepo.
|
||||
|
||||
#### Filnavnkonvensjon
|
||||
Flat struktur med prosesseringstidspunkt som filnavn:
|
||||
```
|
||||
20260315_143022.srt
|
||||
20260401_091500.srt
|
||||
```
|
||||
* **Format:** `YYYYMMDD_HHMMSS.srt` — settes automatisk av Rust-worker ved prosessering
|
||||
* **Sortering:** Kronologisk i enhver filvisning
|
||||
* **Unikhet:** Tidsstempel garanterer unikhet uten suffiks-logikk
|
||||
* **Ingen metadata i filnavn:** Episodenummer, tittel, slug og annen metadata lever i PostgreSQL, ikke i filnavnet. Filnavnet er en stabil identifikator som aldri endres.
|
||||
|
||||
#### Mediefiler matcher Git
|
||||
Lydfilen i CAS bruker **samme navnekonvensjon** som SRT-filen: `20260315_143022.mp3` matcher `20260315_143022.srt`. Dette kobler mediefil og transkripsjon uten databaseoppslag.
|
||||
|
||||
#### Reprosessering (redigert lyd)
|
||||
Når en lydfil redigeres og transkriberes på nytt, **beholdes det opprinnelige filnavnet**. Rust-worker overskriver SRT-filen i Git — historikken viser endringene via `git log`/`git diff`. Mediefilen i arkivet døpes om til å matche Git-filnavnet dersom den opprinnelig hadde et annet navn.
|
||||
|
||||
#### Forgejo-bruker
|
||||
En dedikert servicebruker **"serverassistent"** opprettes i Forgejo med push-tilgang til transkripsjonsrepoer. Ingen admin-rettigheter.
|
||||
|
||||
#### Webhook-flyt
|
||||
```
|
||||
Forgejo push-webhook → SvelteKit POST /api/webhooks/forgejo
|
||||
→ INSERT INTO job_queue (type: 'srt_parse', payload: {repo, commit, node_id})
|
||||
→ Rust-worker plukker opp jobben og parser SRT → avledede formater i PG
|
||||
```
|
||||
SvelteKit validerer webhook-signatur og legger jobb i køen. Rust-worker forblir en ren kø-consumer uten eget HTTP-endepunkt.
|
||||
|
||||
#### SRT-editor
|
||||
En enkel SRT-editor bygges i SvelteKit (Lag 3, sammen med Podcastfabrikken): segmenter som redigerbare tekstfelt med tidsstempler, "Lagre" committer tilbake til Git via Forgejo API. Forgejo web-UI fungerer som fallback for power users.
|
||||
|
||||
### 5.3 AI-prompts
|
||||
* **Whisper `initial_prompt`:** Navnelister og kontekst lagres per samlings-node i metadata (`whisper_prompt`). Rust-worker bygger prompten fra statisk liste + aktører knyttet til samlings-noden via edges.
|
||||
* **LLM system-prompts:** OpenRouter-prompts for metadata-uttrekk lagres i metadata (`llm_prompts`) slik at AI-en kjenner konteksten og vertene for akkurat den podcasten.
|
||||
|
||||
### 5.4 RSS-feed
|
||||
SvelteKit genererer `/feed.xml` dynamisk basert på domenet forespørselen kommer fra (matcher samlings-nodens domene-metadata), eller node-slug som fallback.
|
||||
|
||||
### 5.5 Statistikk
|
||||
Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig samlings-node basert på domene i loggen.
|
||||
|
||||
## 6. Instruks for Claude Code
|
||||
* **Lydfiler:** Håndter filopplasting i SvelteKit strømmende (streaming) for filer >100MB for å unngå minne-lekkasjer.
|
||||
* **Feilhåndtering:** Hvis OpenRouter timer ut eller Whisper feiler, må oppgaven flagges med status `error` i databasen slik at brukeren kan trigge jobben på nytt manuelt via UI.
|
||||
* **Opprydding (Disk):** Når en fil oppdateres vellykket, skal den gamle/foreldede `.mp3`-filen enten slettes fra Hetzner-serveren automatisk, eller flyttes til en `/archive/`-mappe basert på en miljøvariabel.
|
||||
* **Transkripsjoner:** Master-kopi alltid i Git. Aldri rediger avledede formater direkte i PG — de regenereres fra Git-kilden.
|
||||
* **Tilhørighet:** Alle jobber, mediefiler og metadata knyttes til riktig samlings-node via edges. Hent config (prompts, domene) fra samlings-nodens JSONB-metadata.
|
||||
# Konsept: Podcastfabrikken (Lyd & Publiserings-Pipeline)
|
||||
**Filsti:** `docs/concepts/podcastfabrikken.md`
|
||||
|
||||
## 1. Konsept
|
||||
Den automatiserte "samlebåndet" som tar over når en ferdigklippet episode er klar, samt verktøyet for å **oppdatere eksisterende episoder** (f.eks. en rullerende intro-episode). Målet er at maskinen gjør 90 % av grovarbeidet (transkripsjon, metadata, kapittelinndeling), men at redaksjonen alltid kan overstyre resultatet manuelt før publisering.
|
||||
|
||||
## 2. Arkitektur & Dataflyt
|
||||
Dette er en asynkron arbeidsflyt som kombinerer CAS, AI og databaser.
|
||||
|
||||
1. **Trigger (Opplasting/Oppdatering):** Brukeren laster opp en `.mp3`-fil via SvelteKit-grensesnittet. Dette rutes enten som en *ny* episode (`INSERT`), eller en *oppdatering* av en eksisterende (`UPDATE`).
|
||||
2. **Kø-system (PostgreSQL jobbkø):** Siden lydprosessering tar tid (CPU-intensivt), legges oppgaven i den felles jobbkøen (se `docs/infra/jobbkø.md`). Opplastingen oppretter to jobber i sekvens: først `whisper_transcribe`, deretter `openrouter_analyze` (som trigges automatisk ved fullført transkripsjon).
|
||||
3. **Transkripsjon (faster-whisper):** Maskinrommet kaller faster-whisper-server (OpenAI-kompatibelt API, `POST /v1/audio/transcriptions`) med `response_format=srt` og mottar SRT direkte. Modell: `medium` med `initial_prompt` (navneliste). Modellvalg er en sentral serverinnstilling — null konfigurasjon per samling.
|
||||
4. **Lagring av transkripsjon (PostgreSQL):** Maskinrommet parser SRT-responsen og skriver segmenter til `transcription_segments`-tabellen. Hver kjøring grupperes med `transcribed_at`-tidsstempel. Medianoden oppdateres med sammenhengende tekst i `content`-feltet.
|
||||
5. **Avledede formater:** Genereres direkte fra segmenttabellen:
|
||||
* **Ren tekst** — `SELECT content FROM transcription_segments WHERE node_id = $1 ORDER BY seq` → sammenhengende tekst
|
||||
* **Full-text søkeindeks** — GIN-indeks på `content`-kolonnen for oppslag på tvers av episoder
|
||||
* **Tidsoppslag** — "hva ble sagt ved 05:23?" er en range-query på `start_ms`/`end_ms`
|
||||
6. **AI-Analyse (OpenRouter):** Transkripsjonen sendes til OpenRouter (Claude-modell). Resultatet lagres som **noder med edges** til episoden — ikke som metadata-felt:
|
||||
* Tittel-forslag → node med `title`-edge (`variant: "ai"`)
|
||||
* Sammendrag → node med `summary`-edge (`variant: "ai"`)
|
||||
* Show notes → node med `show_notes`-edge (`variant: "ai"`)
|
||||
* Kapittler → noder med `chapter`-edge (metadata: `{ at: "00:05:23" }`)
|
||||
Se `docs/concepts/publisering.md` § "Presentasjonselementer er noder".
|
||||
7. **Manuell Godkjenning & Fletting (SvelteKit):**
|
||||
* *For nye episoder:* AI-genererte noder presenteres som forslag. Redaksjonen kan godkjenne, redigere, eller opprette egne varianter (nye noder med `variant: "editorial"`).
|
||||
* *For oppdateringer:* Viser AI-ens nye forslag side-om-side med eksisterende noder. Redaksjonen velger hvilke som skal være aktive.
|
||||
* Flere varianter av tittel/sammendrag kan A/B-testes automatisk i RSS-feed og episodeside.
|
||||
8. **Publisering (PostgreSQL):** Ved "Godkjenn" markeres aktive varianter. Maskinrommet rendrer episodeside og oppdaterer RSS basert på aktive noder.
|
||||
9. **RSS-Generering:** SvelteKit-appen genererer en oppdatert `/feed.xml`.
|
||||
|
||||
### 2.1 Episodeside (publisert visning)
|
||||
Hver publisert episode får en side med:
|
||||
* Lydavspiller + sammendrag + kapitler + stikkord
|
||||
* Personreferanser og artikler (koblet via kunnskapsgrafen)
|
||||
* Fane: **Transkripsjon** (lesbart dokument med avspillingsknapp per segment)
|
||||
* Nedlastbar SRT-fil (generert fra segmenttabellen)
|
||||
|
||||
### 2.2 Transkripsjonsvisning (universell)
|
||||
Transkripsjonsvisningen er universell — brukes for podcast-episoder, møter, voice memos og alle andre lydnoder med transkripsjon. Visningen tilbyr:
|
||||
|
||||
* **Segmenter med tidsstempler** — hvert segment viser start/slutt-tid
|
||||
* **Avspillingsknapp per segment** — klikk hopper til riktig sted i lydfilen (CAS-URL fra medianoden, `audio.currentTime = start_ms / 1000`)
|
||||
* **Redigerbare tekstfelt** — brukeren kan korrigere feil, `edited`-flagget settes automatisk
|
||||
* **Re-transkripsjon:** Ved ny transkribering (ny modell, redigert lyd) vises begge versjonene side-om-side. Manuelt redigerte segmenter (`edited = true`) fra forrige versjon highlightes — brukeren velger per segment om forrige redigering eller ny transkripsjon skal gjelde.
|
||||
|
||||
### 2.3 Live-to-Archive (Studio/Møterom → Episode)
|
||||
|
||||
Mye av innholdet som ender i podcastfabrikken starter som live-innspilling
|
||||
i Studioet eller Møterommet. Denne flyten unngår manuell opplasting:
|
||||
|
||||
1. Under innspilling i LiveKit produserer Whisper chunks i sanntid
|
||||
2. Når innspillingen stoppes → automatisk jobb `studio_to_episode`:
|
||||
- Konsoliderer live-chunks til segmenter i `transcription_segments`
|
||||
- Oppretter episode-node med storyboard basert på markører satt under innspilling
|
||||
- Trigger AI-analyse (samme pipeline som ved vanlig opplasting)
|
||||
3. Lydfilen lagres i CAS med edge til episode-noden
|
||||
4. Episoden dukker opp i podcastfabrikken som et utkast — klar for
|
||||
godkjenning, redigering og publisering
|
||||
|
||||
Fordel: aldri behov for «last opp MP3 etter innspilling» — flyten er
|
||||
live → arkiv → publisering.
|
||||
|
||||
## 3. Transkripsjonssegmenter (PostgreSQL)
|
||||
|
||||
Master-kopien av alle transkripsjoner lever i `transcription_segments`-tabellen.
|
||||
SRT og ren tekst er avledede eksportformater.
|
||||
|
||||
```sql
|
||||
CREATE TABLE transcription_segments (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
transcribed_at TIMESTAMPTZ NOT NULL, -- grupperer segmenter fra samme kjøring
|
||||
seq INT NOT NULL,
|
||||
start_ms INT NOT NULL,
|
||||
end_ms INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
edited BOOLEAN DEFAULT false,
|
||||
|
||||
UNIQUE (node_id, transcribed_at, seq)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_segments_node ON transcription_segments (node_id, transcribed_at, seq);
|
||||
CREATE INDEX idx_segments_fts ON transcription_segments
|
||||
USING gin(to_tsvector('norwegian', content));
|
||||
```
|
||||
|
||||
### 3.1 Universell tjeneste
|
||||
Transkribering er en tjeneste fra maskinrommet med null konfigurasjon per samling.
|
||||
Modellvalg (`medium` for asynkron, evt. annen for synkron/live) er en sentral
|
||||
serverinnstilling. Alle lydnoder — podcast-episoder, møter, voice memos —
|
||||
transkriberes via samme pipeline og lagres i samme tabell.
|
||||
|
||||
### 3.2 Tilhørighet og tilgang
|
||||
Tilgang styres gjennom `node_id` — den som har tilgang til medianoden (via
|
||||
`node_access`) har tilgang til segmentene. Ingen RLS på segmenttabellen,
|
||||
ingen duplisering av tilgangslogikk.
|
||||
|
||||
### 3.3 Re-transkripsjon
|
||||
Ny transkripsjon (ny modell, redigert lyd) gir nye rader med nytt
|
||||
`transcribed_at`-tidsstempel. Forrige versjon beholdes inntil brukeren
|
||||
har vurdert. "Gjeldende transkripsjon" = siste `transcribed_at` per `node_id`.
|
||||
|
||||
### 3.4 SRT-eksport
|
||||
SRT rekonstrueres trivielt fra segmenttabellen:
|
||||
```
|
||||
{seq}
|
||||
{start_ms → HH:MM:SS,mmm} --> {end_ms → HH:MM:SS,mmm}
|
||||
{content}
|
||||
```
|
||||
|
||||
## 4. Spesialhåndtering: Oppdatering av eksisterende episoder (Cache-busting)
|
||||
Podcast-apper (Apple, Spotify) og CDN-er cacher innhold aggressivt. For at en endring i f.eks. "Introepisoden" skal slå gjennom hos lytterne, MÅ følgende tekniske regler følges:
|
||||
|
||||
* **Filnavn-versjonering (Viktigst!):** Den nye lydfilen skal *aldri* overskrive det gamle filnavnet på disken. Systemet må legge til en hash, UUID eller et tidsstempel (f.eks. `intro_v2_1710289000.mp3`). Dette tvinger appene til å laste ned filen på nytt.
|
||||
* **RSS `<guid>` (Global Unique Identifier):** Denne taggen MÅ forbli 100% statisk/uendret fra originalepisoden. Den forteller appene at "Dette er fortsatt samme episode, ikke lag en duplikat".
|
||||
* **RSS `<enclosure>`:** URL-en i `enclosure`-taggen (som peker på `.mp3`-filen) oppdateres i databasen til å reflektere det *nye* filnavnet.
|
||||
* **RSS `<pubDate>`:** SvelteKit-grensesnittet skal gi redaksjonen en toggle-knapp ved oppdatering:
|
||||
* Alternativ A: "Behold opprinnelig dato" (Episoden oppdateres i det stille for nye lyttere).
|
||||
* Alternativ B: "Sett dato til NÅ" (Episoden spretter til toppen av feeden som en ny utgivelse).
|
||||
|
||||
## 5. Whisper-konfigurasjon
|
||||
* **Tjeneste:** `fedirz/faster-whisper-server` (Docker, OpenAI-kompatibelt API)
|
||||
* **Endepunkt:** `POST /v1/audio/transcriptions` med `response_format=srt`
|
||||
* **Beslutning:** SRT direkte fra Whisper. SRT gir tidsstempler + tekst som er trivielt å parse til segmenter. Maskinrommet parser SRT og skriver til `transcription_segments`-tabellen.
|
||||
* **Modell:** `medium` med `initial_prompt`. Sentral serverinnstilling, ikke per samling.
|
||||
* **Modeller (benchmarket med E277.mp3, 32:45 norsk tale, CPU i7-13900K):**
|
||||
|
||||
| Konfigurasjon | Tid (CPU) | Seg | Tegn | Kommentar |
|
||||
|---|---|---|---|---|
|
||||
| `small` | ~6 min | 777 | 25851 | Rask, men hyppige feil i egennavn |
|
||||
| `medium` | ~18 min | 442 | 26938 | God balanse, noen navnefeil |
|
||||
| `medium` + prompt | ~17 min | 455 | 26957 | Riktige egennavn, anbefalt standard |
|
||||
| `large-v3` | ~24 min | 520 | 14559 | Hallusinerer uten VAD — IKKE bruk |
|
||||
| `large-v3` + VAD | ~31 min | 964 | 28291 | God kvalitet, men noen navnefeil |
|
||||
| `large-v3` + VAD + prompt | ~31 min | 964 | 28295 | Best kvalitet, men for treg |
|
||||
|
||||
* **Valg:** `medium` + `initial_prompt`. God nok kvalitet, rask nok for produksjon.
|
||||
* **Språk:** Sett `language=no` eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk.
|
||||
|
||||
### 5.1 initial_prompt (navneliste)
|
||||
`initial_prompt` primes Whisper med ordforråd som forbedrer gjenkjenning av egennavn. Effekten er tydelig:
|
||||
* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja"
|
||||
* Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig)
|
||||
|
||||
Prompten bygges automatisk av maskinrommet fra en statisk navneliste (miljøvariabel) + aktører i kunnskapsgrafen (senere):
|
||||
```
|
||||
Sidelinja podcast med Vegard Nøtnæs, Trond Sørensen, Arne Eidshagen,
|
||||
Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie
|
||||
```
|
||||
|
||||
## 6. Per samlings-node konfigurasjon
|
||||
Hver samlings-node (f.eks. Sidelinja) har sin egen podcast-konfigurasjon, lagret som JSONB-metadata på noden:
|
||||
|
||||
### 6.1 Mediefiler
|
||||
Lydfiler lagres i CAS (content-addressable store) med edges til episode-noder. Caddy ruter trafikk basert på domene (fra samlings-nodens metadata) til riktig innhold.
|
||||
|
||||
### 6.2 AI-prompts
|
||||
* **LLM system-prompts:** OpenRouter-prompts for metadata-uttrekk lagres i metadata (`llm_prompts`) slik at AI-en kjenner konteksten og vertene for akkurat den podcasten.
|
||||
|
||||
### 6.3 RSS-feed
|
||||
SvelteKit genererer `/feed.xml` dynamisk basert på domenet forespørselen kommer fra (matcher samlings-nodens domene-metadata), eller node-slug som fallback.
|
||||
|
||||
### 6.4 Statistikk
|
||||
Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig samlings-node basert på domene i loggen.
|
||||
|
||||
## 7. Instruks for Claude Code
|
||||
* **Lydfiler:** Håndter filopplasting i SvelteKit strømmende (streaming) for filer >100MB for å unngå minne-lekkasjer.
|
||||
* **Feilhåndtering:** Hvis OpenRouter timer ut eller Whisper feiler, må oppgaven flagges med status `error` i databasen slik at brukeren kan trigge jobben på nytt manuelt via UI.
|
||||
* **Opprydding (Disk):** Når en fil oppdateres vellykket, skal den gamle/foreldede `.mp3`-filen enten slettes fra Hetzner-serveren automatisk, eller flyttes til en `/archive/`-mappe basert på en miljøvariabel.
|
||||
* **Transkripsjoner:** Master-kopi i `transcription_segments`-tabellen. SRT og ren tekst er eksportformater som genereres fra tabellen. Aldri rediger avledede formater — segmenttabellen er kilden.
|
||||
* **Tilhørighet:** Alle jobber, mediefiler og metadata knyttes til riktig samlings-node via edges. Hent config (prompts, domene) fra samlings-nodens JSONB-metadata.
|
||||
|
|
|
|||
762
docs/concepts/publisering.md
Normal file
762
docs/concepts/publisering.md
Normal 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
|
||||
201
docs/features/ressursforbruk.md
Normal file
201
docs/features/ressursforbruk.md
Normal 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`).
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
205
docs/primitiver/traits.md
Normal 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`
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -97,8 +97,7 @@ Uavhengige faser kan fortsatt plukkes.
|
|||
- [x] 7.2 Transkripsjons-pipeline: lydfil i CAS → maskinrommet trigger Whisper → resultat i `content`-feltet.
|
||||
- [x] 7.3 Voice memo i frontend: opptak-knapp i input-komponenten → upload → CAS → transkripsjon.
|
||||
- [x] 7.4 Lyd-avspilling: spiller av original lyd fra CAS-node. Waveform-visning.
|
||||
- [~] 7.5 Segmenttabell-migrasjon: opprett `transcription_segments`-tabell i PG. Oppdater `transcribe.rs` til SRT-format → parse → skriv segmenter. Miljøvariabler: `WHISPER_MODEL` (default "medium"), `WHISPER_INITIAL_PROMPT`. Ref: `docs/concepts/podcastfabrikken.md` § 3.
|
||||
> Påbegynt: 2026-03-17T18:10
|
||||
- [x] 7.5 Segmenttabell-migrasjon: opprett `transcription_segments`-tabell i PG. Oppdater `transcribe.rs` til SRT-format → parse → skriv segmenter. Miljøvariabler: `WHISPER_MODEL` (default "medium"), `WHISPER_INITIAL_PROMPT`. Ref: `docs/concepts/podcastfabrikken.md` § 3.
|
||||
- [ ] 7.6 Transkripsjonsvisning i frontend: segmenter med tidsstempler, avspillingsknapp per segment (hopper til riktig sted i lydfilen), redigerbare tekstfelt (setter `edited = true`). Universell komponent for podcast, møter, voice memos.
|
||||
- [ ] 7.7 Re-transkripsjonsflyt: ved ny transkripsjon, vis side-om-side med forrige versjon. Highlight manuelt redigerte segmenter fra forrige versjon. Bruker velger per segment.
|
||||
- [ ] 7.8 SRT-eksport: generer nedlastbar SRT-fil fra `transcription_segments`-tabellen.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue