synops/scripts/synops.md
vegard c1d3ad66a5 Fjern gjenværende v2-referanser, dokumenter editor og tekstlagring
Rydder opp siste «v2»-referanser i docs (status_quo, migration_safety,
personlig_workspace, spacetimedb_integrasjon). Legger til editor-seksjon
i universell_input.md (TipTap, presets, tekstlagring) og oppdaterer
nodes.md med content/metadata.document-modellen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 10:55:39 +01:00

405 KiB
Raw Blame History

================================================================ FILE: CLAUDE.md

Synops — Claude Code Prosjektguide

Prosjektoversikt

Synops er en plattform for redaksjonelt arbeid og podcast-produksjon. Self-hosted på Hetzner VPS med full datakontroll.

Synops er plattformen. Sidelinja (podcastredaksjonen) er en tenant — en organisasjon som bruker Synops. Denne distinksjonen er bevisst: plattformkode og infrastruktur er skilt fra tenant-data og -innhold.

Arbeidsflyt

  • Standard arbeidsmodus: Start i planleggingsmodus. Lag en grundig plan, få godkjenning, deretter implementer. Jobbene er ment å kunne kjøre lenge autonomt uten input underveis.
  • Utvikling mot server. Ingen lokale databaser eller tjenester. Frontend (SvelteKit) utvikles lokalt med HMR mot server-API. Rust bygges lokalt, deployes til server for integrasjonstest.
  • Browser-testing: Claude har ikke tilgang til browser. Visuell testing gjøres av Vegard. Claude verifiserer backend (kompilering, API, DB-state).
  • Commit og push: Bruk egen vurdering. Trygt og reverserbart.
  • Deploy til produksjon: Krever alltid eksplisitt godkjenning fra Vegard.
  • Diskusjon: Forklar og diskuter før arkitekturendringer. For implementering innenfor eksisterende spec — bare kjør.

Dokumentasjonstre

CLAUDE.md er eneste startdokument. Alt annet ligger under docs/:

  • docs/arkitektur.md — Overordnet arkitektur, lagmodell, teknologivalg
  • docs/retninger/ — Arkitektoniske teser og vedtatte retninger:
    • README.md — Oversikt og status
    • status_quo.md — Hva v1 var: ambisiøse primitiver, tradisjonell overflate
    • rom_ikke_forum.md — Opplevelse-først, to-lags-modell, administrativ opplevelse
    • universell_input.md — Tre primitiver (input, mottak, kommunikasjon), noder+edges
    • maskinrommet.md — Rust-orkestrator: fang, prosesser, lever
    • bruker_ikke_workspace.md — Noder er sentrum: brukere, team, innhold er noder. Tilgangsmatrise fra edges.
    • datalaget.md — PG(+AGE) som graf og arkiv, SpacetimeDB som sanntidslag
  • docs/primitiver/ — Spesifikasjoner for kjerneprimitivene:
    • nodes.md — Node-skjema, node_kind, visibility, CAS-noder, eierskap
    • edges.md — Edge-skjema, typer, metadata, systemedges
    • (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
  • docs/features/ — Tekniske byggeklosser:
    • Se individuelle filer for chat, kanban, kalender, meldingsboks, kunnskapsgraf, whiteboard, live transkripsjon, 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
    • lokal.md — Lokalt utviklingsmiljø (WSL2, mot server)
    • migration_safety.md — Sjekkliste for PostgreSQL-migrasjoner (v1 workspace-RLS, trenger omskriving til node_access)
  • docs/infra/ — Infrastruktur og drift:
    • ai_gateway.md — LiteLLM som sentralisert AI-ruter (BYOK + fallback)
    • api_grensesnitt.md — Kommunikasjonskart: SvelteKit er web-API, Rust er worker
    • jobbkø.md — PostgreSQL-basert køsystem for bakgrunnsjobber
    • synkronisering.md — PostgreSQL ↔ SpacetimeDB dataflyt og eierskapsmodell
  • docs/erfaringer/ — Lærdommer fra v1 (adapter-mønster, Svelte 5, SpacetimeDB, Authentik)
  • reference/ — Kode fra v1 med gjenbruksverdi (Editor.svelte)
  • ops/ — Repeterbare vedlikeholdsjobber (ryddejobb, doc-audit, drift-sjekk)

Aktører

  • Vegard — serveradmin, utvikler, bruker. SSH: vegard@157.180.81.26
  • Claude — AI-agent, utvikler. SSH: claude@157.180.81.26
  • Begge har sudo + docker-tilgang på serveren.

Stack

  • Orkestrator/Backend: Rust (maskinrommet)
  • Frontend: SvelteKit (TypeScript, PWA)
  • Sanntid: SpacetimeDB
  • Database/Graf: PostgreSQL (+Apache AGE ved behov)
  • Binærlagring: CAS (content-addressable store)
  • AI: LiteLLM (AI Gateway), faster-whisper (STT), ElevenLabs (TTS)
  • Infra: Docker Compose, Caddy, Authentik (SSO)

Produksjonsserver

  • IP: 157.180.81.26
  • SSH: ssh vegard@157.180.81.26 / ssh claude@157.180.81.26
  • Root-login: Deaktivert
  • SSH-nøkkel (lokal WSL2): /home/vegard/.ssh/id_ed25519
  • Server-filer: /srv/synops/ (docker-compose.yml, .env, config/, data/)
  • Domener:
    • sidelinja.org — Tenant-app (Sidelinja podcastredaksjonen)
    • auth.sidelinja.org — Authentik SSO
    • git.sidelinja.org — Forgejo (SSH port 222)
    • vegard.info — Separat nettsted
    • synops.no — Plattformdomene (reservert, ikke i bruk ennå)

Git

  • Repos i Forgejo:
    • vegard/synops — plattformkode og arkitektur: ssh://git@git.sidelinja.org:222/vegard/synops.git
    • sidelinja/sidelinja — podcastinnhold: ssh://git@git.sidelinja.org:222/sidelinja/sidelinja.git
  • Git-identitet: vegard / vnotnes@pm.me
  • Forgejo-bruker: vegard (admin)
  • CLI: Bruk tea, ikke gh (vi bruker Forgejo, ikke GitHub)

Viktige regler

  • Aldri eksponere databaseporter mot internett
  • All AI-trafikk via maskinrommet, aldri direkte til leverandør-APIer
  • Tunge jobber (Whisper, LLM, TTS) blokkerer aldri brukerforespørsler
  • Sjekk alltid relevant doc i docs/ før implementering
  • Dokumenter lærdommer i docs/erfaringer/

Lagmodell

GUI (SvelteKit)
  │ skriv              │ les (sanntid, direkte WebSocket)
  ▼                    ▼
Maskinrommet (Rust)    SpacetimeDB ──→ GUI
  │
  ▼
Tjenester: PG+AGE, SpacetimeDB, CAS, Whisper, LiteLLM, LiveKit ...

Kjerneprinsipper

  1. Alt er noder og edges. Ingen separate tabeller for chat, kanban, kalender, notater. Visninger er spørringer mot grafen.
  2. Tre primitiver: Input (fanger), Mottak (presenterer), Kommunikasjon (samler folk). Alt annet er visninger og edges.
  3. Maskinrommet orkestrerer alt. Fang, prosesser, lever. Edge-drevet ressursallokering. Tjenester under er utbyttbare.
  4. Noder er sentrum. Brukere, team, innhold — alt er noder. Du ser dine edges. Tilgang via materialisert tilgangsmatrise.
  5. Privat er default. Input uten mottaker-edge er privat. Security by design, ikke konfigurasjon.
  6. PG er arkivet, SpacetimeDB er nåtid. Ingen eierskapskonflikt. To lag, to roller.

================================================================ FILE: docs/arkitektur.md

Arkitektur — Synops

Visjon

Synops er en plattform for redaksjonelt arbeid og podcast-produksjon. Ikke en webapp med features — en plattform med primitiver som kan bli hva som helst. Sidelinja (podcastredaksjonen) er en tenant som bruker Synops.

Alt er noder og edges i en graf. En bruker er en node. Et team er en node. En mediefil er en node. Hva noe "er" bestemmes av edges, ikke av noden selv. Maskinrommet eier alle skrivinger. Frontend er et tynt lag som leser grafen fra SpacetimeDB.

Lagmodell

┌─────────────────────────────────────┐
│  GUI (SvelteKit)                     │
│  Visninger: spørringer mot STDB     │
└────────┬──────────────────┬─────────┘
         │ intensjoner       │ les (sanntid)
         │                   │ direkte WebSocket
┌────────▼────────┐  ┌──────▼──────────┐
│  Maskinrommet    │  │  SpacetimeDB    │
│  (Rust)          │  │  Hele grafen    │
│  Eier alle       │  │  (noder+edges)  │
│  skrivinger      │  └─────────────────┘
└──┬─────┬─────┬──┘
   │     │     │
   ▼     ▼     ▼
┌─────┐┌─────┐┌─────┐┌─────────────┐
│ PG  ││STDB ││ CAS ││ Whisper,    │
│(bak)││(skr)││     ││ LiteLLM,   │
│     ││     ││     ││ LiveKit ... │
└─────┘└─────┘└─────┘└─────────────┘

Skrivestien

GUI → intensjon → Maskinrommet (Rust) → SpacetimeDB (instant) → PG (async)

Frontend sender intensjoner (ikke data). Maskinrommet validerer, skriver til SpacetimeDB først for umiddelbar oppdatering, deretter persisterer til PG asynkront. Maskinrommet leser edges og bestemmer hvilke tjenester som trigges.

Lesestien (sanntid)

SpacetimeDB → GUI (direkte WebSocket)

SpacetimeDB holder hele grafen — alle noder og edges. Frontend abonnerer via WebSocket med edge-filtre. Visninger er spørringer mot STDB, ikke forhåndsdefinerte API-endepunkter.

Lesestien (tunge spørringer)

GUI → Maskinrommet (Rust) → PG

Søk, statistikk, semantisk søk (pgvector), graftraversering (AGE/Cypher). For operasjoner der STDB ikke er egnet.

Datamodell

Alt er noder

Én nodes-tabell. Alt er noder: brukere, team, meldinger, oppgaver, notater, mediefiler, kommunikasjonsrom, samlings-noder. En bruker er en node som tilfeldigvis kan logge inn.

Felles skjema: id, innhold, created_at, content_hash (→ CAS). Modalitetsspesifikk metadata i JSONB.

Edges definerer alt

Én edges-tabell. Edge-typer er frie strenger. Hva en node "er" bestemmes utelukkende av dens edges:

  • Node + edge til kanal = chatmelding
  • Node + edge til board + status-edge = kanban-kort
  • Node + edge til dato = kalenderoppføring
  • Node + edge til kun bruker = privat notat
  • Node uten edges = løs tanke

Visninger er spørringer

Visninger er spørringer mot SpacetimeDB med edge-filtre:

  • Chat = noder med kanal-edge, sortert på tid
  • Kanban = noder med board-edge, gruppert på status
  • Kalender = noder med dato-edge, på tidslinje
  • Mottaksflate = noder med edge til deg, vektet på relevans

Ingen forhåndsdefinerte visningstyper. Nye visninger er nye filtre.

Input

Én universell input-komponent, gjenbrukt overalt. Fanger tekst, lyd, bilde, AI, URL. Konteksten (hvor du er) bestemmer hvilke edges som legges til. Output er alltid en node.

Struktur uten workspaces

Ingen workspace-velger. Ingen workspace_id. Samlings-noder gir struktur: et team er en samlings-node, et prosjekt er en samlings-node. Du ser noder du har tilgang til via dine edges.

Aliaser

En bruker kan ha alias-noder (f.eks. for ulike roller). Koblet med system-edges som er usynlige for traversering.

Maskinrommet

Rust-tjeneste med tre operasjoner: fang, prosesser, lever.

Eier alle skrivinger. Frontend sender intensjoner, maskinrommet validerer og utfører. Edge-drevet ressursorkestrering: maskinrommet leser edges og bestemmer hvilke tjenester som spinnes opp.

Forvalter også CAS (binærlagring) med intelligent pruning basert på modalitet, edges og aksessmønstre.

Sikkerhet

Synlighet (visibility)

Noder har en visibility-egenskap med fire nivåer:

  • hidden — usynlig, kun tilgjengelig via system-edges
  • discoverable — kan finnes, men innhold skjult
  • readable — innhold lesbart for de med tilgang
  • open — tilgjengelig for alle med traverseringssti

Traversering respekterer visibility. Du kan ikke følge edges gjennom noder du ikke har lov til å se.

Materialisert tilgangsmatrise

node_access-tabell som cacher beregnet tilgang fra edge-grafen. Oppdateres ved edge-endring, ikke ved lesing. Rask oppslag — ingen rekursiv graftraversering per forespørsel.

Privat er default

Input uten mottaker-edge er automatisk privat. Ingen ser det. Deling er å legge til edges.

Datalag

PostgreSQL

Persistent backup og arkiv. Alle noder og edges. Fulltekstsøk, pgvector (semantisk søk), JSONB. Apache AGE for Cypher ved behov.

SpacetimeDB

Holder hele grafen — alle noder og edges. Frontend abonnerer via WebSocket. Maskinrommet skriver hit først for umiddelbar oppdatering, PG synkroniseres asynkront.

CAS (Content-Addressable Store)

Binærdata (lyd, bilde, video) lagret med hash. TTL basert på modalitet, edges og aksesslog. Generert innhold (TTS, thumbnails) er en cache som regenereres on-demand.

Teknologivalg

Rolle Teknologi Begrunnelse
Orkestrator Rust Ytelse, typesikkerhet, eier alle skrivinger
Frontend SvelteKit PWA, SSR, tynt lag mot STDB
Database PostgreSQL Persistent backup, pgvector, fulltekstsøk, AGE
Sanntid SpacetimeDB Hele grafen, WebSocket-subscriptions, ~10μs
Binærlagring CAS (filsystem) Enkel, deduplisering, ingen ekstern avhengighet
AI Gateway LiteLLM Multi-provider, BYOK, OpenRouter fallback
STT faster-whisper Lokal, god norsk kvalitet
TTS ElevenLabs (→ lokal) Kommersiell start, lokal når kvaliteten holder
Auth Authentik SSO, OIDC, self-hosted
Reverse proxy Caddy Auto-TLS, enkel config
Lyd/video LiveKit WebRTC, self-hosted

Retninger

Arkitekturen er basert på vedtatte retninger dokumentert i docs/retninger/. Se docs/retninger/README.md for oversikt.

================================================================ FILE: docs/retninger/README.md

Retninger

Store, åpne spørsmål om prosjektets identitet og arkitektoniske retning.

Dette er ikke features, ikke proposals, ikke spesifikasjoner — det er teser som utforsker hvordan Sidelinja bør tenke om seg selv. En retning kan påvirke alt fra teknologivalg til UX-filosofi, men den er ikke en beslutning. Den er en pågående diskusjon.

Pipeline

retninger/     →     kan informere alt:
(tese)               concepts/, features/, infra/, arkitektur.md

En retning "forfremmes" ikke — den modnes, og det den konkluderer med påvirker andre dokumenter. En retning kan også forkastes eller parkeres.

Oversikt

Retning Status Kjernespørsmål
Status quo Referanse Hva er Sidelinja i dag? Ankerpunkt for de andre retningene.
Rom, ikke forum Åpen Bør Sidelinja være en oppslukende sanntidsopplevelse fremfor en tradisjonell webapp?
Universell input og mottak Besluttet Én multimodal input-primitiv, én mottaksflate, kommunikasjonsnoder. Edges definerer alt.
Maskinrommet Besluttet Én Rust-tjeneste: fang, prosesser, lever. Eier all skriving. Edge-drevet ressursorkestrering.
Noder er sentrum Besluttet Alt er noder (brukere, team, innhold). Edges definerer relasjoner og tilgang. Materialisert tilgangsmatrise for RLS.
Datalaget Besluttet SpacetimeDB holder hele grafen, PG er persistent arkiv, CAS for binærdata, AGE ved behov

Format

  • Hva er tesen?
  • Hva motiverer den? (observasjoner, frustrasjoner, inspirasjon)
  • Hva ville vært annerledes hvis vi fulgte den?
  • Spenninger og åpne spørsmål
  • Ingen krav om konklusjon

================================================================ FILE: docs/retninger/bruker_ikke_workspace.md

Noder er sentrum

Status: Besluttet.

Alt er noder. En bruker er en node. Et team er en node. Et møte er en node. Sidelinja er en node. Relasjoner mellom dem er edges. Tilgangskontroll via materialisert tilgangsmatrise beregnet fra edge-grafen.

Beslutningen

  1. Alt er noder. Brukere, team, prosjekter, innhold, møter — alt er rader i nodes-tabellen. En bruker er en node som tilfeldigvis kan logge inn.
  2. Relasjoner er edges. Vegard → Sidelinja (owner). Trond → Sidelinja (member). Møtereferat → møte (belongs_to).
  3. Ingen containere. Hva du ser er summen av dine edges.
  4. Samlings-noder gir struktur — de er vanlige noder som fungerer som gravitasjonspunkt.
  5. Privat er default — en node uten edges til andre er kun din.
  6. Tilgangskontroll via node_access-matrise, oppdatert ved edge-endring, brukt av RLS ved lesing.

Visibility

Visibility er en egenskap på noden som definerer hva som gjelder for alle uten eksplisitt edge. Eksplisitte edges overrider alltid oppover.

CREATE TYPE visibility AS ENUM ('hidden', 'discoverable', 'readable', 'open');
Nivå Oppdagbar Lesbar Interagerbar
hidden Nei Nei Nei — kun via eksplisitt edge
discoverable Ja (søk/katalog) Nei Kontaktforespørsel
readable Ja Ja Nei — krever edge
open Ja Ja Ja

Eksempler:

  • Spøkelsesbruker: hidden — usynlig, kun invitasjon
  • Katalogbruker: discoverable — finnes i søk, profil krever edge
  • Podcast-episode: readable — alle kan lytte, bare teamet kan redigere
  • Kunnskapsgraf-entitet: open — alle kan se og bidra

Traverseringsregelen

Visibility er en hard grense ved traversering. Når du følger edges i grafen, kan du bare se noder hvis deres visibility tillater det — eller du har en eksplisitt edge.

Eksempel: Episode #42 (readable) har en host-edge til Peter (hidden). Anonym bruker leser episoden, følger host-edge → Peter er hiddenstopp, usynlig. Trond (som har edge til Peter) følger samme edge → ser Peter.

Ingen transitivitet bryter denne regelen. Uansett hvor mange offentlige noder som har edges til en hidden node, forblir den skjult for de uten eksplisitt edge.

Brukere er noder

En brukernode er en node med en kobling til Authentik for autentisering. Alt annet — navn, preferanser, roller, relasjoner — er noden og dens edges.

users-tabellen krymper til autentisering:

CREATE TABLE auth_identities (
    node_id       UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    authentik_sub TEXT UNIQUE NOT NULL,  -- Authentik subject ID
    email         TEXT UNIQUE NOT NULL
);

Brukerens profil, innstillinger og metadata lever på noden (JSONB) eller som edges. Autentiseringstabellen er en tynn bro mellom "denne HTTP-sesjonen" og "denne noden i grafen."

Aliaser

En bruker kan opprette aliaser — separate noder som representerer en offentlig persona eller anonym identitet.

Aleks (hidden) ──alias──→ Bjørn (readable)

Bjørn er en egen node med eget navn, egen profil, egen visibility. Omverdenen ser bare Bjørn. alias-edgen er en systemedge — usynlig ved traversering, aldri eksponert utad. Ellers ville "hvem kontrollerer Bjørn?" lekke Aleks' identitet.

Én flate, kontekst bestemmer identitet

Aleks ser alt i sin mottaksflate — egne noder og alt som kommer til Bjørn. Han svarer fra sin egen flate. Systemet bestemmer created_by basert på konteksten:

  • NN skrev til Bjørn → Aleks svarer → meldingen stemplet med Bjørn som created_by. Automatisk, ingen bytte.
  • Vegard skrev til Aleks direkte → svaret kommer fra Aleks.

Ingen manuell identitetsbytte. Aleks trenger aldri å "logge inn som Bjørn" eller bytte modus. Konteksten (hvilken samtale, hvilken kanal, hvem mottakeren kjenner) bestemmer hvilken node som er avsender.

Identitetsvalg ved tvetydighet

Noen ganger kjenner mottakeren både personen og aliaset. Vegard kjenner Aleks og vet hvem Bjørn er. Hvis Vegard sender en melding til Bjørn under en episode, og Aleks svarer:

  • Default: svaret kommer fra Bjørn (konteksten er Bjørn).
  • Valg: systemet vet at Vegard har edge til Aleks. Aleks kan velge "svar som Aleks" — da startes en ny, separat samtale mellom Vegard og Aleks.

Alias-grensen brytes aldri automatisk. Bare med eksplisitt handling fra personen bak aliaset.

Flere aliaser

Ingenting stopper en bruker fra å ha flere aliaser — Bjørn for podcast, et anonymt alias for publisering, seg selv for privat. Hver er en node med en alias-edge fra brukernoden. Alle samles i én mottaksflate.

Alias vs. owner

Edge Betydning Eksempel
alias Jeg er denne noden. Usynlig systemedge. Aleks → Bjørn
owner Jeg forvalter denne noden. Synlig for medlemmer. Vegard → Sidelinja

alias = identitet. owner = ansvar. Aleks er Bjørn. Vegard eier Sidelinja, men han er ikke Sidelinja.

Team er noder

Et team er en node med member-edges til brukernoder. Gi teamet tilgang til en samlings-node → alle teammedlemmer arver tilgang via transitiv traversering. Ingen egen team-mekanisme.

Vegard (node) ──member──→ Podcastteamet (node) ──member──→ Sidelinja (node)
Trond  (node) ──member──→ Podcastteamet (node)

Trond får tilgang til alt under Sidelinja fordi han er medlem av Podcastteamet som er medlem av Sidelinja. Tilgangsmatrisen beregner dette transitivt.

Samlings-noder

En samlings-node er en vanlig node som fungerer som gravitasjonspunkt. "Sidelinja" er en samlings-node Vegard oppretter og eier. Trond kobles på — direkte eller via et team. Andre inviteres inn.

Det kan finnes mange samlings-noder — eller få. Et podcast-prosjekt, en research-samling, en vennegjeng. De er ikke noe spesielt i datamodellen — bare noder med edges til andre noder.

En innholdsnode kan ha edge til flere samlings-noder. En faktoid om en gjest er relevant for både podcast-prosjektet og research-samlingen. Ikke kopi — samme node, to edges.

Felles kontekst

En samlings-node bærer kontekst (i JSONB) som arves av tilknyttede noder:

  • Pruning-profil — hvor aggressivt slettes binærdata?
  • Tema — visuelt uttrykk
  • AI-konfigurasjon — prompts, modell, regler
  • Default synlighet — for nye noder opprettet i denne konteksten
  • Kapasitet — ressursgrenser for maskinrommet

Konflikter (node med edge til to samlings-noder med ulike pruning-profiler) løses med: mest konservativ vinner, eller eier-edge bestemmer.

Tilgangsroller

Rolle Hva den gir
owner Full kontroll — slette, endre tilgang, endre innstillinger
admin Kan invitere/fjerne andre, endre konfigurasjon
member Kan gi input og motta
reader Kan kun motta (observatør, lytter)

Roller er en egenskap på edgen, ikke på noden. Vegard har owner-edge til Sidelinja og member-edge til Research-gruppa. Samme node, ulike roller i ulike kontekster.

Tilgangsmatrise — spesifikasjon

Skjema

CREATE TYPE access_level AS ENUM ('reader', 'member', 'admin', 'owner');

CREATE TABLE node_access (
    subject_id  UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    object_id   UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    access      access_level NOT NULL,
    via_edge    UUID REFERENCES edges(id) ON DELETE CASCADE,
    PRIMARY KEY (subject_id, object_id)
);

CREATE INDEX idx_na_subject ON node_access (subject_id);

subject_id er noden som har tilgang (bruker eller team). object_id er noden det gis tilgang til. via_edge peker på edgen som ga tilgangen — for debugging og deterministisk revokering.

RLS-policy

CREATE POLICY node_read ON nodes FOR SELECT
    USING (
        created_by = current_node_id()
        OR id IN (
            SELECT object_id FROM node_access
            WHERE subject_id = current_node_id()
        )
        OR visibility >= 'discoverable'
    );

current_node_id() returnerer brukernodens id fra sesjonen. Tre sjekker i prioritert rekkefølge:

  1. Egne noder (created_by) — instant
  2. Eksplisitt tilgang via matrisen — indeksert lookup
  3. Offentlig synlige noder — kolonne-sjekk

Merk: discoverable gir kun at noden finnes i søkeresultater. Innholdet filtreres i applikasjonslaget basert på visibility-nivå.

Matrise-oppdatering

Matrisen oppdateres når edges endres, ikke ved lesing. Én transaksjon: edge + matrise-oppdatering. Alltid synkront — ingen vindu med stale tilgang.

Transitiv tilgang: Når Trond får member-edge til Sidelinja, beregnes hans tilgang til alle noder med edge til Sidelinja.

Brukernode → samlings-node → innholdsnoder:         2 hopp
Brukernode → team → samlings-node → innholdsnoder:  3 hopp
Brukernode → team → samlings-node → komm.node → noder: 4 hopp

Beregningsfunksjon:

CREATE OR REPLACE FUNCTION recompute_access(
    p_subject_id   UUID,   -- bruker- eller teamnode
    p_root_node_id UUID,   -- noden det gis tilgang til
    p_access       access_level,
    p_via_edge     UUID
) RETURNS void AS $$
BEGIN
    -- Direkte tilgang til roten
    INSERT INTO node_access (subject_id, object_id, access, via_edge)
    VALUES (p_subject_id, p_root_node_id, p_access, p_via_edge)
    ON CONFLICT (subject_id, object_id)
    DO UPDATE SET access = GREATEST(node_access.access, p_access);

    -- Transitiv: noder som tilhører roten
    INSERT INTO node_access (subject_id, object_id, access, via_edge)
    SELECT p_subject_id, e.source_id, p_access, p_via_edge
    FROM edges e
    WHERE e.target_id = p_root_node_id
      AND e.edge_type = 'belongs_to'
    ON CONFLICT (subject_id, object_id)
    DO UPDATE SET access = GREATEST(node_access.access, p_access);

    -- Hvis subject er et team: propager til alle teammedlemmer
    INSERT INTO node_access (subject_id, object_id, access, via_edge)
    SELECT e.source_id, na.object_id, na.access, na.via_edge
    FROM node_access na
    JOIN edges e ON e.target_id = p_subject_id
      AND e.edge_type = 'member_of'
    WHERE na.subject_id = p_subject_id
    ON CONFLICT (subject_id, object_id)
    DO UPDATE SET access = GREATEST(node_access.access, EXCLUDED.access);
END;
$$ LANGUAGE plpgsql;

Detaljer (hvilke edge-typer som gir transitiv tilgang, maks dybde) avklares ved implementering.

Matrisestørrelse

Sparse matrise. Typisk bruker med tilgang til 2-3 samlings-noder med noen tusen noder hver: ~10k rader per bruker. Med 50 brukere: ~500k rader. Trivielt for PG.

Brukeropplevelse

Når du logger inn ser du:

  • Dine aktive samtaler — kommunikasjonsnoder med edge til deg
  • Dine noder — alt du har skapt eller er koblet til
  • Dine samlings-noder — grupperer kontekst, filtrering er frivillig
  • Din mottaksflate — det som er relevant for deg nå

Å filtrere etter samlings-node ("vis bare podcast-prosjektet") er et filter, ikke en modebytte.

Forhold til andre retninger

  • Rom, ikke forum — "rommet" er summen av dine edges, ikke en container du går inn i
  • Universell input og mottak — mottaksflaten er "noder med edge til meg", filtrert og vektet
  • Maskinrommet — eier matrise-oppdatering og leser samlings-node-edges for kontekst (pruning, kapasitet, AI-konfig)
  • Datalaget — matrisen lever i PG, indeksert for RLS-ytelse

================================================================ FILE: docs/retninger/datalaget.md

Datalaget

Status: Besluttet.

SpacetimeDB holder hele grafen i minne og mottar skrivinger først. PostgreSQL er persistent arkiv og backup. CAS lagrer binærdata. Apache AGE legges til ved behov for Cypher-traverseringer.

Lagmodell

GUI (SvelteKit)
  │ skriv                    │ les (sanntid, WebSocket)
  ▼                          ▼
Maskinrommet (Rust)          SpacetimeDB ──→ GUI
  │ validering                 ▲
  ├──→ SpacetimeDB (først)  ───┘
  └──→ PostgreSQL (asynk, persistent)

Skrivestien

GUI → Maskinrommet → validering → SpacetimeDB (instant) → PG (asynk). Frontend oppdateres umiddelbart. PG persisterer i bakgrunnen.

Lesestien (sanntid)

SpacetimeDB → GUI (direkte WebSocket, ~10μs). Hele grafen er i SpacetimeDB. Frontend har alltid alt tilgjengelig.

Lesestien (tunge spørringer)

GUI → Maskinrommet → PG. Fulltekstsøk, pgvector (semantisk søk), statistikk, AGE-traverseringer.

SpacetimeDB — hele grafen i minne

Node- og edge-skjemaet er minimalt (åtte kolonner hver). For en liten brukerbase er hele grafen triviell å holde i minne. Fordeler:

  • Ingen sync-logikk for å bestemme hva som er "aktivt"
  • Ingen henting fra PG når noen åpner noe gammelt
  • Frontend har alltid alt via WebSocket
  • Én lesekilde — ingen tvetydighet

Når dette blir problematisk (hundretusenvis av noder), innføres eviction. Det er en optimalisering, ikke en arkitekturendring.

PostgreSQL — arkiv og kraftspørringer

PG er persistent backup for hele grafen, pluss hjemsted for tunge operasjoner SpacetimeDB ikke er laget for:

  • Fulltekstsøktsvectornodes.content og nodes.title
  • Semantisk søk — pgvector for embedding-basert likhet
  • Graftraversering — rekursive CTEs, Apache AGE ved behov
  • Statistikk — aggregeringer, tidsserier
  • Tilgangsmatrisenode_access beregnes her, speiles til STDB

Apache AGE — ved behov

De fleste spørringer er grunne (1-3 hopp) og håndteres av CTEs. AGE legges til som PG-extension når Cypher-semantikk faktisk trengs:

  1. Nå: PG med nodes/edges-tabeller og CTEs
  2. Når CTEs blir smertefulle: Legg til AGE
  3. Usannsynlig: Evaluer Neo4j hvis AGE ikke holder

AGE er en extension, ikke en migrering — boltes på uten å endre eksisterende kode.

CAS — binærlagring

Lyd, bilde, video lagres content-addressable på disk. CAS-noder i grafen bærer metadata (cas_hash, mime, size_bytes). Selve biten lever utenfor PG.

Pruning-regler basert på modalitet, edges og aksessmønstre. Se maskinrommet.

SpacetimeDB som utbyttbar

SpacetimeDB er en sanntidscache, ikke en avhengighet. Hvis den fjernes:

  • Sanntid: PG LISTEN/NOTIFY → SvelteKit SSE
  • Skriving: Maskinrommet → PG direkte
  • Lesing: Maskinrommet → PG → GUI

Trenger ikke implementeres, men arkitekturen skal aldri gjøre det umulig. Se synkronisering.

Forhold til andre retninger

================================================================ FILE: docs/retninger/maskinrommet.md

Maskinrommet

Status: Besluttet.

Én Rust-tjeneste med et fast grensesnitt. Alt som krever tunge ressurser eller eksterne tjenester går gjennom dette laget. Fang, prosesser, lever. Maskinrommet er det eneste som skriver noder og edges.

Tre operasjoner

1. Fang (input-absorpsjon)

Ta imot råmateriale i alle modaliteter:

  • Tekst (melding, URL, dokument)
  • Lyd (voice memo, live stream, filopplasting)
  • Bilde (foto, skjermbilde, tegning)
  • Video (stream, opptak)
  • Strukturert data (JSON, metadata, edges)

2. Prosesser (transformasjon)

Analyser, transformer, berik og systematiser:

  • STT — lyd → tekst (Whisper)
  • TTS — tekst → lyd (ElevenLabs / lokal modell)
  • AI-analyse — oppsummering, klassifisering, edge-forslag
  • Beriking — URL → metadata, bilde → beskrivelse
  • Søk — fulltekst, semantisk (pgvector), graftraversering
  • Mediaprosessering — transcode, thumbnail, waveform

3. Lever (output-distribusjon)

Lever resultat i riktig modalitet til riktig mottaker:

  • Tekst (melding, notifikasjon, digest)
  • Lyd (TTS-opplesning, lydstream)
  • Video/bilde (stream, thumbnail)
  • Strukturert data (noder, edges tilbake i grafen)
  • Push (SpacetimeDB-reducer)

Maskinrommet eier all skriving

Frontend sender intensjoner. Maskinrommet utfører.

Frontend: "legg til Trond i møtet"
  → Maskinrommet validerer
  → Skriver edge til SpacetimeDB (instant)
  → Oppdaterer tilgangsmatrise
  → Persisterer til PG (asynk)
  → Reagerer på konsekvensene (koble inn LiveKit, starte transkripsjon)

Alt i én operasjon. Maskinrommet er ikke reaktivt i en pub/sub- forstand — det orkestrerer hele sekvensen. Enklere å forstå, enklere å debugge.

Skrivestien: validering → SpacetimeDB (instant) → PG (asynk). Se synkronisering.

Edge-drevet ressursorkestrering

Maskinrommet leser edges for å vite hva det skal gjøre. Noden er alltid enkel. Edges bestemmer hvilke ressurser som spinnes opp.

Privat er default

Input uten mottaker-edge er automatisk privat. Ingen ressurser kobles inn utover det grunnleggende (fang + transkriber).

Ressurser er proporsjonale med edges

Dagboknotat (privat voice memo):
  node → fang lyd → transkriber (Whisper) → lagre
  Ressurser: minimal

Samtale med Trond:
  node + mottaker-edge(Trond)
  → fang lyd → transkriber → lever tekst/lyd til Trond
  Ressurser: STT + levering til én

Redaksjonsmøte (5 deltakere):
  node + mottaker-edges(5) + rolle-edges
  → fang lyd fra alle → transkriber → lever til alle → AI-referent
  Ressurser: STT + levering til 5 + LLM

Livesending (1000 lyttere):
  node + mottaker-edges(∞) + stream-edge
  → fang lyd → transkriber → stream via LiveKit → distribuer
  → generer segmenter → kjør live AI → publiser
  Ressurser: STT + LiveKit + LLM + mediaprosessering

Naturlig eskalering

Du starter en privat voice-note. Deler den med Trond → legg til edge, maskinrommet begynner å levere. Trond foreslår møte → flere edges, maskinrommet kobler inn sanntidsstrømming. Møtet blir innspilling → publiserings-edge, maskinrommet aktiverer produksjonspipeline. Hvert steg er bare å legge til edges.

Grensesnittet

fang(input: RåInput) → NodeId
prosesser(node: NodeId, operasjon: Operasjon) → Resultat
lever(node: NodeId, mottaker: Mottaker, format: Format) → Status

I praksis er mye av dette implisitt: maskinrommet ser hvilke edges som ble skrevet og handler deretter. Primitivene manipulerer edges via maskinrommet, og maskinrommet kobler inn riktige ressurser.

CAS og intelligent pruning

CAS som lagringsprimitiv

All binærdata (lyd, bilde, video) lagres content-addressable. CAS-noder i grafen bærer metadata (cas_hash, mime, size). Selve biten lever på disk.

  • Deduplisering gratis — samme fil delt i tre kontekster = én kopi
  • Separasjon — "innholdet eksisterer" er adskilt fra "innholdet er tilgjengelig"
  • Enkel opprydning — slett filen fra CAS, noden beholder metadata

Lagringsregler per modalitet

Modalitet Default levetid Begrunnelse
Tekst Evig Billig, essensen av innholdet
Transkripsjon Evig Tekstlig representasjon — bevarer meningen
Lyd 30 dager Transkripsjon bevarer innholdet
Bilde 30 dager Beskrivelse/metadata bevarer kontekst
Video 14 dager Dyrest, transkripsjon + thumbnail bevarer det meste

Signaler som forlenger levetid

  • Edges. Lydfil med publiserings-edge = beholdes. Privat voice-memo uten edges = 30-dagers TTL.
  • Aksesslog. Avspilt i løpet av TTL-perioden = forlenges.
  • Transkripsjonsstatus. Utranskribert lyd kan trenge lengre TTL.
  • Edge-type. Publisert = behold. Arkivert møte = transkripsjon holder.

Generert innhold er en cache

TTS, thumbnails, AI-oppsummeringer, waveforms — alt som kan regenereres er en cache i CAS med samme TTL-mekanisme.

Samlings-node-styrt aggressivitet

Hver samlings-node kan justere sin pruning-profil:

  • Konservativt — behold alt lenge (arkiv-node)
  • Aggressivt — tekst bevares, binærdata prunes raskt
  • Tilpasset — egne regler per modalitet og edge-type

Disk-nødventil

Maskinrommet overvåker diskbruk:

  • >85 %: Genererte filer slettes (kan regenereres)
  • >90 %: Aggressiv pruning for alle samlings-noder
  • >95 %: Kritisk alarm. Alt uten publiserings-edge slettes. Tekst og transkripsjoner bevares alltid.

Isolasjon og observerbarhet

Isolasjon

Bytt Whisper med noe annet? Endre maskinrommet. Frontend vet ingenting. Legg til bildegenerering? Ny operasjon. Primitivene kaller den uten å vite hva som skjer under.

Observerbarhet

Alt går gjennom ett punkt. Logging, metrikker, kostnadsrapportering — alt på ett sted. "Hva bruker vi AI-ressurser på?" har ett svar.

Kapasitetsstyring

Prioritering, kø, rate limiting, fallback mellom leverandører. Live transkripsjon prioriteres over bakgrunns-oppsummering.

Compute-separasjon

Maskinrommet orkestrerer — tunge jobber trenger ikke kjøre på samme maskin.

  • Nå: Alt på én VPS. Jobbkøen prioriterer sanntid over batch.
  • Snart: Trekk ut tunge workers til separat node (billig ARM-instans) som poller jobbkøen. Maskinrommet ruter transparent.
  • Kildevern-modus: Lokal LLM krever dedikert compute med fysisk isolasjon.

Compute-separasjon er en konfigurasjon, ikke en arkitekturendring.

Forhold til andre retninger

Maskinrommet er infrastrukturen under de tre primitivene i universell input og mottak:

  • Input-primitiven → fang() + prosesser()

  • Mottak-primitiven → lever()

  • Kommunikasjonsnoden → alle tre

  • Noder er sentrum — maskinrommet eier tilgangsmatrise-oppdatering

  • Datalaget — maskinrommet skriver SpacetimeDB først, PG asynk

================================================================ FILE: docs/retninger/rom_ikke_forum.md

Rom, ikke forum

Hva om Sidelinja ikke er en webapp med sanntidsfunksjoner, men en oppslukende sanntidsopplevelse som tilfeldigvis leverer tradisjonell funksjonalitet?

Observasjoner

Sidelinja har vokst organisk. Vi har bygget chat, kanban, kalender, notater, kunnskapsgraf — hver som sin feature, hver med sin spec. SpacetimeDB ble lagt til for sanntid, men arkitekturen er fortsatt "PostgreSQL-app med sanntidskrydder."

Resultatet:

  • Forum-følelsen. Ting er organisert i tråder, kort, lister. Brukeren navigerer mellom sider. Det føles som et tradisjonelt verktøy med litt polish.
  • Databasespenning. PG og SpacetimeDB har et komplisert eierforhold. SpacetimeDB-loven løser grensesnittet, men ikke det underliggende spørsmålet: hva er primæropplevelsen?
  • Feature-fragmentering. Chat, kanban, whiteboard, notater — hver lever i sin boks. "Universell overføring" og "meldingsboks" prøver å lime dem sammen, men utgangspunktet er fortsatt separate primitiver.

Tesen

Snu gravitasjonen:

I stedet for: Tradisjonell webapp → bolt på sanntid der det trengs Tenk: Sanntidsrom er default → persistens er en egenskap ved ting som skjer

Forskjellen er subtil men fundamental:

  • Et "dokument som flere kan redigere" vs "et rom der folk er sammen og ting de gjør blir husket"
  • "Gå til kanban-brettet" vs "åpne kanban-laget i rommet du allerede er i"
  • "Send en melding i chat" vs "si noe i rommet"

Hva ville vært annerledes?

SpacetimeDB som verden, PG som arkiv

SpacetimeDB er ikke en "sanntidskache foran PG" — det er verdenen brukerne lever i. PG er arkivet som husker hva som har skjedd.

Rollene blir klare og adskilte:

  • SpacetimeDB = sanntidslaget. Aktivt samarbeid, live interaksjon, ting som skjer .
  • PostgreSQL = arkivet. Alt som noensinne har skjedd. Søk, historikk, statistikk, revisjon.

Men viktig: sanntidslaget er bare et sanntidslag. Ikke alt trenger sanntid. En kunnskapsgraf-utforsker, et søk i gamle episoder, en statistikkside, en offentlig publisert artikkel — disse snakker rett med PG-arkivet som tradisjonelle nettsider. De trenger ikke gå gjennom SpacetimeDB. Begge lagene leser og skriver til det samme arkivet.

Dermed har vi to parallelle overflater:

  • Sanntidsopplevelsen (via SpacetimeDB) — for alt som er levende, aktivt, samarbeidende. Chat, whiteboard, live redigering.
  • Tradisjonelt lag (rett mot PG) — for alt som er retrospektivt, utforskende, statisk. Arkiv, søk, publisering, statistikk.

Dataflyt mellom dem: ting som oppstår i sanntidslaget synkes til PG. Ting i PG kan løftes inn i sanntidslaget når de blir aktive igjen. Men det er ingen eierskapskonflikt — de to lagene har fundamentalt forskjellige roller. Det er ikke to konkurrerende sannheter, det er nåtid og arkiv, med to overflater som passer til hver sin rolle.

Rommet som primitiv, ikke siden

I dag navigerer brukeren mellom /chat, /kanban, /kalender. I "rom"-modellen er brukeren alltid et sted, og funksjonalitet er lag som kan slås av og på: chat-laget, oppgave-laget, tidslinje-laget. Alt eksisterer i samme sanntidsrom.

Tilstedeværelse som førsteklasses konsept

Hvem er her nå? Hva ser de på? Hva jobber de med? I en tradisjonell webapp er dette en "feature" (online-indikator). I rom-modellen er det fundamentet alt annet bygger på.

Interaksjon før organisering

I dag: lag et kanban-kort → fyll ut feltene → flytt det. I rom-modellen: si noe, tegn noe, del noe → det som oppstår kan bli et kort, en oppgave, en notis — organisering skjer etterpå, ikke som forutsetning.

Den administrative opplevelsen

Tesen ovenfor kan føles abstrakt. Mer konkret: Sidelinja bør være en administrativ opplevelse — ikke et spill med avatarer, men et arbeidsmiljø der produktive ting er lette å oppnå og strukturen følger deg, ikke omvendt.

Formløs input, struktur etterpå

I dag velger du visning først: "nå er jeg i chat", "nå er jeg i kanban." Men meldingsboksen er allerede designet for at input ikke trenger å vite hva den er på forhånd. En tanke bør kunne starte som en løs setning og bli et kanban-kort, en faktoid, en kalenderoppføring — uten at brukeren måtte bestemme det på forhånd. Visninger er flyktige linser. Innhold er permanent.

Trylle frem, legge fra seg

Trenger du et whiteboard? Det dukker opp. En videosamtale? Den starter. En dagbok? Den er der. Og når fokuset tar en annen retning legger du det fra deg uten at det blir borte — kunnskapsgrafen holder styr på det. Du trenger ikke "lukke" noe eller "lagre" noe. Ting eksisterer i verden og kan gjenfinnes.

Siloer forsvinner

"Universell overføring" som eksplisitt feature blir overflødig fordi det ikke finnes separate steder å overføre mellom. Du endrer ikke hvor noe er — du endrer hvordan du ser på det som allerede er der. Chat, kanban, kalender er ikke apper — de er visninger av samme tilstandsrom.

Privat og delt som lag, ikke separate systemer

Privat/delt er bare en synlighetsbryter på alt du gjør. Du chatter med en kollega og samtidig skriver du dagbok — begge er meldingsbokser, den ene er delt, den andre er privat. De eksisterer side om side i samme rom.

Dette åpner for naturlig flyt mellom kontekster: du sitter på bussen, snakker inn en tanke via voice, den transkriberes automatisk og lander som en privat meldingsboks — dagboknotis, oppgavepåminnelse, idé til neste episode. Du trenger ikke åpne "dagbok-appen" eller "notat-appen." Du bare snakker, og systemet tar vare på det riktig sted basert på kontekst og synlighet.

Input-metode (tekst, voice, tegning) og synlighet (privat, delt, publisert) er ortogonale egenskaper. Ingen av dem bør diktere hva innholdet blir.

SpacetimeDB som naturlig motor

SpacetimeDB tenker "dette eksisterer i verden nå" — ikke "lagre dette i riktig tabell." Å trylle frem et whiteboard er naturlig i en verden-modell: det er bare et nytt objekt med en tilstand. I PG-modellen må du opprette rader, definere relasjoner, sette opp persistens. SpacetimeDB låner seg til flytende, formløs interaksjon på en måte PG ikke gjør.

PG briljerer i rollen som arkiv: relasjonelle spørringer over historikk, fulltekstsøk, pgvector for semantisk søk, aggregeringer og statistikk. Når du spør "hva snakket vi om i mars?" er det PG som svarer. Når du spør "hva skjer nå?" er det SpacetimeDB.

Spenninger og åpne spørsmål

  • Ytelse. En alltid-på sanntidsopplevelse krever mer av både klient og server enn tradisjonelle sideinnlastinger. Er SpacetimeDB klar for dette?
  • Kompleksitet. "Alt er et rom" høres elegant ut, men kan bli kaotisk. Hvordan unngår vi at det blir uoversiktlig?
  • Discovery vs fokus. "Alt kan bli hva som helst" er kraftig, men kan bli overveldende. Et whiteboard du tryller frem må være like lett å finne igjen om tre uker. Kunnskapsgrafen er kanskje svaret — den er allerede designet for å koble ting uavhengig av type.
  • Gradvis overgang. (Løst — se "Innebygd utviklingsstrategi" nedenfor.)
  • Solo-bruk. Mye av verdien i "rom" kommer fra å være der sammen. Hvordan føles det for én person som jobber alene?
  • Er det vi allerede har? Meldingsboks-konseptet, universell overføring og SpacetimeDB-loven peker allerede i denne retningen. Kanskje dette ikke er en ny retning, men en artikulering av det vi ubevisst har bygget mot?
  • Inspirasjon. Spillverdener (MMO-lobbyer, shared spaces), Figma (alle i samme canvas), tldraw, Gather.town. Hva kan vi lære fra disse?

Innebygd utviklingsstrategi

To-lags-modellen gir en viktig implikasjon for utviklingen: innebygd fallback og to veier inn til alt.

Ny funksjonalitet kan alltid starte i det tradisjonelle laget — rett mot PG, vanlig request/response, kjent terreng. Den fungerer med en gang. Når det senere gir verdi (samarbeid, sanntid, live interaksjon) kan den løftes inn i sanntidslaget. Men den trenger ikke det for å være nyttig.

Dette betyr:

  • Ingenting vi har bygget er bortkastet. Eksisterende PG-baserte features er ikke "gammel arkitektur" — de er det tradisjonelle laget, og det er et fullverdig lag.
  • Ingen stor omskriving. Retningen er ikke en migrasjon med en frist. Den er en utviklingsstrategi: bygg tradisjonelt, løft til sanntid ved behov.
  • Risikoen er lav. Hvis SpacetimeDB skuffer, har vi fortsatt et komplett tradisjonelt system. Hvis det leverer, får ting gradvis en rikere opplevelse.
  • Primitivene er nøkkelen. Så lenge meldingsboksen og kunnskapsgrafen er fleksible nok, kan begge lag bruke dem. Arkitekturen holder uavhengig av hvilket lag en feature lever i.

Kritisk vurdering

SpacetimeDB er en ung teknologi

Å lene seg tungt på SpacetimeDB er et veddemål. Hvis prosjektet stagnerer, endrer API, eller har skaleringstak vi ikke ser ennå — er vi eksponert. Mildnet av at det tradisjonelle laget mot PG alltid finnes som fallback: SpacetimeDB er ikke alt-eller-ingenting, men et lag for det som trenger sanntid. Resten lever trygt mot PG uansett.

"Formløs input" er vanskelig i praksis

Det høres elegant ut, men noen må bestemme hva noe blir. AI? Brukeren etterpå? Automatiske regler? Notion, Roam Research og mange andre startet med lignende ambisjoner og endte opp med å gi brukeren eksplisitte strukturverktøy — fordi ambiguitet i praksis skaper friksjon, ikke flyt. Risikoen er at vi bygger noe som føles magisk i demoen og forvirrende i hverdagen.

Grensen mellom "nåtid" og "arkiv" er uklar

En samtale fra i går som fortsatt er relevant — er den "nå" eller "arkiv"? Et kanban-kort som har stått stille i to uker? Vi trenger regler for når ting flyttes mellom lagene, og de reglene kan bli like komplekse som SpacetimeDB-loven de erstatter. "Tid og arkiv" er renere i prinsippet, men i praksis er "aktiv" et spektrum, ikke en binær tilstand.

Solo-bruk er underadressert

Vegard er primærbruker. "Rom"-konseptet henter mye av sin kraft fra tilstedeværelse og samarbeid. For én person som produserer podcast er det en reell risiko at sanntidslaget er overhead sammenlignet med et godt organisert tradisjonelt verktøy. Mildnet av to-lags-modellen: solo-bruk kan lene seg tyngre på det tradisjonelle PG-laget, og sanntidslaget aktiveres når det faktisk gir verdi (live innspilling, samarbeid).

Omfanget

Betydelig mildnet av utviklingsstrategien ovenfor. Retningen krever ikke en omskriving — den er kompatibel med inkrementell utvikling. Den gjenværende risikoen er mer subtil: at vi bruker mental energi på å vurdere "bør dette være sanntid?" for hver feature i stedet for å bare bygge. Pragmatisk default bør være: bygg tradisjonelt, løft senere.

================================================================ FILE: docs/retninger/status_quo.md

Status quo — Hva Sidelinja er i dag

Historisk dokument (v1). Denne teksten beskriver tilstanden i v1 — workspace-basert arkitektur, CRUD-mønster, fragmentert navigasjon. Den er bevart som referanse for å forstå utgangspunktet for de vedtatte retningene i docs/retninger/.

En redaksjonell webapp med ambisiøse primitiver og tradisjonell overflate.

Hva fungerer

Meldingsboksen som universell primitiv

Den viktigste arkitekturbeslutningen. Én datamodell — meldingsboksen — er underlag for chat, kanban-kort, kalenderoppføringer, notater og faktoider. I stedet for fem separate domenemodeller har vi én fleksibel primitiv med view-konfigurasjoner oppå. Dette er genuint uvanlig og gir oss muligheter de fleste redaksjonelle verktøy ikke har.

Kunnskapsgrafen

Nodes og edges i PostgreSQL gir en rik struktur for å koble alt med alt — personer, temaer, episoder, fakta. Dette er ryggraden i det redaksjonelle arbeidet og skiller Sidelinja fra enklere verktøy.

Self-hosted med full kontroll

Hetzner VPS, Caddy, Authentik, Forgejo. Ingen avhengighet til skytjenester vi ikke kontrollerer. For et journalistisk verktøy er dette ikke bare en preferanse — det er et prinsipp.

AI som infrastruktur, ikke feature

LiteLLM som gateway, BYOK-modell, Whisper for transkripsjon. AI er ikke en knapp i UI-et — det er en del av maskineriet. Jobbkø-arkitekturen gjør at tunge operasjoner aldri blokkerer brukeropplevelsen.

Hva som er tradisjonelt

Navigasjon

Brukeren beveger seg mellom /chat, /kanban, /kalender som separate sider. Til tross for at datamodellen er universell, føles opplevelsen fragmentert — som et sett med separate verktøy som deler database.

Interaksjonsmodell

CRUD-mønsteret dominerer: opprett, rediger, slett, flytt. Interaksjonen er form-basert og eksplisitt. Brukeren "administrerer innhold" mer enn de "jobber sammen i et miljø."

Sanntid som tillegg

SpacetimeDB er lagt til for å gi sanntidsoppdatering, men arkitekturen er PostgreSQL-først. Sanntid er noe som skjer med tradisjonelle operasjoner, ikke noe som er grunnlaget for opplevelsen.

Spenninger

To sannhetskilder

PG og SpacetimeDB har et komplisert forhold. SpacetimeDB-loven definerer klare regler for hvem som eier hva, men selve eksistensen av loven vitner om en arkitektonisk spenning: vi har to systemer som begge vil være primærkilde, og vi bruker konvensjoner for å holde dem fra å kollidere.

Ambisiøs bunn, forsiktig topp

Meldingsboksen og kunnskapsgrafen åpner for opplevelser vi ikke leverer ennå. Datamodellen sier "alt henger sammen" — men UI-et sier "her er chatten, her er kanban-brettet, her er kalenderen." Grunnmuren er mer spennende enn det brukeren ser.

Produksjonsverktøy vs opplevelse

Sidelinja er designet for podcast-produksjon, men pendler mellom å være et effektivt arbeidsverktøy og noe mer oppslukende. Studioet og møterommet peker mot sanntidsopplevelser. Redaksjonen og kanban peker mot tradisjonelt prosjektstyringsverktøy. Begge er gyldige, men de trekker i ulike retninger.

Oppsummert

Sidelinja har en sterkere grunnmur enn overflaten viser. Meldingsboksen, kunnskapsgrafen og AI-infrastrukturen er genuint interessante primitiver. Spørsmålet er ikke om vi har bygget feil — men om overflaten utnytter det fundamentet faktisk tillater.

================================================================ FILE: docs/retninger/universell_input.md

Universell input og mottak

Status: Besluttet.

Én multimodal input-primitiv. Én personlig mottaksflate. Alt som fanges er en node. Hva det "er" bestemmes av edges. Hvordan det presenteres bestemmes av mottakeren.

Input-primitiven

Én overflate som fanger alt:

  • Tekst — skriving, Markdown, kodeblokker
  • Lyd — voice memo, diktering → automatisk transkribert
  • Bilde — foto, skjermbilde, tegning
  • AI-støtte — spør AI, få forslag, la den transformere input
  • URL — lim inn lenke, den berikes automatisk

Brukeren gjør det samme uansett kontekst — skriver, snakker eller tegner i input-feltet. Forskjellen mellom "dagbok", "chatmelding" og "kanban-kort" er ikke hva brukeren gjør — det er hvilke edges som knyttes til resultatet.

Én pipeline

All input går gjennom samme tekniske pipeline: maskinrommet → SpacetimeDB (instant) → PG (asynk).

Konteksten bestemmer routing, ikke en teknisk modus:

  • Du er i et møte → inputen streames live til andre deltakere
  • Du er alene på bussen → inputen lander som privat node
  • Du er i en podcast-kanal → inputen går inn i produksjonspipeline

Samme pipeline. Ulike edges.

Input-metode og innholdstype er ortogonale

Du kan snakke inn et kanban-kort. Du kan tegne en kalenderoppføring. Input-primitiven bryr seg ikke om hva det blir — den fanger det som kommer inn. Alt etterpå er edges.

Én overflate å perfeksjonere

All UX-investering konsentreres ett sted. Én perfekt input-opplevelse — responsiv, multimodal, med god AI-støtte — i stedet for ti middelmådige spesialgrensesnitt.

Editoren

Input-komponenten er en TipTap-editor (ProseMirror-basert) som konfigureres med ulike extensions basert på kontekst.

Kontekst setter default, brukeren bestemmer

Konteksten (kommunikasjonsnoden du er i, visningen du bruker) setter en default editor-konfigurasjon. Men brukeren kan alltid overstyre — utvide til full editor eller forenkle. Ingen kunstig grense mellom "chatmelding" og "artikkel."

Du kan skrive en gjennomformatert tekst med overskrifter, bilder og blockquotes i chatten. Om du vil publisere den etterpå er det bare å legge til en publiserings-edge. Innholdet er det samme.

Editoren husker brukerens valg per kontekst via preferanser på brukernoden.

Presets

Kontekst Default extensions Eksempel
Chat Tekst, markdown, kodeblokker, lenker Enkel melding
Artikkel/blogg + overskrifter, bilder, embeds, blockquotes, tabeller Publisert tekst
Show notes + lister, tidskoder, lenker Episodenotater
Kanban-kort Tekst, sjekklister Oppgavebeskrivelse

Presets er bare default — brukeren kan utvide eller forenkle med en knapp eller tastatursnarvei. Ikke en modebytte, bare at flere verktøy blir tilgjengelig.

Tekstlagring

Noden lagrer to representasjoner:

  • content TEXT — ren tekst uten formatering, for fulltekstsøk og enkel visning
  • metadata.document JSONB — strukturert TipTap/ProseMirror- dokument for rendering
{
  "type": "doc",
  "content": [
    { "type": "paragraph", "content": [
      { "type": "text", "text": "Her er en intro." }
    ]},
    { "type": "image", "attrs": {
      "node_id": "uuid-av-cas-node", "alt": "Diagram"
    }},
    { "type": "paragraph", "content": [
      { "type": "text", "text": "Teksten fortsetter." }
    ]}
  ]
}

content genereres automatisk fra dokumentet ved lagring — bare teksten, uten markup. Editoren produserer begge.

For enkle meldinger (ren tekst uten formatering) er metadata.document null — content er alt som trengs.

Bilder og media i tekst

Bilder i dokumentet refererer til CAS-noder via node_id. CAS-noden er en egen node med has_media-edge til innholdsnoden. Dokumentstrukturen bestemmer hvor bildet plasseres i teksten.

Tekst er noden. Binærfiler er andre noder koblet med edges. Ren separasjon: tekst er innhold, binærfiler er vedlegg som kan plasseres inline.

Edges definerer alt

Hva en node "er" bestemmes utelukkende av edges:

  • Node + belongs_to → kanal = chatmelding
  • Node + belongs_to → board + status = kanban-kort
  • Node + scheduled → tidspunkt = kalenderoppføring
  • Node uten edges til andre = privat notat
  • Node + mentions → topic = faktoid i kunnskapsgrafen
  • Node uten edges = løs tanke, ennå uorganisert

Retyping er trivielt. Chatmelding → kanban-kort = legg til board-edge og status-edge. Ingen datamigrering, bare edges.

Multitype er naturlig. En node kan være både kanban-kort og kalenderoppføring og faktoid. Det er ikke en edge case — det er arkitekturen.

Edge-tildeling

Når du "bare sier noe" — hvem bestemmer edges?

  1. Kontekst gir det meste. Du er i en samtale → belongs_to- edge til samtalen. Du er alene → privat. Dekker 80%.
  2. Eksplisitt handling. Du drar en node til kanban-brettet. Du tagger noe. Du setter en dato.
  3. AI-foreslått. Systemet foreslår mentions-edge når du nevner en person. Foreslår kanban når noe ligner en oppgave.

Detaljer for AI-foreslåtte edges avklares ved implementering.

Mottak-primitiven

Der input er "én overflate som fanger alt", er mottak "én overflate som presenterer alt tilpasset deg."

Mottaker bestemmer format

All lyd transkriberes. All tekst kan leses opp (TTS). Noden har alltid begge representasjoner. Mottaker setter sin preferanse:

  • Trond snakker inn en tanke → node med lyd + transkripsjon
  • Peter har tekst-preferanse → ser transkripsjonen
  • Vegard har lyd-preferanse → hører originallyd

Modalitet er ikke en egenskap ved meldingen, men ved lesningen.

Dimensjoner ved mottak

Format. Lyd, tekst, visuelt — mottaker bestemmer.

Filtrering. Mottaksflaten filtrerer basert på dine edges: kanaler du følger, personer du samarbeider med, topics du er interessert i.

Prioritering. AI-assistert vekting: ubesvarte meldinger, oppgaver med frist, noder endret siden sist. Ikke en notifikasjonsliste — en vektet visning av det som er relevant.

Tempo. Sanntid (ting streamer inn) eller asynkront (digest, oppsummering).

Mottaksflaten er en visning av grafen

"Noder med edge til meg, vektet på relevans og tid." Ikke en egen mekanisme — en spørring mot grafen med deg som sentrum.

Kommunikasjonsnoden — den tredje primitiven

Input fanger. Mottak presenterer. Kommunikasjonsnoden er stedet der folk møtes — en node som samler deltakere, definerer tilgangsregler, og fungerer som kontekst.

Én node, mange former

Variant Deltakere Input Mottak
Én-til-én 2 Begge Begge
Gruppechat N Alle Alle
Møte N Alle Alle
Allmøte 1 + N Leder Alle lytter
Podcastinnspilling 2-4 + N Verter Alle lytter
Livesending 1-4 + ∞ Verter Streamet
Asynkron gjest 1 + 1 Gjest Redaksjonen

Forskjellen er edge-konfigurasjoner, ikke ulike systemer:

  • owner-edge — kontrollerer noden
  • member-edge — kan gi input og motta
  • reader-edge — kan kun motta

Kontekst arves automatisk

Input i en kommunikasjonsnode arver kontekst-edges. Sier du noe i et møte → noden får belongs_to-edge til møtet automatisk.

Livssyklus

  • Live — deltakere til stede, input streames
  • Asynkron — deltakere gir input i eget tempo
  • Avsluttet — arkivert, alt som ble sagt er noder med edges
  • Gjenåpnet — reaktivert ("vi tar opp tråden fra forrige møte")

Skalering er edge-endring

Samtale → møte = flere deltaker-edges. Møte → livesending = offentlige mottak-edges. Livesending → podcast = publiserings-edges på arkivert innhold.

Tekniske forutsetninger

STT (tale → tekst): løst

Faster-whisper kjører lokalt, god norsk kvalitet.

TTS (tekst → tale): løsbart

Start med ElevenLabs bak AI Gateway, bytt til lokal modell når kvaliteten holder. Backend-swap bak gatewayen — brukeren merker ingenting.

Visninger er spørringer

Chat = noder med kanal-edge, sortert på tid. Kanban = noder med board-edge, gruppert på status. Kalender = noder med dato-edge, på tidslinje. Dagbok = private noder, sortert på tid. Mottaksflate = noder med edge til deg, vektet.

Alle leser fra samme graf. Ingen har "sin egen" data.

Forhold til andre retninger

  • Noder er sentrum — visibility, tilgangsmatrise, aliaser
  • Datalaget — SpacetimeDB holder hele grafen, PG persisterer asynkront
  • Maskinrommet — validering, routing, CAS, tunge jobber (Whisper, TTS, AI)
  • Rom, ikke forum — kommunikasjonsnoden er den konkrete realiseringen av "rommet"

================================================================ FILE: docs/primitiver/edges.md

Edges — spesifikasjon

Status: Besluttet.

Edges er relasjoner mellom noder. De er retningsbestemte, typede med freeform strenger, og kan bære metadata. Hva en node "er" bestemmes av dens edges, ikke av noden selv.

Skjema

CREATE TABLE edges (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    source_id   UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    target_id   UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    edge_type   TEXT NOT NULL,
    metadata    JSONB NOT NULL DEFAULT '{}',
    system      BOOLEAN NOT NULL DEFAULT false,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by  UUID REFERENCES nodes(id),

    UNIQUE (source_id, target_id, edge_type)
);

CREATE INDEX idx_edges_source ON edges (source_id);
CREATE INDEX idx_edges_target ON edges (target_id);
CREATE INDEX idx_edges_type   ON edges (edge_type);

Retning

Edges er retningsbestemte: source_id → target_id. "Vegard eier Sidelinja" er en annen edge enn "Sidelinja eier Vegard" (som ikke gir mening). Retningen leses naturlig:

Vegard  ──owner──→   Sidelinja       (Vegard eier Sidelinja)
Referat ──belongs_to──→ Møte         (referatet tilhører møtet)
Episode ──mentions──→ Jonas Gahr Støre (episoden nevner ham)

Symmetriske relasjoner (f.eks. vennskap) modelleres som to edges eller som en edge-type maskinrommet vet er symmetrisk.

Edge-typer

Freeform strenger med konvensjoner — ikke en hard enum. Nye relasjonstyper krever ingen migrering. Systemkritiske typer valideres i maskinrommet.

Kjente edge-typer

Type Betydning Metadata
owner Eier noden
admin Administrerer noden
member_of Er medlem av
reader Kan kun lese
belongs_to Tilhører (innhold → samling)
alias Identitet (systemedge) system: true
mentions Refererer til
reply_to Svar på
scheduled Tidsplanlagt { "at": "2026-03-20T14:00Z" }
status Tilstand { "value": "done" }
tagged Merket med { "tag": "urgent" }
host_of Vert i

Listen vokser organisk. Nye typer legges til ved behov uten skjemaendring.

Metadata

JSONB-feltet bærer kontekstspesifikk data om relasjonen:

  • status-edge: { "value": "in_progress" }
  • scheduled-edge: { "at": "2026-03-20T14:00Z" }
  • tagged-edge: { "tag": "urgent", "color": "#ff0000" }

Metadata er fleksibelt og spørrbart uten migrering.

Systemedges

Edges med system: true er usynlige ved traversering. De eksisterer for systemet, ikke for brukere.

Kjente systemedges:

  • alias — identitetskobling, aldri eksponert

Unique-constraint

UNIQUE (source_id, target_id, edge_type) betyr: én edge av hver type mellom to noder. Vegard kan ha owner-edge og member_of-edge til Sidelinja, men ikke to owner-edges.

Hva edges gjør med noder

Edges definerer hva en node "er" — ikke noden selv:

Node + member_of → kanal       = chatmelding
Node + belongs_to → board
     + status → "todo"         = kanban-kort
Node + scheduled → tidspunkt   = kalenderoppføring
Node + belongs_to → kun bruker = privat notat
Node + mentions → topic        = faktoid i kunnskapsgrafen
Node uten edges                = løs tanke

Retyping er å endre edges. Ingen datamigrasjon.

Tilgangsmatrise-oppdatering

Når en edge med tilgangsrolle (owner, admin, member_of, reader) opprettes eller slettes, oppdaterer maskinrommet node_access-matrisen i samme transaksjon. Se noder er sentrum for full spesifikasjon.

================================================================ FILE: docs/primitiver/nodes.md

Nodes — spesifikasjon

Status: Besluttet.

Alt er noder. Brukere, team, prosjekter, innhold, møter, mediefiler, kunnskapsgraf-entiteter — alt er rader i nodes-tabellen. Hva en node "er" bestemmes av dens edges, ikke av noden selv.

Skjema

CREATE TYPE visibility AS ENUM ('hidden', 'discoverable', 'readable', 'open');

CREATE TABLE nodes (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    node_kind   TEXT NOT NULL DEFAULT 'content',
    title       TEXT,
    content     TEXT,
    visibility  visibility NOT NULL DEFAULT 'hidden',
    metadata    JSONB NOT NULL DEFAULT '{}',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by  UUID REFERENCES nodes(id)
);

CREATE INDEX idx_nodes_kind ON nodes (node_kind);
CREATE INDEX idx_nodes_created_by ON nodes (created_by);
CREATE INDEX idx_nodes_visibility ON nodes (visibility);

Kolonner

id

UUID, generert. Primærnøkkel for alt i systemet.

node_kind

Hint om hva noden primært er. Freeform streng — ikke en enum, samme filosofi som edge-typer. Edges kan gi noden flere roller utover dette.

Kjente node_kinds:

Kind Eksempel
person Vegard, Trond, Bjørn (alias)
team Podcastteamet
collection Sidelinja Podcast, Research-gruppa
content Chatmelding, bloggpost, notat, dagboknotis
communication Møte, samtale, livesending
topic Kunnskapsgraf-entitet (Jonas Gahr Støre, Skolepolitikk)
media CAS-node (lydfil, bilde, video)

Listen vokser organisk etter behov.

title

Det du viser overalt: i lister, søkeresultater, mottaksflaten. Universelt — navnet på en person, tittelen på en bloggpost, navnet på en podcast.

  • Vegard: 'Vegard'
  • Sidelinja: 'Sidelinja Podcast'
  • Bloggpost: 'Hvorfor noder er sentrum'
  • Chatmelding: NULL (har sjelden tittel)

content

Ren tekst uten formatering. Brukes til fulltekstsøk og enkel visning. For rike dokumenter (formatert tekst med bilder) genereres content automatisk fra metadata.document ved lagring.

  • Bloggpost: teksten uten markup (generert fra metadata.document)
  • Chatmelding: 'Hei, er du klar?'
  • Voice memo: NULL ved opprettelse, fylles etter transkribering
  • Brukernode: NULL
  • CAS-node: NULL (binærdata lever på disk)

For formatert innhold: se metadata.document under metadata.

visibility

Default synlighet for alle uten eksplisitt edge. Se noder er sentrum for full spesifikasjon av visibility-nivåer og traverseringsregelen.

metadata

JSONB for alt typespesifikt som ikke er tittel eller ren tekst:

  • Person: { "display_name": "Vegard", "preferences": { ... } }
  • CAS-node: { "cas_hash": "abc123", "mime": "audio/mp3", "size_bytes": 84000000 }
  • Kommunikasjonsnode: { "started_at": "...", "ended_at": "..." }
  • Samlings-node: { "pruning_profile": "conservative", "theme": "dark" }
  • Rikt dokument: { "document": { "type": "doc", "content": [...] } }

metadata.document inneholder TipTap/ProseMirror JSON for formatert innhold (overskrifter, bilder, blockquotes, etc.). Bilder refererer til CAS-noder via node_id. For enkle meldinger (ren tekst) er document null — content er alt som trengs. Se universell input for detaljer.

created_at

Tidsstempel, automatisk.

created_by

Referanse til noden som opprettet denne noden. For alias-bruk: created_by settes til aliasnoden, ikke brukernoden bak.

Brukernoder

En brukernode er en node med node_kind = 'person' og en rad i auth_identities:

CREATE TABLE auth_identities (
    node_id       UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    authentik_sub TEXT UNIQUE NOT NULL,
    email         TEXT UNIQUE NOT NULL
);

Alt annet — profil, preferanser, relasjoner — er noden og dens edges. Autentiseringstabellen er en tynn bro mellom HTTP-sesjonen og grafen.

CAS-noder (mediefiler)

Binærfiler er noder med node_kind = 'media'. Selve biten lever på disk i CAS-lageret. Noden bærer bare metadata.

Episode #42 (content)
  ──has_media──→ CAS-node (media, metadata: { cas_hash: "abc123", mime: "audio/mp3" })
  ──has_media──→ CAS-node (media, metadata: { cas_hash: "def456", mime: "text/srt" })
  ──belongs_to──→ Sidelinja Podcast (collection)

En innholdsnode kan ha mange mediefiler via edges. Pruning-logikken i maskinrommet opererer på CAS-noder — sletter binærfilen fra disk, men noden kan leve videre som tombstone.

Eierskap og tilgang

created_by gir redigeringsrett til egne noder. Men sletting og strukturelle endringer scopes av rollen i konteksten:

  • Du kan alltid redigere noder du opprettet.
  • Sletting krever owner eller admin i samlings-noden innholdet tilhører — selv om du opprettet det.
  • En privat node (ingen samlings-edge) kan slettes fritt av creator.

Flere noder kan ha owner-tilgang til samme node via tilgangsmatrisen (direkte og transitiv). Forretningslogikk for hva ulike tilgangsnivåer tillater lever i maskinrommet, ikke i databasen.

================================================================ FILE: docs/concepts/den_asynkrone_gjesten.md

Konsept: Den Asynkrone Gjesten

Filsti: docs/concepts/den_asynkrone_gjesten.md

1. Konsept

Mange interessante gjester har ikke tid til å stille i studio. Den Asynkrone Gjesten lar redaksjonen sende en unik lenke til en gjest som kan svare på spørsmål via tale — fra mobilen, når det passer dem. Svarene lander direkte i redaksjonens arbeidsflyt, transkriberes automatisk, og kan brukes i podcasten.

2. Brukeropplevelse

2.1 Redaksjonens side

  1. Redaksjonen oppretter en "Gjestesesjon" knyttet til et Tema.
  2. Legger inn spørsmål (tekst) som gjesten skal svare på.
  3. Systemet genererer en unik, tidsbegrenset URL.
  4. URL-en sendes til gjesten via e-post, SMS eller chat.
  5. Gjestens svar (lydmeldinger) dukker opp i Tema-chatten som voice_memo-meldinger, automatisk transkribert.
  6. Redaksjonen triagerer svarene — kan tagge, klippe inn i episode, eller bruke som research.

2.2 Gjestens side

  1. Gjesten åpner lenken i mobilnettleseren. Ingen app, ingen konto, ingen registrering.
  2. Ser en enkel, ren flate: podcast-logo, spørsmålene fra redaksjonen, og en opptaksknapp per spørsmål.
  3. Trykker record, snakker, trykker stopp. Kan lytte tilbake og ta om igjen.
  4. Ved innsending lastes lydfilene opp og gjesten ser en bekreftelse.
  5. Lenken utløper etter gitt tid eller antall besøk.

2.3 Minimal friksjon

  • Ingen Authentik-innlogging — tilgang via signert token
  • Ingen app — ren PWA/nettleser
  • Ingen redigering — gjesten snakker bare
  • Responsivt, mobil-first design

3. Komponenter

Feature Rolle
Lydmeldinger Opptakskomponent gjenbrukes (se docs/features/lydmeldinger.md)
Chat (channels) Svarene lander i en channel knyttet til Temaet
Live transkripsjon Whisper transkriberer via jobbkø (se docs/features/live_transkripsjon.md)
Podcastfabrikken Lydklipp kan trekkes inn som segment (se docs/concepts/podcastfabrikken.md)

4. Autentisering: Gjeste-tokens

4.1 Datamodell

guest_tokens (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    node_id      UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,  -- Samlings- eller tema-node
    guest_name   TEXT NOT NULL,               -- Visningsnavn ("Erna Solberg")
    questions    JSONB NOT NULL,              -- [{ "sort": 1, "text": "Hva tenker du om...?" }]
    token        TEXT UNIQUE NOT NULL,         -- Kryptografisk sikker, URL-safe token
    expires_at   TIMESTAMPTZ NOT NULL,
    max_recordings SMALLINT DEFAULT 10,       -- Maks antall opptak
    recordings_count SMALLINT DEFAULT 0,
    created_by   TEXT NOT NULL REFERENCES users(authentik_id),
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
)

4.2 Sikkerhet

  • Token er kryptografisk tilfeldig (256-bit, URL-safe base64).
  • SvelteKit validerer token ved hvert request: sjekker expiry, recordings_count < max_recordings, og node-tilhørighet.
  • Gjestens meldinger merkes med author_id = NULL og metadata.guest_name + metadata.guest_token_id for sporbarhet.
  • Ingen tilgang til andre noder eller funksjoner.
  • Tokenet kan revokeres manuelt av redaksjonen.

4.2b Sikkerhetsdybde (mot token-lekkasje og misbruk)

Et lekket gjeste-token gir direkte filopplasting uten autentisering — dette er høyrisiko. Følgende tiltak begrenser skadepotensialet:

Tiltak Implementering Formål
Rate limiting per token SvelteKit middleware: maks 1 opplasting per 30 sek per token Forhindrer spam/flooding
Filtype-validering SvelteKit: kun audio/* MIME-typer aksepteres, filstørrelse maks 50 MB Blokkerer malware-opplasting
Malware-scanning ClamAV sidecar-container scanner opplastede filer før de lagres Fanger kjent malware
Auto-revoke Token deaktiveres automatisk når recordings_count >= max_recordings Begrenser eksponering
IP-logging Logger klient-IP per opplasting i guest_token_usage-tabell Sporbarhet ved misbruk
Geo-begrensning (valgfritt) Caddy-nivå: blokker requests fra uventede geolokasjoner Reduserer angrepsflate

ClamAV Docker-oppsett:

clamav:
  image: clamav/clamav:latest
  restart: unless-stopped
  volumes:
    - /srv/synops/media:/scan:ro
  networks:
    - sidelinja-net

SvelteKit kaller ClamAV via clamdscan (socket) etter filopplasting, før filen flyttes til endelig plassering. Infiserte filer slettes umiddelbart og tokenet flagges for manuell gjennomgang.

Fremtidig hardening — prosess-isolasjon: Ved økt eksponering (mange aktive guest-tokens, offentlige lenker) bør opplastede filer prosesseres i en isolert kontekst per token. Mulige tilnærminger:

  • Firejail/bubblewrap-sandbox for Whisper-prosessering av gjeste-audio
  • Dedikert temp-mappe per token som slettes etter prosessering
  • Docker sidecar-container for uautentisert filopplasting med egne cgroups

Dette er komplementært til ClamAV (som fanger kjent malware) — sandboxing beskytter mot ukjente angrep. Implementeres når gjeste-tokens eksponeres bredere enn redaksjonell bruk.

4.3 Flyt (teknisk)

Gjest åpner URL med token
  → SvelteKit validerer token
  → Viser spørsmål + opptaksknapp
  → Gjest tar opp svar
  → SvelteKit streamer lydfil til CAS (content-addressable store)
  → Oppretter message (voice_memo) i channelen
  → Oppretter whisper_transcribe-jobb i jobbkøen
  → Inkrementerer recordings_count
  → Redaksjonen ser svaret i Tema-chatten

5. Dataklassifisering

Data Kategori Detaljer
Gjestens lydopptak Kritisk (backup) Unikt innhold
Guest tokens Flyktig (TTL) Utløper automatisk, slett expired tokens periodisk
Spørsmål (JSONB) Kritisk (PG) Redaksjonelt innhold

6. Instruks for Claude Code

  • guest_tokens-tabellen er ikke en node i grafen — den er ren tilgangsstyring.
  • Gjeste-UI er en egen SvelteKit-rute (/guest/[token]) med minimal layout (ingen navbar).
  • Gjenbruk lydmeldinger-komponenten — ikke bygg en egen opptaksflyt.
  • Meldinger fra gjester har author_id = NULL. Frontend må håndtere dette gracefully (vis guest_name i stedet).
  • Tokenet skal aldri gi tilgang til å lese andre meldinger i channelen — gjesten kan kun skrive.
  • Tilgang er styrt via node_access. Token bærer node_id eksplisitt.

================================================================ FILE: docs/concepts/kunnskapsgrafen.md

Konsept: Kunnskapsgrafen (Utforsking og redigering)

Filsti: docs/concepts/kunnskapsgrafen.md

1. Konsept

Kunnskapsgrafen er Sidelinjas kjerne — et levende nettverk av Temaer, Aktører, Faktoider, Episoder og Segmenter. Inspirert av Logseq og Obsidian bygger den seg opp organisk gjennom daglig bruk og skaper "serendipity" (lykketreff) i research-fasen ved å synliggjøre uventede forbindelser.

2. Brukeropplevelse

2.1 Organisk vekst

Grafen vokser gjennom daglig bruk av Sidelinja:

  1. Chat-meldinger med #-tags oppretter automatisk MENTIONS-relasjoner i grafen.
  2. AI-behandling i editoren trekker ut aktører og faktoider fra innlimt tekst (se docs/proposals/editor.md).
  3. Podcastfabrikken kobler episode-segmenter til temaer og aktører.
  4. Møtereferater trådes automatisk mot temaer og aktører av AI-referenten.

2.2 Visuell utforsking

En interaktiv graf-visning (se docs/features/visuell_graf.md) lar redaksjonen:

  • Navigere nettverket rundt en Aktør eller et Tema (2-3 ledd ut)
  • Dra streker mellom noder for å opprette nye relasjoner
  • Filtrere etter nodetype, relasjonstype, tidsperiode eller fritekst
  • Bruke PART_OF-hierarkier for fleksibel prosjektorganisering uten stive mappestrukturer

2.3 Søk

Full-text search på norsk (to_tsvector('norwegian', ...)) gjør det mulig å søke på tvers av alle episoder, segmenter og faktoider.

3. Komponenter

Feature Rolle i Kunnskapsgrafen
Kunnskapsgraf datamodell Nodes/edges i PostgreSQL (se docs/features/kunnskapsgraf_og_relasjoner.md)
Visuell graf Interaktiv D3.js/Vis.js-visning (se docs/features/visuell_graf.md)
Chat Mentions (#/@) oppretter edges automatisk (se docs/features/chat.md)
Editor (AI-knapp) Trekker ut aktører/faktoider til grafen (se docs/proposals/editor.md)

4. Entity Resolution (Merge Entities)

Grafen vokser organisk via #-mentions, men dette skaper uunngåelig fragmentering: #Jonas, #Støre og #Jonas Gahr Støre ender som tre separate noder. Uten en strategi for sammenslåing dør serendipity-effekten — faktoidene spres over duplikater som AI-en tror er ulike konsepter.

Løsning: Merge Entities admin-verktøy (Lag 2)

  1. Velg autoritativ node (Node A) og duplikat(er) (Node B, C, ...)
  2. Flytt alle graph_edges som peker på Node B → Node A (UPDATE graph_edges SET source_id/target_id = A WHERE ... = B)
  3. Flytt alle messages-mentions som refererer til Node B → Node A
  4. Legg til name fra Node B som alias i entities.aliases på Node A
  5. Slett Node B (DELETE FROM nodes → cascader)

aliases-arrayet i entities-tabellen finnes allerede og er indeksert med GIN — autocomplete søker i både name og aliases, noe som forebygger fremtidige duplikater.

Forebygging: Autocomplete bør vise eksisterende entiteter med matchende aliases før brukeren oppretter nye. "Mente du #Jonas Gahr Støre?" ved skriving av #Støre.

5. Datamodell

Den tekniske datamodellen (nodes-supertabell, graph_edges, detailtabeller, deterministiske UUIDs, tilgangsstyring via node_access) er dokumentert i docs/features/kunnskapsgraf_og_relasjoner.md.

================================================================ FILE: docs/concepts/møterommet.md

Konsept: Møterommet (Interne redaksjonsmøter)

Filsti: docs/concepts/møterommet.md

1. Konsept

Et fullverdig virtuelt møterom for Sidelinjas redaksjon. Bygget på LiveKit for lyd/video, med AI-referent som automatisk genererer referat og action points, delt whiteboard for visuell brainstorming, og søkbar møtehistorikk i Kunnskapsgrafen.

2. Brukeropplevelse

  1. En bruker oppretter et møterom i SvelteKit. Alle deltakere kobler seg til via LiveKit (WebRTC).
  2. Under møtet er følgende verktøy tilgjengelige:
    • Video/lyd — LiveKit-strøm mellom deltakere
    • Whiteboard — Frihåndstavle for skisser (se docs/features/whiteboard.md)
    • Delt scratchpad — SpacetimeDB-drevet tekstfelt for kjappe notater
    • Aha-markør — Markerer viktige øyeblikk med tidsstempel
    • Off-the-record — Pauser AI-lytting og opptak
  3. Whisper transkriberer møtet via live transkripsjonspipelinen (se docs/features/live_transkripsjon.md).
  4. Ved møteslutt genererer AI-referenten (se docs/features/live_ai.md, møte-modus):
    • Strukturert referat (Markdown)
    • Action points → foreslåtte Kanban-kort (se docs/features/kanban.md)
    • Identifiserte #Temaer og @Aktører → automatisk tråding i Kunnskapsgrafen

3. Komponenter

Feature Rolle i Møterommet
LiveKit WebRTC lyd/video
Live transkripsjon Whisper-pipeline for møtetranskripsjon (se docs/features/live_transkripsjon.md)
Live AI (møte-modus) Referat, action points, tråding (se docs/features/live_ai.md)
Whiteboard Frihåndstavle for visuell brainstorming (se docs/features/whiteboard.md)
Chat Scratchpad / tekstnotater (se docs/features/chat.md)
Kanban Mottaker av foreslåtte action-point-kort (se docs/features/kanban.md)

4. Off-the-record

Når off-the-record er aktivt:

  • Whisper-strømmen stopper — ingen data lagres
  • Visuell indikator i alle deltakeres grensesnitt
  • Transkripsjonen får et gap i tidslinja
  • Whiteboard forblir aktivt (visuelt, ikke tale)

5. Søkbar møtehistorikk

Møter transkriberes i segmenter (samme modell som episode-segmenter) og indekseres i PostgreSQL med full-text search. Møtereferater lenkes til Temaer og Aktører via graph_edges, slik at intern møtehistorikk blir søkbar i Kunnskapsgrafen.

================================================================ FILE: docs/concepts/podcastfabrikken.md

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.

================================================================ FILE: docs/concepts/redaksjonen.md

Konsept: Redaksjonen (Daglig redaksjonelt arbeid)

Filsti: docs/concepts/redaksjonen.md

1. Konsept

Redaksjonen er den daglige arbeidsflaten for Sidelinjas team. Her planlegges episoder, diskuteres temaer, samles research og skrives show notes. Temaet er hovedobjektet — ikke episoden.

2. Brukeropplevelse

2.1 Tema-bassenget

Alle pågående "Saker" vises i en oversikt. PostgreSQL er kilden til sannhet, SpacetimeDB holder aktive temaer i minnet for sanntidsoppdateringer.

2.2 Trådet Chat

Hver melding tilhører et Tema. Meldinger støtter tråder (svar-på-svar) og rike mentions med #-tags og @-mentions. Se docs/features/chat.md for teknisk spesifikasjon.

2.3 Kanban / Kjøreplan

Episoder fungerer som containere. Brukerne drar Temaer fra bassenget inn i en episodes kjøreplan med drag-and-drop. Se docs/features/kanban.md for teknisk spesifikasjon.

2.4 Show Notes

Et kollaborativt tekstfelt koblet til et Tema. Enkle "Operational Transformation"-aktige oppdateringer (eller felt-låsing) håndteres i SpacetimeDB-modulen. Synkes til PostgreSQL for persistens.

2.5 AI-behandling av tekst

Brukere limer inn uformatert tekst fra nettet i editoren, trykker AI-knappen () og velger handling (rens, oppsummer, trekk ut fakta). Resultatet publiseres som en ny melding med foreslåtte graf-koblinger. Se docs/proposals/editor.md § "AI-behandling — universell knapp".

3. Komponenter

Feature Rolle i Redaksjonen
Chat Trådet diskusjon per Tema (se docs/features/chat.md)
Kanban Episodeplanlegging (se docs/features/kanban.md)
Editor (proposal) Universell editor med AI-behandling av tekst (se docs/proposals/editor.md)
Whiteboard Kan åpnes fra chat for visuell brainstorming (se docs/features/whiteboard.md)

4. Instruks for Claude Code

  • Bruk SvelteKit for Drag-and-Drop. Unngå tunge biblioteker hvis native HTML5 Drag and Drop er tilstrekkelig.
  • SpacetimeDB er "State Manager". Frontend speiler SpacetimeDB sin tilstand — ikke bygg kompleks lokal state.
  • All tilgang er styrt via node_access-matrisen. SpacetimeDB-tilkoblinger bærer brukerens identitet, og tilgang avgjøres av edges til samlings-noder.

================================================================ FILE: docs/concepts/studioet.md

Konsept: Studioet (Podcast-innspilling)

Filsti: docs/concepts/studioet.md

1. Konsept

Det virtuelle podcast-studioet er Sidelinjas innspillingsmiljø. LiveKit håndterer WebRTC for flerbruker lyd/video, mens AI-assistenten lytter med og dytter relevante faktoider til programlederne i sanntid.

2. Brukeropplevelse

  1. Programlederne åpner studioet i SvelteKit (PWA) og kobler seg til et LiveKit-rom.
  2. Høykvalitetslyd streames mellom deltakerne via WebRTC.
  3. I bakgrunnen transkriberer Whisper lydstrømmen i chunks (~5 sek) via live transkripsjonspipelinen (se docs/features/live_transkripsjon.md).
  4. AI-assistenten analyserer transkripsjonen for entiteter (NER) og slår opp i Kunnskapsgrafen. Relevante faktoider popper lydløst opp på skjermen (se docs/features/live_ai.md, studio-modus).
  5. Programlederne kan trykke Aha-markør for å markere viktige øyeblikk. Tidsstempelet lagres i SpacetimeDB, koblet til episoden.
  6. Etter innspilling skyves lydfilen inn i Podcastfabrikken for full transkripsjon og publisering (se docs/concepts/podcastfabrikken.md).

3. Komponenter

Feature Rolle i Studioet
LiveKit WebRTC lyd/video mellom deltakere
Live transkripsjon Whisper small for lav latens, ~1s forsinkelse (se docs/features/live_transkripsjon.md)
Live AI (studio-modus) NER + faktoid-oppslag fra Kunnskapsgrafen (se docs/features/live_ai.md)
Aha-markør Manuell markering av viktige øyeblikk, lagres i SpacetimeDB

4. Avgrensning

  • Studioet er for innspilling, ikke redigering. Klipping/postproduksjon skjer utenfor Sidelinja.
  • Live-transkripsjonen her er flyktig (TTL 30 dager) — den endelige transkripsjonen lages via Podcastfabrikken med medium + initial_prompt.
  • Aha-markøren deles med Møterommet (se docs/concepts/møterommet.md), men i studio-konteksten brukes den primært til klippepunkter.

5. Utviklingsfaser

  1. Bygg SpacetimeDB-lytter i frontend + dummy faktoid-push for å verifisere UI.
  2. Koble Whisper til et offline lydopptak, kjør NER/oppslag mot PostgreSQL.
  3. Koble LiveKit-strømmen til Whisper for sanntid.

================================================================ FILE: docs/concepts/valgomaten.md

Konsept: Valgomaten (Crowdsourced & Datadrevet)

Filsti: docs/concepts/valgomaten.md

1. Konsept & Kjernefilosofi

Neste generasjons valgomat tar et oppgjør med den redaktørstyrte, endimensjonale modellen. Istedenfor forhåndsdefinerte akser styrer brukerne innholdet "bottom-up" gjennom et marked for akser. Valgomaten kombinerer friksjonsfri "Tinder-swiping" for folk flest, med et dyptgående redaksjonelt verktøy for nerdene, alt bygget på toppen av Sidelinjas eksisterende Kunnskapsgraf og sanntidsinfrastruktur.

1.1 Markedsmekanisme

Brukere kan fritt opprette nye politiske akser og spørsmål. Dimensjoner som engasjerer (får mange svar) "bobler opp" og blir standardakser, mens irrelevante forsvinner. Ingen begrensning på antall akser — systemet støtter komplekse skillelinjer i samfunnet.

1.2 Teoretisk fundament (Default-akser)

For å unngå "Blank Canvas"-syndromet startes plattformen med anerkjente rammeverk som fungerer som ankere:

  • John Haidts Moral Foundations Theory: Omsorg, Rettferdighet, Lojalitet, Autoritet, Renhet.
  • Rokkans skillelinjer: Sentrum/periferi, by/land, religiøs/sekulær.
  • Nanny State Index: Formynderstat vs. personlig frihet og ansvar.
  • Intensjon vs. Resultat: Støttes politikk på bakgrunn av teoretisk målsetting eller empirisk utfall?

2. Brukeropplevelse (Trakten)

Valgomaten er bygget som en to-trinns trakt for å maksimere viral spredning samtidig som dataintegriteten bevares.

2.1 Forsiden: Friksjonsfri & Anonym

  • Mekanikk: Ingen registrering kreves for å starte. SvelteKit genererer en anonym UUID som lagres i localStorage og brukes mot SpacetimeDB.
  • UX: Et rent kortstokk-grensesnitt (Tinder-stil swipe). Påstand på forsiden, brukeren velger Enig/Uenig.
  • Tre-trinns kalibrering (OKCupid-inspirert):
    1. Hva mener du?
    2. Hva ønsker du at kandidaten skal mene?
    3. Hvor viktig er dette for deg?
  • Dealbreakers (Negative Veto): Brukere kan sette absolutte grenser (f.eks. "Uansett hvor enige vi er om skatt, matcher jeg aldri med en som er for/mot vindkraft").
  • Sanntid: SpacetimeDB beregner PCA (Prinsipalkomponentanalyse) og oppdaterer brukerens posisjon lynraskt i minnet for hvert swipe.

2.2 Baksiden: "Snu kortet" (Krever innlogging)

For avansert interaksjon må brukeren logge inn via Authentik (SSO). Den anonyme sesjonen flettes da automatisk med brukerprofilen. På baksiden av kortet finner man:

  • Folkets Redaksjonsmøte: Mulighet til å se andres kommentarer til spørsmålet, foreslå endringer, og stemme opp/ned forbedringsforslag.
  • Podcast-snarveien: Hvis spørsmålet er knyttet til et Tema i Kunnskapsgrafen, vises en "Play"-knapp. Caddy (Accept-Ranges: bytes) streamer tidsstemplede lydsegmenter fra tidligere Sidelinja-episoder der dette temaet ble diskutert.
  • Multi-akse vekting: Innsikt i hvilke akser spørsmålet påvirker (f.eks. 80% Klima, 20% Sentrum/Periferi), med mulighet for å foreslå nye akse-koblinger.

3. Matching & Resultat

3.1 Individfokus

Matcher velgeren mot spesifikke lokalpolitikere og listekandidater, ikke bare mot det sentrale partiprogrammet.

3.2 PCA (Prinsipalkomponentanalyse)

Matematisk reduksjon av kompleksitet. Algoritmen koker ned brukerens svar på tvers av 50+ variabler til de 2-3 hovedfaktorene som faktisk styrer vedkommendes politiske kompass, og visualiserer disse spesifikt for brukeren.

4. Crowdsourcing & Dataintegritet

4.1 Uforanderlig Historikk (Versjonering)

Et spørsmål i PostgreSQL kan aldri endres (UPDATE) etter at folk har begynt å svare på det.

  • Hvis et forbedringsforslag stemmes frem (eller godkjennes av redaksjonen), opprettes en ny versjon (f.eks. Spørsmål 42 (v2)).
  • Brukere beholder sine resultater knyttet til den nøyaktige teksten de leste. Algoritmen vet at v1 og v2 tilhører samme politiske konsept.

4.2 Opprettelse av nye spørsmål og akser (Sandkassen)

  • Inkubator: Innloggede brukere kan opprette nye spørsmål eller definere helt nye akser (ved å angi to motpoler). Disse havner i en "Sandkasse" og vises ikke på forsiden før de har fått nok organisk engasjement fra andre innloggede brukere.
  • Vekting via Graph Edges: Koblingen mellom et spørsmål og en akse lagres som en relasjon i graph_edges. Når brukere "stemmer opp" at et spørsmål tilhører en spesifikk akse, øker feltet confidence. SpacetimeDB bruker denne confidence-scoren som multiplikator i match-algoritmen.

5. Visualisering & Sidelinja Explorer

5.1 Innhenting av referansedata

For å bygge nøyaktige velgerkart, introduseres et valgfritt spørsmål før resultatet vises: "For å kalibrere landskapet: Hva stemte du ved forrige valg?". Dette knytter den anonyme eller innloggede sesjonen mot en parti-aktør i Kunnskapsgrafen.

5.2 Sidelinja Explorer (Offentlig Graf)

En egen SvelteKit-side der lekfolk og journalister kan analysere dataene.

  • Heatmaps: Viser klynger ("skyer") av velgermasser basert på partipreferanse på to valgfrie akser.
  • Varder i landskapet: Historiske figurer (Churchill, Stalin), nåværende politikere og Sidelinjas egne verter ligger inne som faste referansepunkter i grafen. AI-estimerte profiler gir pedagogisk (og underholdende) kontekst.

6. AI & Asynkron Prosessering

Av kostnads- og ytelseshensyn skjer all AI-bruk asynkront i backend via jobbkøen og ai-gateway (LiteLLM). Ingen live AI-kall i klienten.

  • AI-Kandidater (valgomat_generate_profile): En bakgrunnsjobb analyserer partiprogrammer via sidelinja/rutine (Gemini) og genererer "syntetiske" referanseprofiler for listekandidater og historiske figurer. Kandidater kan senere logge inn (Authentik) og overstyre AI-ens svar manuelt.
  • Semantisk deduplisering (valgomat_moderation): En asynkron jobb overvåker nye brukerskapte akser og spørsmål, slår sammen duplikater (f.eks. "Skattetrykk" og "Skattenivå"), og flagger emosjonelt ladede spørsmål for Sidelinja-redaksjonen i Redaksjonens chat.

7. Arkitektur & Ansvarsfordeling

Komponent Rolle / Ansvar
SvelteKit (Klient) UX, Anonym UUID-håndtering i localStorage, kortstokk-swipe-grensesnitt, "lazy loading" av baksiden ved innlogging, visning av Heatmaps via Sidelinja Explorer.
Authentik SSO for brukere, kandidater og redaksjon. Sammenfletting av anonym UUID med ekte bruker-ID.
SpacetimeDB Sanntids match-kalkulering (Rust Reducers), swiping-logikk, PCA-beregning, og "Multiplayer"-rom ("Sofagruppa"). Holder kun aktive sesjoner i minnet.
PostgreSQL Kunnskapsgrafen (nodes, graph_edges). Permanent lagring av spørsmål, akser, versjonshistorikk og aggregerte data for Sidelinja Explorer.
Rust Worker (Sync) Synkroniserer batcher av svar fra SpacetimeDB over til PostgreSQL-lagringen via standard sync-mekanismen (se synkronisering.md).
Rust Worker (AI) Kjører valgomat_generate_profile og valgomat_moderation via jobbkøen.

8. Dataklassifisering (ref. docs/arkitektur.md 2.2)

Data Kategori Detaljer
Spørsmål, akser, versjonshistorikk Kritisk (PG) Brukergenerert innhold, krever backup
Individuelle svar (aggregert) Kritisk (PG) Synket fra SpacetimeDB
Aktive sesjoner, live PCA-state Flyktig (SpacetimeDB) Tåler tap — bruker svarer på nytt
AI-genererte kandidatprofiler Avledet (PG) Kan regenereres fra partiprogrammer

9. Skaleringsrisiko

PCA i SpacetimeDB

PCA-beregning i SpacetimeDB er minnekrevende og udokumentert for store datasett. Ved tusenvis av samtidige brukere med 50+ akser kan minnebruken eksplodere. Tiltak:

  • Materialized Views i PostgreSQL for Sidelinja Explorer — aldri kjør tunge aggregeringer on-the-fly. Oppdater views via nattlig jobb eller etter batch-sync fra SpacetimeDB.
  • Batch-sync med størrelsesbegrensning — sync_outbox kan bli bottleneck ved høy svaraktivitet. Vurder dedikert sync-frekvens for valgomat-data.
  • PCA-fallback i PG — hvis SpacetimeDB-minnebruk overstiger terskel, flytt PCA-beregning til PostgreSQL (via pg_stat_statements for kostnadsmåling) med lengre oppdateringsintervall.
  • Overvåk tidlig — legg til valgomat-spesifikke metrikker i /admin/observability (antall aktive sesjoner, PCA-beregningstid, SpacetimeDB-minnebruk for valgomat-tabeller).

10. Instruks for Claude Code

  1. Datamodell: Utvid enum node_type i PostgreSQL med valgomat_question og valgomat_axis. Bruk graph_edges med relation_type = 'AFFECTS_AXIS' og oppdater confidence-feltet for å håndtere crowdsourcet vekting av spørsmål opp mot ulike akser.
  2. SpacetimeDB Reducers: Implementer innkommende events som SubmitSwipe, SuggestAxis, og match-algoritmen i Rust inne i SpacetimeDB. Pass på at reducere støtter anonym session_id.
  3. State Management: SvelteKit skal ikke kreve innlogging for forsiden. Implementer Auth-guards slik at opprettelse av spørsmål, stemmegivning på andres forslag og visning av kommentarer gir 403 Forbidden for uautoriserte og trigger Authentik-flyten.
  4. Explorer API: Bygg aggregerte Materialized Views i PostgreSQL for Sidelinja Explorer for å unngå tung on-the-fly kalkulering av Heatmaps over millioner av rader.
  5. Jobbtyper: Registrer valgomat_generate_profile og valgomat_moderation som jobbtyper i jobbkøen (se jobbkø.md). Bruk sidelinja/rutine som modellalias.
  6. Versjonering: Spørsmål er append-only. Nye versjoner er nye rader med referanse til forgjengeren — aldri UPDATE på tekst som har mottatt svar.

================================================================ FILE: docs/features/brukerinnstillinger.md

Feature: Brukerinnstillinger

Filsti: docs/features/brukerinnstillinger.md

1. Konsept

Hver bruker har personlige innstillinger som styrer hvordan appen ser ut og oppfører seg. Innstillingene påvirker kun visning og opplevelse — aldri innholdet. Tilgjengelig via et innstillingspanel i appen.

2. Innstillinger

2.1 Utseende

Innstilling Default Alternativer Beskrivelse
Tema System Lys / Mørk / System Følger OS-preferanse som default
Skriftstørrelse 16px 1224px (slider) Gjelder all tekst i appen
Linjehøyde 1.6 1.42.0 (slider) Avstand mellom linjer
Innholdsbredde 70ch 50chfull (slider eller presets) Maks bredde på tekstinnhold
Font System default Serif / Sans-serif / Monospace Brødtekst-font i appen
Redusert bevegelse System Av / På / System Respekterer prefers-reduced-motion
Kompakt modus Av Av / På Mindre padding, tettere layout

2.2 Editor

Innstilling Default Alternativer Beskrivelse
Standard visning Rendered Raw / Rendered Foretrukket editor-modus
Stavekontroll Av / På Nettleserens innebygde stavekontroll
Auto-save intervall 500ms 500ms5s Debounce for auto-save
Vis tegnteller Av Av / På Antall tegn/ord i bunn av editor

2.3 Notifikasjoner (fremtidig)

Innstilling Default Alternativer Beskrivelse
Desktop-varsler Av Av / På Push-notifikasjoner i nettleseren
Lyd Av Av / På Lydvarsling ved nye meldinger
Varsle ved mention Av / På Varsle når noen #-mentioner noe du følger

2.4 Tilgjengelighet

Innstilling Default Alternativer Beskrivelse
Høy kontrast Av Av / På Sterkere fargekontraster
Fokus-synlighet System System / Forsterket Tydeligere fokus-indikatorer

3. Datamodell

Innstillingene lagres i en JSONB-kolonne på users-tabellen:

ALTER TABLE users ADD COLUMN settings JSONB NOT NULL DEFAULT '{}';

Eksempel:

{
  "theme": "dark",
  "font_size": 18,
  "line_height": 1.7,
  "content_width": "80ch",
  "font_family": "serif",
  "reduced_motion": "system",
  "compact_mode": false,
  "editor_default_view": "raw",
  "spellcheck": true,
  "autosave_ms": 500,
  "show_char_count": true
}

Hvorfor users.settings og ikke en egen tabell?

  • Innstillingene er per bruker, ikke per samlings-node
  • JSONB er fleksibelt — nye innstillinger legges til uten migrering
  • Ingen relasjoner, ingen spørringer mot enkeltfelt — bare hent hele objektet

Samlings-node-spesifikke overrides kan legges som edges med metadata mellom bruker og samlings-node ved behov. Men dette er fase 2. Start med globale brukerinnstillinger.

4. Frontend

4.1 Innstillingspanel

Tilgjengelig via brukermenyen (avatar → "Innstillinger") eller hurtigtast.

┌─────────────────────────────────────┐
│ Innstillinger                    ✕  │
├─────────────────────────────────────┤
│                                     │
│ Utseende                            │
│ ┌─────────────────────────────────┐ │
│ │ Tema        [Lys] [Mørk] [Auto]│ │
│ │ Skrift      ──●────────── 18px │ │
│ │ Linjehøyde  ────●──────── 1.7  │ │
│ │ Bredde      ──────●────── 70ch │ │
│ │ Font        [Sans] [Serif] [Mono]│
│ └─────────────────────────────────┘ │
│                                     │
│ Editor                              │
│ ┌─────────────────────────────────┐ │
│ │ Standard    [Raw] [Rendered]    │ │
│ │ Stavekontroll        [●]       │ │
│ │ Tegnteller           [ ]       │ │
│ └─────────────────────────────────┘ │
│                                     │
│ Live forhåndsvisning:               │
│ ┌─────────────────────────────────┐ │
│ │ Dette er en prøvetekst som      │ │
│ │ viser hvordan innstillingene    │ │
│ │ påvirker visningen.             │ │
│ └─────────────────────────────────┘ │
│                                     │
│              [Tilbakestill default]  │
└─────────────────────────────────────┘

Endringer appliseres øyeblikkelig (live preview). Ingen "lagre"-knapp — auto-save med debounce.

4.2 CSS Custom Properties

Innstillingene appliseres via CSS custom properties på :root:

:root {
  --user-font-size: 16px;
  --user-line-height: 1.6;
  --user-content-width: 70ch;
  --user-font-family: system-ui, sans-serif;
}

Alle komponenter bruker disse variablene i stedet for hardkodede verdier:

.message-body {
  font-size: var(--user-font-size);
  line-height: var(--user-line-height);
  max-width: var(--user-content-width);
}

4.3 Svelte Store

// $lib/stores/userSettings.ts
export const userSettings = writable<UserSettings>(defaults);

// Ved innlogging: hent fra API
// Ved endring: debounce → PATCH /api/user/settings
// CSS variables oppdateres reaktivt

5. API

Metode Sti Beskrivelse
GET /api/user/settings Hent brukerens innstillinger
PATCH /api/user/settings Oppdater innstillinger (merge med eksisterende)

PATCH gjør en shallow merge: settings = settings || patch. Kun oppgitte felt endres.

6. Viktige prinsipper

  • Innstillinger påvirker aldri innhold. En bruker med 24px skrift og en bruker med 12px skrift ser identisk innhold. Publiserte artikler for eksterne lesere bruker typografi-stacken fra artikkel-publisering, ikke forfatterens personlige innstillinger.
  • Defaults er gode. En bruker som aldri åpner innstillingspanelet skal ha en god opplevelse. Innstillinger er for tilpasning, ikke for at ting skal fungere.
  • Ingen overraskelser. Endringer gjelder kun brukeren som gjør dem. Workspace-admin kan ikke overstyre personlige innstillinger.
  • Progressiv avsløring. Start med de viktigste innstillingene (tema, skriftstørrelse). Legg til flere etter hvert som behov oppstår.

7. Instruks for Claude Code

  • Innstillinger lagres i users.settings JSONB — hent hele objektet, merge ved oppdatering
  • Bruk CSS custom properties for all visuell tilpasning — aldri hardkodede verdier i komponenter
  • Innstillingspanelet er en modal/drawer, ikke en egen side
  • Auto-save med debounce (500ms) — ingen "lagre"-knapp
  • Ved nye innstillinger: legg til i defaults-objektet, ingen migrering nødvendig

================================================================ FILE: docs/features/canvas_primitiv.md

Feature: Canvas-primitiv — felles fritt-canvas underlag

Filsti: docs/features/canvas_primitiv.md

1. Konsept

Canvas-primitivet er den felles underliggende komponenten for alle friform-views i Sidelinja: whiteboard (tegning), storyboard (kort-canvas), og fremtidige canvas-baserte visninger. Det håndterer kamera (pan, zoom), viewport-styring, objekt-plassering og interaksjon — men vet ingenting om hva som rendres.

1.1 Hvorfor et felles primitiv?

Whiteboard og storyboard har identisk infrastruktur-behov:

  • Uendelig canvas med pan og zoom
  • Objekter med (x, y)-posisjon
  • Drag-and-drop av objekter
  • Viewport culling (ikke render det som er utenfor synsfeltet)
  • Touch-støtte (pinch-zoom, to-finger-pan)
  • Responsivt design (fungerer på mobil, tablet, desktop)

Forskjellen er innholdet: whiteboard rendrer streker/figurer, storyboard rendrer meldingsboks-kort. Primitivet abstraherer det felles, slik at begge views gjenbruker 100 % av canvas-logikken.

1.2 Arkitekturprinsipp

Canvas-primitiv (felles)
├── Kamera: pan, zoom, transform matrix
├── Viewport: culling, synlige objekter
├── Interaksjon: pointer events, touch, drag
├── Grid: valgfri snap, hjelpelinje
└── Render-delegering: slot/callback for innhold

Whiteboard (consumer)
├── Tegneverktøy: penn, linje, rektangel, tekst
├── Strøk-modell: SVG paths / canvas paths
└── SpacetimeDB: strøk-synkronisering

Storyboard (consumer)
├── Kort-rendering: <MessageBox> i kompakt modus
├── Status-modell: Klar / Tatt opp / Droppet / Arkivert
├── Portal-soner: overføringsmekanikk til andre blokker
└── SpacetimeDB: kort-posisjon + status-synkronisering

2. Kamera-modell

2.1 Transform

Kameraet representeres som en 2D affin transformasjon:

interface Camera {
    x: number;      // pan offset X (world coords)
    y: number;      // pan offset Y (world coords)
    zoom: number;   // scale factor (1.0 = 100%)
}

Rendring via CSS transform på en wrapper-div:

.canvas-world {
    transform: translate(calc(var(--cam-x) * 1px), calc(var(--cam-y) * 1px))
               scale(var(--cam-zoom));
    transform-origin: 0 0;
}

2.2 Zoom-begrensning

  • Min zoom: 0.1 (10 % — fugleperspektiv, brukes av Pinboard Mode)
  • Max zoom: 3.0 (300 % — detalj)
  • Default: 1.0
  • Zoom pivoterer rundt musepeker/finger-midtpunkt

2.3 Pan

  • Desktop: Hold mellomknapp eller mellomrom + dra. Alternativt: to-finger-drag på trackpad.
  • Touch: To-finger-pan (én finger = dra objekter, to fingre = pan).
  • Edge-pan: Når man drar et objekt nær kanten av viewport, scroller canvaset automatisk i den retningen.

3. Viewport Culling

Bare objekter som overlapper med det synlige viewport-rektangelet rendres i DOM. For storyboard med 50200 kort er dette en optimalisering som holder DOM-et lett.

function visibleObjects(objects: CanvasObject[], camera: Camera, viewportSize: { w: number, h: number }): CanvasObject[] {
    const worldRect = screenToWorld(camera, viewportSize);
    return objects.filter(obj => intersects(obj.bounds, worldRect));
}

En margin (f.eks. 200px i world-space) legges til for å unngå pop-in ved pan.

4. Objektmodell

Canvas-primitivet opererer på generiske objekter:

interface CanvasObject {
    id: string;
    x: number;
    y: number;
    width: number;
    height: number;
    // Consumer-spesifikk data håndteres via generics/props
}

Consumer (whiteboard, storyboard) bestemmer hva som rendres for hvert objekt via en render-callback eller Svelte snippet:

<Canvas objects={cards} let:object>
    <!-- Consumer bestemmer innholdet -->
    <StoryboardCard card={object} />
</Canvas>

5. Interaksjon

5.1 Pointer events

All interaksjon håndteres via pointer events (unified mouse + touch):

Gest Desktop Touch Handling
Pan Mellomknapp-drag / Space+drag To-finger-drag Flytt kamera
Zoom Scroll wheel Pinch Zoom inn/ut
Velg Klikk Tap Velg objekt
Flytt Venstreklikk-drag på objekt Én-finger-drag på objekt Flytt objekt
Multi-select Shift+klikk / lasso Lang-trykk + drag Velg flere

5.2 Snap-to-grid (valgfri)

Når aktivert, snapper objekter til et rutenett ved drag-slipp:

function snap(value: number, gridSize: number): number {
    return Math.round(value / gridSize) * gridSize;
}

Default: av. Kan toggles via hurtigtast eller toolbar.

5.3 Seleksjon

  • Klikk på tom flate: deselect alle
  • Klikk på objekt: velg det (deselect andre)
  • Shift+klikk: toggle seleksjon
  • Lasso: dra på tom flate uten Space = tegn seleksjonsboks

6. Responsivt design

Canvas-primitivet skal fungere på alle skjermstørrelser:

Skjerm Tilpasning
Desktop (>1024px) Full interaksjon, alle hurtigtaster
Tablet (7681024px) Touch-gester, toolbar i bunn
Mobil (<768px) Forenklet toolbar, større treffområder for objekter, ingen lasso

Touch-treffområder skal være minimum 44x44px (WCAG 2.5.5).

7. Fullskjerm-modus (BlockShell-feature)

Enhver blokk i BlockShell kan gå i fullskjerm. Dette er en generell feature, ikke spesifikk for canvas:

  • Toggle: Dobbeltklikk på blokk-headeren, eller knapp i header
  • Implementering: Blokken settes til position: fixed; inset: 0; z-index: 50
  • Escape: Trykk Esc eller klikk "minimer"-knapp for å gå tilbake
  • URL-state: Fullskjerm-tilstand lagres ikke i URL — det er en visuell modus, ikke en side

8. SpacetimeDB-integrasjon

Canvas-primitivet selv har ingen SpacetimeDB-kobling — det er consumer-ens ansvar. Men primitivet eksponerer events som consumeren kan koble til SpacetimeDB:

interface CanvasEvents {
    onObjectMove: (id: string, x: number, y: number) => void;
    onObjectResize: (id: string, w: number, h: number) => void;
    onCameraChange: (camera: Camera) => void;
    onSelectionChange: (ids: string[]) => void;
}

Storyboard-consumeren bruker onObjectMove til å kalle en SpacetimeDB-reducer for å synkronisere posisjon til andre klienter.

9. Bygger på

  • SvelteKit: Svelte 5 $state/$derived for reaktiv kamera- og objekt-state
  • CSS transforms: Ingen Canvas2D eller WebGL — DOM-basert rendering for å beholde Svelte-komponent-rendering inne i objektene
  • Pointer Events API: Unified input for mus og touch

10. Implementeringsstrategi

Fase 1: Kjerne-primitiv

  • <Canvas> Svelte-komponent med kamera (pan/zoom), viewport culling, og objekt-drag
  • Touch-støtte (pinch-zoom, to-finger-pan)
  • BlockShell fullskjerm-toggle

Fase 2: Storyboard som første consumer

  • <StoryboardCard> rendrer meldingsboks-kort på canvaset
  • SpacetimeDB-synk for posisjon og status
  • Portal-soner for overføring

Fase 3: Whiteboard-migrering

  • Migrere eksisterende whiteboard-spec til å bruke canvas-primitivet
  • Tegneverktøy som overlay oppå primitivet

11. Instruks for Claude Code

  • Canvas-primitivet er en ren Svelte-komponent uten backend-avhengigheter
  • Bruk CSS transforms, ikke Canvas2D — innholdet inne i objekter er vanlige Svelte-komponenter
  • All state styres via Svelte 5 $state og $derived — ingen external state management
  • Pointer events, ikke mouse events — unified input
  • Test med touch-emulering i DevTools for responsivitet
  • Viewport culling er påkrevd fra dag 1 — ikke optimaliser bort

================================================================ FILE: docs/features/chat.md

Feature: Chat (Channels & Meldinger)

Filsti: docs/features/chat.md

1. Konsept

En universell, sanntids meldingskomponent bygget på SpacetimeDB. Chat er ikke bundet til én kontekst — den kan knyttes til enhver node i Kunnskapsgrafen via channels. Ulike konsepter bruker chat med ulik konfigurasjon, men all infrastruktur er delt.

2. Channels-modellen

En channel er en meldingsstrøm knyttet til en vilkårlig node (parent) i grafen. Channelen er selv en node (node_type = 'channel'), noe som betyr at den deltar i grafen og arver tilgangsstyring via node_access-matrisen.

┌─────────────┐     parent_id      ┌─────────────┐
│   Channel   │ ──────────────────► │ Vilkårlig   │
│   (node)    │                     │ node        │
└──────┬──────┘                     └─────────────┘
       │
       │  channel_id
       ▼
┌─────────────┐
│  Meldinger  │
│  (nodes)    │
└─────────────┘

En node kan ha flere channels. Eksempler:

  • Et Tema har "Diskusjon" (default) + "Research-dump"
  • En Episode har "Redaksjonelt" (intern kommentartråd)
  • Et Møte har "Scratchpad" (flyktig, TTL)
  • En Aktør har "Notater" (valgfri)

2.1 Channel-konfigurasjon

Hver channel har en config (JSONB) som styrer hvilke capabilities den støtter:

{
  "threads": true,          // reply-to-tråder
  "mentions": true,         // #/@ parsing + automatiske graph_edges
  "attachments": true,      // filopplasting
  "research_clips": true,   // research_clip meldingstype (AI-prosessert)
  "ttl_days": null           // null = permanent, tall = auto-slett etter N dager
}

2.2 Standard channel-presets per konsept

Konsept Channel-navn Config
Redaksjonen (Tema) "Diskusjon" threads: true, mentions: true, attachments: true, research_clips: true, ttl_days: null
Redaksjonen (Tema) "Research" (valgfri) threads: false, mentions: true, attachments: true, research_clips: true, ttl_days: null
Møterommet "Scratchpad" threads: false, mentions: false, attachments: false, research_clips: false, ttl_days: 90
Studioet "Studio-chat" threads: false, mentions: false, attachments: false, research_clips: false, ttl_days: 30
Episode "Redaksjonelt" threads: true, mentions: true, attachments: true, research_clips: false, ttl_days: null

Presets er kun defaults — en admin for samlings-noden kan justere config per channel.

2.3 Automatisk opprettelse

Når en node opprettes som forventes å ha chat (Tema, Episode, Møte), oppretter systemet automatisk en default-channel. Dette skjer i SvelteKit server-side som en del av node-opprettelsestransaksjonen.

3. Meldinger

3.1 Datamodell (PostgreSQL)

Meldinger er noder i Kunnskapsgrafen (node_type = 'melding'):

messages (
    id           UUID PK  nodes(id),
    channel_id   UUID NOT NULL  channels(id),
    reply_to     UUID  messages(id),       -- tråder (hvis config.threads = true)
    author_id    TEXT NOT NULL  users,
    message_type message_type,              -- 'text', 'research_clip', 'factoid', 'system'
    body         TEXT NOT NULL,
    metadata     JSONB,                     -- ekstra data (research-klipp AI-resultat, etc.)
    edited_at    TIMESTAMPTZ,
    created_at   TIMESTAMPTZ
)

3.2 SpacetimeDB (sanntid)

SpacetimeDB holder aktive channels' meldinger i minnet som varm cache foran PG. Ved oppstart gjør worker warmup fra PG → SpacetimeDB (per-kanal konfigurasjon). Nye meldinger sendes via SpacetimeDB-reducers og kringkastes til alle tilkoblede klienter. Synkes til PostgreSQL med ~1 sek forsinkelse (se docs/infra/synkronisering.md).

4. Mentions & Autocomplete

Kun aktive når config.mentions = true.

  • Trigger-tegn: # (Temaer/Aktører fra Kunnskapsgrafen), @ (brukere/redaksjonsmedlemmer), / (kommandoer).
  • Filtrering: Svelte-klienten filtrerer den lokale SpacetimeDB-cachen umiddelbart. Skriver man #Ha... vises en klikkbar liste ("Hans Petter Sjøli", "Høyre").
  • Grafkobling: Ved #-mention opprettes automatisk MENTIONS-edges i graph_edges mellom meldingen og den nevnte noden.
  • Mobil-optimalisert: Autocomplete-listen er tappbar og tilpasset mindre skjermer.

5. Tråder

Kun aktive når config.threads = true. Meldinger kan ha en reply_to-referanse. Frontend grupperer meldinger i tråder (rot + svar) med visuell skillelinje mellom hver tråd. Svar vises med innrykk og vertikal linje under rot-meldingen, uten ekstra skillelinje mellom rot og svar.

6. Vedlegg

Kun aktive når config.attachments = true. Meldinger kan ha vedlegg via message_attachmentsmedia_files. Whiteboard-eksport kan knyttes som vedlegg.

7. Versjonshistorikk

Alle meldinger støtter redigering med full historikk via message_revisions. Original tekst bevares alltid. AI-behandlede meldinger har en revisjons-toggle i UI — brukeren kan veksle mellom AI-versjon og original tekst. AI-output rendres som Markdown via marked.

7.1 Meldingsvisning

Lange meldinger (mer enn 2 linjer) kollapses automatisk med en "Vis mer"-knapp. Ved ekspandering vises "Vis mindre" både over og under meldingen, slik at man slipper å scrolle for å kollapse igjen.

8. Tale-til-tekst (Voice-to-text)

Mobilvennlig diktering for situasjoner der tastatur er upraktisk. Brukeren trykker en mikrofon-knapp, snakker, og får teksten tilbake som en vanlig melding klar til redigering og sending.

8.1 Flyt

  1. Bruker trykker og holder (eller toggler) mikrofon-knappen i chat-feltet.
  2. Nettleseren fanger lyd via MediaRecorder API (WebM/Opus).
  3. Lydklippet sendes til Whisper (POST /v1/audio/transcriptions, response_format=text, language=no) via SvelteKit server-side.
  4. Transkripsjonen settes inn i meldingsfeltet — brukeren kan redigere før sending.
  5. Ingen lagring av lydfilen — den kastes etter transkripsjon.

8.2 Avgrensning

  • Dette er ikke en lydmelding-feature (à la WhatsApp). Lyden er et transportmiddel for tekst. Kun teksten lagres.
  • Whisper-kallet er kort (<30 sek tale) og kan rutes direkte til Whisper-serveren uten jobbkø.
  • Bruk small-modellen for lav latens. Navnenøyaktighet er mindre viktig for korte chatmeldinger.

9. TTL (automatisk opprydding)

Channels med config.ttl_days satt til et tall får sine meldinger automatisk slettet av en nattlig jobbkø-jobb. Brukes for flyktige kontekster (scratchpads, studio-chat).

10. Implementeringsstatus

Ferdig (mars 2026)

  • ChatBlock.svelte: Adapter-mønster via createChat() factory. Bruker chat.edit(), chat.delete(), chat.react() — ingen direkte PG API-kall.
  • SpacetimeDB-adapter (spacetime.svelte.ts): Ren SpacetimeDB-adapter. All data fra SpacetimeDB (historikk via warmup + sanntid). Reaksjoner fra message_reaction-tabellen.
  • PG-adapter (pg.svelte.ts): Polling hvert 3. sek. Readonly fallback når SpacetimeDB ikke er konfigurert.
  • Factory (create.svelte.ts): Velger adapter basert på VITE_SPACETIMEDB_URL. SSR-safe med browser-guard.
  • Shared types (types.ts): ChatConnection interface med send, edit, delete, react, readonly.
  • SpacetimeDB Rust-modul (spacetimedb/src/lib.rs): ChatMessage, MessageReaction, SyncOutbox-tabeller. Reducers: send_message, delete_message, edit_message, add_reaction, remove_reaction, load_messages, load_reactions, clear_channel, mark_synced.
  • Worker warmup (worker/src/warmup.rs): PG → SpacetimeDB ved oppstart. Per-kanal konfig (all/messages/days/none). Trådbasert henting.
  • Worker sync (worker/src/sync.rs): SpacetimeDB → PG hvert sekund. Insert/delete/update meldinger + reaksjoner.
  • Admin-side (/admin/channels): Per-kanal warmup-konfigurasjon.
  • Tråder: Komplett trådvisning med datogruppering, autoscroll og visuell skillelinje mellom tråder.
  • Reaksjoner: Via SpacetimeDB-reducers, synket til PG.
  • Meldingskollaps: Lange meldinger begrenses til 2 linjer med "Vis mer"/"Vis mindre".
  • AI-behandling: Meldinger kan AI-behandles (-knapp). Revisjons-toggle viser original vs. AI-versjon. Markdown-rendering for AI-output.
  • Konvertering: Meldinger kan opprettes som kanban-kort eller kalenderhendelse (dialog sier "Opprett", ikke "Konverter" — meldingen beholdes i chatten).

Gjenstår

  • Vedlegg, TTL — avventer implementering.
  • Tilgangsfiltrering: SpacetimeDB-laget må filtrere basert på node_access-matrisen.
  • Pin/konvertering: Går fortsatt direkte til PG API (ikke via SpacetimeDB).

11. Instruks for Claude Code

  • Opprettelsesrekkefølge: Opprett nodes-rad → channels-rad → (for meldinger) nodes-rad → messages-rad. Alt i én transaksjon.
  • Channel-opprettelse: Når en Tema, Episode eller Møte opprettes, opprett alltid en default-channel i samme transaksjon.
  • Mentions-parsing: Skjer i sync-workeren ved persistering til PG. Parser mention-UUIDs fra HTML body og oppretter graph_edges.
  • Config-respekt: Frontend-komponenten må lese channel.config og slå av/på UI-elementer. channels.config inneholder også warmup_mode/warmup_value for SpacetimeDB-oppvarming.
  • PG er autoritativ — SpacetimeDB er varm cache. Frontend snakker kun med SpacetimeDB.
  • Tilgang styres via node_access-matrisen. Channels arver tilgang fra sin parent-node via edges.

================================================================ FILE: docs/features/kalender.md

Feature: Kalender

Filsti: docs/features/kalender.md

1. Konsept

Månedsbasert kalendervisning for redaksjonell planlegging. Hendelser er nodes i kunnskapsgrafen og kan kobles til episoder, temaer, aktører og kanban-kort. Komplementerer Kanban ("hva" vs "når").

2. Status

PG-adapter ferdig og deployet (mars 2025). Abonnement, ICS-eksport og SpacetimeDB-sync gjenstår.

Implementert

  • Migrering 0003_calendar.sql: calendars + calendar_events (begge FK→nodes)
  • Hendelser er nodes — tilgangsstyrt via node_access-matrise
  • Heldagshendelser (T12:00:00 for tidssone-trygghet) vs. tidshendelser med klokkeslett
  • Fargekoder per hendelse (7 forhåndsdefinerte) + standard kalenderfarge
  • REST API: GET med tidsvindu-filtrering, POST/PATCH/DELETE hendelser
  • PG polling-adapter med 5 sek intervall
  • CalendarBlock.svelte: månedsrutenett, navigering, opprett/rediger-modal, Escape-lukking
  • linked_node-kolonne for fremtidig kobling til kanban-kort, episoder etc.

Gjenstår — Fase 2

  • Kobling til kanban-kort (vis deadline på kalender)
  • Uke- og dagsvisning
  • Gjentakende hendelser (RRULE)
  • Dra-og-slipp for å flytte hendelser mellom datoer
  • Flerdag-hendelser (vises over flere celler)
  • Abonnementsmodell (kalender → kalender via graph_edges)
  • Personlige vs. delte kalendere (via samlings-noder)
  • ICS/CalDAV-eksport
  • SpacetimeDB-modul + hybrid-adapter
  • Varsler/påminnelser via jobbkøen

3. Datamodell (implementert)

CREATE TABLE calendars (
    id          UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    parent_id   UUID NOT NULL REFERENCES nodes(id),
    name        TEXT NOT NULL,
    color       TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE calendar_events (
    id            UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    calendar_id   UUID NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
    title         TEXT NOT NULL,
    description   TEXT,
    starts_at     TIMESTAMPTZ NOT NULL,
    ends_at       TIMESTAMPTZ,
    all_day       BOOLEAN NOT NULL DEFAULT false,
    color         TEXT,
    linked_node   UUID REFERENCES nodes(id) ON DELETE SET NULL,
    created_by    TEXT REFERENCES users(authentik_id),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

Indekser: (calendar_id) og (calendar_id, starts_at) for effektiv tidsvindu-filtrering.

4. API-endepunkter

Metode Sti Beskrivelse
GET /api/calendar/[calendarId]?from=...&to=... Hent kalender med hendelser i tidsvindu
POST /api/calendar/[calendarId]/events Opprett hendelse
PATCH /api/calendar/[calendarId]/events/[eventId] Oppdater hendelse
DELETE /api/calendar/[calendarId]/events/[eventId] Slett hendelse

5. Brukes av

Konsept Bruk
Redaksjonen Innspillingsdatoer, publiseringsplan, deadlines
Foreningen Liberalistene Styremøter, arrangementer
Møterommet (fremtidig) Møteplanlegging

6. Tidssone-håndtering

  • Heldagshendelser lagres som T12:00:00 (middag) — unngår datoforskyvning ved UTC-konvertering
  • Tidshendelser konverteres til ISO via new Date().toISOString() (lokal → UTC)
  • Visning bruker new Date() som konverterer tilbake til lokal tid

7. Fremtidig: Abonnementsmodell

Kalendere kan abonnere på andre kalendere via SUBSCRIBES_TO-edge i grafen. Abonnenten ser hendelser read-only.

Type Eier Synlighet
Samlings-node Admin for samlings-noden Alle med tilgang via node_access
Personlig Bruker Kun eier + eksplisitt deling
Offentlig Samlings-node Alle som abonnerer

8. Fremtidig: ICS-eksport

Offentlige kalendere får en ICS-URL (/cal/{calendar_id}.ics) for Google Calendar, Apple Calendar etc.

9. Instruks for Claude Code

  • Bruk eventsForDate() med lokal dato-konvertering, ikke UTC-substring
  • Heldagshendelser: T12:00:00, aldri T00:00:00 (tidssone-felle)
  • Tilgang styres via node_access-matrisen
  • Sjekk docs/erfaringer/adapter_moenster.md for hybrid-strategi

================================================================ FILE: docs/features/kanban.md

Feature: Kanban (Planlegging)

Filsti: docs/features/kanban.md

1. Konsept

Et drag-and-drop Kanban-brett for planlegging. Primært brukt til episodeplanlegging i Redaksjonen, men også mottaker av AI-genererte action points fra Møterommet.

2. Status

PG-adapter ferdig og deployet (mars 2025). SpacetimeDB-sync gjenstår.

Implementert

  • Migrering 0002_kanban.sql: kanban_boards, kanban_columns, kanban_cards
  • Kanban-kort er nodes i kunnskapsgrafen (tilgangsstyrt via node_access-matrise)
  • REAL-posisjon for midpoint-innsetting ((1.0 + 2.0) / 2 = 1.5) — ingen re-nummerering
  • REST API: GET brett, POST kolonne/kort, PATCH kort/flytt, DELETE kort
  • PG polling-adapter (pg.svelte.ts) med 5 sek intervall og optimistisk UI
  • Adapter-factory (create.svelte.ts) — klar for SpacetimeDB-hybrid
  • KanbanBlock.svelte: drag & drop, redigeringsmodal (tittel/beskrivelse/slett), enkelt kort-input som legger til i første kolonne

Gjenstår

  • SpacetimeDB-modul + hybrid-adapter for sanntidsoppdatering
  • Reposisjonering ved dra innad i kolonne (sortert rekkefølge)
  • Tildeling (assignee) UI
  • Fargekoder/labels på kort
  • AI-integrasjon: møtereferent → nye kort

3. Datamodell

kanban_boards (id FK→nodes, parent_id FK→nodes, name)
kanban_columns (id, board_id FK→kanban_boards, name, color, position REAL)
kanban_cards (id FK→nodes, column_id FK→kanban_columns, title, description, assignee_id, position REAL, created_by, created_at)

Kort og brett er nodes — tilgang styres via node_access-matrisen.

4. API-endepunkter

Metode Sti Beskrivelse
GET /api/kanban/[boardId] Hent brett med kolonner og kort
POST /api/kanban/[boardId]/columns Opprett kolonne
POST /api/kanban/[boardId]/cards Opprett kort (oppretter node + kort)
PATCH /api/kanban/[boardId]/cards/[cardId] Oppdater tittel/beskrivelse
PATCH /api/kanban/[boardId]/cards/[cardId]/move Flytt kort til kolonne/posisjon
DELETE /api/kanban/[boardId]/cards/[cardId] Slett kort (cascader fra node)

5. Brukes av

Konsept Bruk
Redaksjonen Episodeplanlegging — dra Temaer inn i Kjøreplanen
Møterommet AI-referenten foreslår nye kort basert på action points
Foreningen Liberalistene Styreoppgaver (Å gjøre / Pågår / Ferdig)

6. Instruks for Claude Code

  • Bruk native HTML5 Drag and Drop i SvelteKit, unngå tunge biblioteker.
  • PG-adapter er autoritativ inntil SpacetimeDB-sync er på plass.
  • Tilgang styres via node_access-matrisen.
  • Sjekk docs/erfaringer/adapter_moenster.md for hybrid-strategi.

================================================================ FILE: docs/features/kunnskaps_bridge.md

Feature: Kunnskaps-Bridge (Cross-Context Discovery)

Filsti: docs/features/kunnskaps_bridge.md

NB: Dette dokumentet er en skisse fra v1 og må oppdateres til node/edge-modellen ved implementering.

1. Konsept

En valgfri, opt-in feature som lar brukere oppdage semantisk beslektet kunnskap på tvers av samlings-noder de har tilgang til. Bryter ikke tilgangsstyringen — resultatet er en peker ("dette finnes i Podcast B"), ikke selve innholdet. Brukeren må ha tilgang til begge samlings-nodene via node_access for å se treffet.

2. Avgrensning: Hva dette IKKE er

  • Ikke datadeling. Ingen data kopieres mellom samlings-noder. Bridge viser kun at en relevant node finnes.
  • Ikke automatisk. Begge samlings-noder må ha Bridge eksplisitt aktivert av en admin.
  • Ikke synlig for gjester. Kun for brukere med tilgang til begge samlings-nodene.
  • Ikke et søk i andres data. Du ser bare treff i samlings-noder du allerede har tilgang til.

3. Teknisk arkitektur

3.1 Vector Embeddings (pgvector)

Krever pgvector-extension i PostgreSQL:

CREATE EXTENSION IF NOT EXISTS vector;

ALTER TABLE actors ADD COLUMN embedding vector(768);
ALTER TABLE topics ADD COLUMN embedding vector(768);
ALTER TABLE factoids ADD COLUMN embedding vector(768);

CREATE INDEX idx_actors_embedding ON actors USING ivfflat (embedding vector_cosine_ops);
CREATE INDEX idx_topics_embedding ON topics USING ivfflat (embedding vector_cosine_ops);
CREATE INDEX idx_factoids_embedding ON factoids USING ivfflat (embedding vector_cosine_ops);

3.2 Embedding-generering (generate_embeddings)

En jobbkø-jobb som genererer embeddings for nye/endrede noder:

  1. Rust-worker plukker opp jobben fra jobbkøen.
  2. Bygger en tekst-representasjon av noden (navn, body, tilknyttede faktoider).
  3. Sender til AI Gateway (sidelinja/rutine) for embedding-generering.
  4. Lagrer vektoren i pgvector-kolonnen.
  5. Re-genereres ved vesentlige endringer av noden.

3.3 Cross-context søk

Når en bruker utforsker en node (f.eks. Tema "Skolepolitikk"):

  1. SvelteKit server-side henter brukerens tilgjengelige samlings-noder fra node_access-matrisen.
  2. Kjører et similarity-søk med <=> (cosine distance), filtrert mot brukerens tilgjengelige noder:
    SELECT na.node_id, e.name, e.embedding <=> $target_embedding AS distance
    FROM entities e
    JOIN nodes n ON e.id = n.id
    JOIN node_access na ON n.id = na.node_id
    WHERE na.user_id = $current_user
      AND n.id != $current_node_id
      AND e.embedding <=> $target_embedding < 0.3
    ORDER BY distance
    LIMIT 10;
    
  3. Resultatet vises som en diskret "Finnes også i..."-seksjon i UI-et.

3.4 Samlings-node config

Bridge aktiveres per samlings-node i nodens metadata (JSONB):

{
  "bridge_enabled": true,
  "bridge_discoverable": true
}
  • bridge_enabled — samlings-noden kan søke i andre samlings-noder
  • bridge_discoverable — andre samlings-noder kan finne noder under denne

Begge må være true for at en kobling skal vises.

4. Dataklassifisering

Data Kategori Detaljer
Embedding-vektorer Avledet (PG) Kan regenereres fra nodeinnhold

5. Instruks for Claude Code

  • pgvector er en ny avhengighet — dokumenter i docker-compose og setup-guides.
  • Cross-context søk bruker node_access-matrisen for tilgangsstyring. Isolér denne koden grundig — egen funksjon, aldri gjenbruk i andre kontekster.
  • Embedding-dimensjon (768) bør matche modellen som brukes. Konfigurér som konstant, ikke hardkod overalt.
  • Jobbtype generate_embeddings bruker sidelinja/rutine som modellalias.
  • Bridge er Lag 4+ — krever fylt kunnskapsgraf i minst to samlings-noder.

================================================================ FILE: docs/features/kunnskapsgraf_og_relasjoner.md

Feature Spec: Kunnskapsgraf og Relasjoner (Logseq-modell)

Filsti: docs/features/kunnskapsgraf_og_relasjoner.md

1. Konsept

Inspirert av verktøy som Logseq og Obsidian, bygger vi databasen som en toveis-lenket graf. Målet er å skape "serendipity" (lykketreff) i research-fasen ved å synliggjøre uventede forbindelser. Hvis Aktør A og Aktør B begge er nevnt i samme chat-tråd eller knyttet til samme Tema over tid, skal systemet kunne visualisere denne røde tråden for programlederne.

2. Arkitektur og Teknologivalg

Vi unngår tunge, dedikerte grafdatabaser (som Neo4j) for å holde infrastrukturen og ressursbruken (RAM) minimal.

  • Valgt teknologi: Vanilla PostgreSQL.
  • Mekanisme: En "Nodes and Edges" (Noder og Kanter) tabellstruktur kombinert med Recursive CTEs (Common Table Expressions) i SQL for å traversere grafen. Dette er mer enn raskt nok for redaksjonelle datamengder (100k+ noder).

3. Datastruktur

3.1 Supertabell: nodes

Alle entiteter i systemet arver sin UUID fra én sentral tabell. Dette gir ekte Foreign Key-integritet på graph_edges uten applikasjonslogikk-hacks.

CREATE TYPE node_type AS ENUM (
    'entitet',      -- person, organisasjon, sted, tema, konsept (erstatter 'aktør' og 'tema')
    'episode',      -- podcast-episode
    'segment',      -- tidsavgrenset del av episode
    'melding',      -- meldingsboks (chat, kanban-kort, kalenderhendelse, faktoide, notat)
    'channel',      -- gruppering av meldinger
    'kanban_board', -- strukturelt
    'calendar',     -- strukturelt
    'meeting'       -- LiveKit-møte
);

CREATE TABLE nodes (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    node_type    node_type NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

Merk: Gamle enum-verdier ('aktør', 'tema', 'faktoide', 'note', 'kanban_card', 'calendar_event') kan ikke fjernes fra PostgreSQL ENUM, men skal aldri brukes i ny kode.

3.2 Detailtabeller

Hver nodetype har sin egen tabell med FK til nodes.

Entiteter (erstatter actors og topics)

Alt som kan nevnes med # i chat er en entitet — personer, organisasjoner, steder, temaer, konsepter. Én tabell, én autocomplete, én #-mekanisme.

CREATE TABLE entities (
    id        UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    name      TEXT NOT NULL,             -- Autoritativ skrivemåte
    type      TEXT NOT NULL,             -- 'person', 'organisasjon', 'sted', 'tema', 'konsept'
    aliases   TEXT[] DEFAULT '{}',       -- Forkortelser, kallenavn, vanlige feilstavinger
    avatar_url TEXT                      -- Portrett, flagg, logo, kommunevåpen
);

CREATE INDEX idx_entities_name ON entities(name);
CREATE INDEX idx_entities_aliases ON entities USING GIN(aliases);

Autocomplete søker i både name og aliases. name er den autoritative formen som vises i UI. Eksempler:

  • name: 'Jonas Gahr Støre', aliases: {'JGS', 'Støre'}, type: 'person'
  • name: 'Arbeiderpartiet', aliases: {'AP', 'Ap', 'DNA'}, type: 'organisasjon'
  • name: 'Lørenskog', aliases: {'Lørenskog kommune'}, type: 'sted'
  • name: 'Skolepolitikk', aliases: {}, type: 'tema'

Episoder og segmenter

CREATE TABLE episodes (
    id           UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    title        TEXT NOT NULL,
    published_at TIMESTAMPTZ,
    guid         TEXT UNIQUE NOT NULL  -- RSS <guid>, aldri endres
);

CREATE TABLE segments (
    id          UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    episode_id  UUID NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
    start_time  INTERVAL NOT NULL,
    end_time    INTERVAL NOT NULL,
    transcript  TEXT,          -- Segmentets transkripsjon
    CONSTRAINT valid_timerange CHECK (end_time > start_time)
);

CREATE INDEX idx_segments_episode ON segments(episode_id);
CREATE INDEX idx_segments_transcript_fts ON segments USING GIN (to_tsvector('norwegian', transcript));

3.3 Kantene: graph_edges

All kobling skjer i én sentral tabell med ekte FK-integritet:

CREATE TABLE graph_edges (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    source_id     UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    target_id     UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    relation_type TEXT NOT NULL,  -- 'MENTIONS', 'CONTRADICTS', 'WORKS_FOR', 'PART_OF', 'DISCUSSED_IN'
    context_id    UUID REFERENCES nodes(id) ON DELETE SET NULL,
    confidence    REAL CHECK (confidence BETWEEN 0.0 AND 1.0),
    created_by    TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
    origin        TEXT NOT NULL DEFAULT 'system',  -- 'system', 'user', 'ai'
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT no_self_reference CHECK (source_id != target_id),
    CONSTRAINT unique_edge UNIQUE (source_id, target_id, relation_type)
);

CREATE INDEX idx_edges_source ON graph_edges(source_id);
CREATE INDEX idx_edges_target ON graph_edges(target_id);
CREATE INDEX idx_edges_relation ON graph_edges(relation_type);

Merk: UNIQUE(source_id, target_id, relation_type) forhindrer duplikate relasjoner. Bruk ON CONFLICT DO NOTHING eller ON CONFLICT DO UPDATE SET confidence = ... ved upsert.

Merk: node_type-enumet kan utvides av feature-spesifikke migrasjoner (f.eks. valgomat_question, valgomat_axis). Se docs/concepts/valgomaten.md for eksempel.

4. Segmenter og Transkripsjoner

4.1 Segment som grafnode

Episoder deles i segmenter — tidsavgrensede deler med egen transkripsjon. Hvert segment er en node i grafen og kan kobles til Temaer, Aktører og Faktoider. Dette muliggjør presise oppslag som: "I Episode 42, fra 14:23 til 21:07, diskuterte dere Skolepolitikk og sa følgende..."

Episode 42 (node: episode)
  ├── Segment 00:00-14:22 (node: segment) ──DISCUSSED_IN──► Tema: Mediepolitikk
  ├── Segment 14:23-21:07 (node: segment) ──DISCUSSED_IN──► Tema: Skolepolitikk
  │                                        ──MENTIONS──────► Aktør: Støre
  └── Segment 21:08-45:00 (node: segment) ──DISCUSSED_IN──► Tema: Kommuneøkonomi

Kapitler i RSS-feeden genereres fra segmentene, men er et eget konsern (se docs/concepts/podcastfabrikken.md).

4.2 Transkripsjoner: Git som master, PG som søkeindeks

Transkripsjoner lever i to steder med klart eierskap:

Sted Rolle Format
Git (Forgejo) Kilde til sannhet. Redigerbar, sporbar, diffbar Markdown med tidsstempler
PostgreSQL Søkeindeks. Full-text search, koblet til grafen Segmentert i segments-tabellen

Flyt:

Whisper → Git (rå transkripsjon med tidsstempler)
            → Redaksjonen korrigerer manuelt ved behov
            → Push til Forgejo
            → Forgejo webhook trigger 'transcript_reimport'-jobb i jobbkøen
            → Rust-worker parser filen, splitter i segmenter
            → DELETE + INSERT i én PG-transaksjon (idempotent reimport)
            → Grafkoblinger bevares (segment-UUID deterministisk fra episode-UUID + tidsstempel)

Deterministisk UUID for segmenter: UUID = uuid_v5(episode_uuid, start_time_ms). Dette sikrer at samme segment alltid får samme UUID, selv ved reimport. Grafkoblinger som peker på segmentet overlever dermed en full reimport.

5. Arbeidsflyt: Hvordan grafen vokser

Grafen bygger seg opp organisk gjennom daglig bruk av Sidelinja-suiten:

  1. Chat & Notater: En bruker skriver: "Apropos #Hans_Petter_Sjøli, hva var greia med #Arbeiderpartiet?"
  2. Parsing (Svelte/Rust): Systemet fanger opp de to #-taggene (som allerede har UUIDs i Aktør-tabellen).
  3. Edge Creation: SvelteKit server-side oppretter automatisk to nye oppføringer i graph_edges-tabellen:
    • [Melding UUID] -> MENTIONS -> [Sjøli UUID]
    • [Melding UUID] -> MENTIONS -> [Arbeiderpartiet UUID]
  4. Indirekte relasjon: Fordi begge aktørene nå deler samme context_id (meldingen), vet Kunnskapsgrafen at det finnes en tematisk kobling mellom Sjøli og Ap.
  5. Publisering: Når en episode publiseres, kobles segmentene automatisk til relevante Temaer og Aktører basert på AI-analyse av transkripsjonen.

6. Instruks for Claude Code

  • Tilgangsstyring: Tilgang til noder styres via node_access-matrisen (materialized view). Spørringer mot detailtabeller (entities, etc.) filtrerer via JOIN med node_access.
  • nodes-tabellen er obligatorisk. Opprett alltid en rad i nodes før du inserter i en detailtabell. Bruk en hjelpefunksjon som gjør begge i én transaksjon.
  • graph_edges-tilgang. Tilgang til edges avledes fra tilgang til kilde- og målnoder via node_access. UNIQUE(source_id, target_id, relation_type) hindrer duplikater — bruk ON CONFLICT ved upsert.
  • Graf-spørringer: Bruk WITH RECURSIVE i PostgreSQL når du bygger endepunkter som skal hente ut "Linked Mentions" eller nettverket rundt en spesifikk Aktør opp til 2-3 ledd ut.
  • Fremtidssikring for UI: Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (som D3.js eller Vis.js) i Svelte-frontenden. Formatet bør være { "nodes": [...], "edges": [...] }.
  • Transkripsjon-reimport: Workeren må være idempotent. Bruk uuid_v5(episode_uuid, start_time_ms) for deterministiske segment-UUIDs. Slett og gjenopprett segmenter i én transaksjon, men ikke slett edges som peker til segmentene — de overlever fordi UUID-en er stabil. Merk: Hvis manuelle korrigeringer endrer segment-grenser (start_time), endres UUID-en. Løsning: automatisk flytt eksisterende edges til nærmeste nye segment basert på tidsintervall-overlapp.
  • Full-text search: Bruk to_tsvector('norwegian', transcript) for norsk språkstøtte i søk.

================================================================ FILE: docs/features/live_ai.md

Feature: Live AI (Faktoid-oppslag & Referent)

Filsti: docs/features/live_ai.md

1. Konsept

AI-drevet analyse av sanntids transkripsjon. Opererer i to moduser med samme underliggende pipeline (Whisper → NER → handling), men ulik output.

2. Studio-modus: Faktoid-oppslag

Brukes i Studioet (se docs/concepts/studioet.md). En "virtuell co-host" som dytter relevante faktoider til programlederne i sanntid.

2.1 Dataflyt

  1. Live transkripsjon (se docs/features/live_transkripsjon.md) leverer tekst-chunks.
  2. Rust-tjenesten analyserer for egennavn (Named Entity Recognition).
  3. Lynraskt oppslag i PostgreSQL: SELECT * FROM factoids JOIN actors... WHERE actor.name = $1.
  4. Treff dytter LiveFactoidEvent inn i SpacetimeDB.
  5. SvelteKit-studio viser faktoiden lydløst i en egen boks.

2.2 Lagring

Live-transkripsjonsloggen er flyktig (TTL 30 dager):

live_transcription_log (
    id SERIAL,
    session_id UUID,
    chunk_timestamp TIMESTAMPTZ,
    chunk_text TEXT,
    matched_entities TEXT[],
    pushed_factoids UUID[],
    created_at TIMESTAMPTZ DEFAULT now()
)

En nattlig jobbkø-jobb sletter rader eldre enn TTL.

3. Møte-modus: AI-Referent

Brukes i Møterommet (se docs/concepts/møterommet.md). Genererer referat og action points.

3.1 Dataflyt

  1. Under møtet: Whisper transkriberer i chunks. Aha-markører fra deltakerne lagres med tidsstempler.
  2. Ved møteslutt: En meeting_summarize-jobb opprettes i jobbkøen (se docs/infra/jobbkø.md).
  3. Rust-worker sender transkripsjonen + Aha-markører til AI Gateway (sidelinja/rutine) med instruksjon om å generere:
    • Referat (strukturert Markdown)
    • Action points (foreslåtte Kanban-kort)
    • Identifiserte #Temaer og @Aktører
  4. Referatet lagres som melding i relevant Tema-chat. Foreslåtte Kanban-kort vises for godkjenning.
  5. graph_edges opprettes automatisk mellom møtesegmenter og identifiserte Temaer/Aktører.

3.2 Off-the-record

Når off-the-record er aktivt, stopper Whisper-strømmen og ingen data lagres. Visuell indikator i alle deltakeres grensesnitt. Transkripsjonen får et gap i tidslinja.

4. Kill Switch

Studio-modus har en kill switch — en synlig "Stopp AI"-knapp i studio-grensesnittet som umiddelbart:

  1. Stopper NER-analyse av nye chunks
  2. Skjuler faktoid-boksen
  3. Logger tidspunkt og grunn (manuelt felt, valgfritt)

Nødvendig fordi AI-en kan dytte feil eller irrelevante faktoider under live innspilling. Programlederen må kunne slå den av uten å forlate studio-viewet.

Kill switch-status (ai_enabled: bool) lagres på LiveKit-rommet i SpacetimeDB og synkes til alle klienter i rommet.

5. Instruks for Claude Code

  • Begge moduser deler samme Whisper-pipeline — ikke dupliser transkripsjonskode.
  • Studio-modus krever lav latens — hold NER-oppslaget raskt (indekserte spørringer).
  • Møte-modus er asynkron — prosessér via jobbkøen etter møteslutt.
  • All AI-kode peker på http://ai-gateway:4000/v1, aldri direkte til leverandør.
  • Kill switch skal alltid være tilgjengelig i studio-modus — default: AI aktivert.
  • Tilgang styres via node_access-matrisen.

================================================================ FILE: docs/features/live_transkripsjon.md

Feature: Live Transkripsjon (Whisper-pipeline)

Filsti: docs/features/live_transkripsjon.md

1. Konsept

Den felles Whisper-pipelinen som brukes av flere konsepter for å transkribere lyd. Abstraherer bort konfigurasjonsforskjeller (modellvalg, latenskrav) bak et felles grensesnitt.

2. Moduser

Kontekst Modell Latenskrav initial_prompt Output
Studioet (live) small <1s per chunk Nei (hastighet prioritert) Flyktig tekst → NER-pipeline
Møterommet (live) small <1s per chunk Nei Flyktig tekst → AI-referent
Podcastfabrikken (batch) medium + prompt Ingen (asynkront) Ja (navneliste) SRT → Git → PG

3. Teknisk arkitektur

  1. Lydkilde: LiveKit server-side hooks (live) eller filopplasting (batch).
  2. Whisper-server: fedirz/faster-whisper-server (Docker, OpenAI-kompatibelt API). Endepunkt: POST /v1/audio/transcriptions.
  3. Chunking (live): Rust-tjeneste mater lyd i ~5-sekunders chunks. small-modellen prosesserer ~5x raskere enn sanntid, noe som gir <1s forsinkelse per chunk.
  4. Output: SRT (batch) eller ren tekst (live).

4. Whisper-konfigurasjon

  • Språk: Sett language=no eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk.
  • large-v3 KREVER vad_filter=true — uten hallusinerer modellen repeterende tekst.
  • Benchmark og modellvalg: Se docs/concepts/podcastfabrikken.md seksjon 4 for ytelsestall og anbefalinger.

4.1 initial_prompt (navneliste)

Brukes kun i batch-modus (Podcastfabrikken). Prompten bygges automatisk fra samlings-nodens metadata (metadata.whisper_prompt) + entiteter i kunnskapsgrafen.

Effekten er tydelig:

  • Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja"
  • Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig)

5. Lagring

  • Live-modus: Transkripsjonen er flyktig (kategori 4, TTL 30 dager). Lagres i live_transcription_log for feilsøking.
  • Batch-modus: SRT committes til Git (Forgejo). Avledede formater (ren tekst, segmenter, søkeindeks) i PostgreSQL.

6. Instruks for Claude Code

  • Live transkripsjon blokkerer ALDRI web-requests — prosesseres i Rust-worker eller separat tjeneste.
  • Batch-transkripsjon kjøres som whisper_transcribe-jobb i jobbkøen (se docs/infra/jobbkø.md).
  • Config (prompts) hentes fra samlings-nodens metadata (JSONB).

================================================================ FILE: docs/features/lydmeldinger.md

Feature: Lydmeldinger & Diktering

Filsti: docs/features/lydmeldinger.md

1. Konsept

En mobil-first talefeature med tre moduser som dekker spekteret fra kjappe chatmeldinger til langt feltopptak. Grunnprinsippet er det samme: du snakker inn i telefonen, og systemet gjør noe nyttig med det. Forskjellen er hva som er output.

Modus Typisk lengde Output Master-format Lagring
Voice-to-text <30 sek Rå transkripsjon i meldingsfeltet Tekst Ingen (lyden kastes)
Lydmelding <10 min Lydfil + transkripsjon for søk Lyd media_file + node i grafen
Diktering 130 min AI-ryddet tekst (strukturert notat) Tekst Node i grafen

Voice-to-text er en del av chat-featuren (se docs/features/chat.md, seksjon 8). Denne specen dekker de to tyngre modusene.

2. Lydmelding-modus

2.1 Brukerflyt

  1. Bruker åpner lydmelding-modus (via FAB-knapp, hurtigtast eller fra en channel).
  2. Trykker record. Nettleseren fanger lyd via MediaRecorder API (WebM/Opus).
  3. Kan valgfritt velge kontekst: et Tema, en Aktør, eller "Usortert".
  4. Ved stopp lastes lydfilen opp til SvelteKit (streaming for store filer).
  5. Filen lagres som media_file i mediamappen (/srv/synops/media/voice/).
  6. En whisper_transcribe-jobb opprettes i jobbkøen for å gjøre opptaket søkbart.
  7. Transkripsjonen lagres som metadata på noden — lydfilen forblir master.

2.2 Datamodell

Lydmeldingen er en node i Kunnskapsgrafen (node_type = 'melding', message_type = 'voice_memo'):

nodes (node_type = 'melding')
  → messages (channel_id, message_type = 'voice_memo', body = transkripsjon, metadata = { duration, transcription_status })
  → message_attachments → media_files (lydfilen)

Lydmeldinger kan:

  • Sendes i en channel (som vedlegg til en melding)
  • Leve fristående i en personlig "Innboks"-channel
  • Knyttes til Temaer/Aktører via graph_edges (manuelt eller AI-foreslått)

2.3 Triagering

Redaksjonen trenger en flate for å gå gjennom nye lydmeldinger:

  • Lytt, tagg med Temaer/Aktører
  • Marker for bruk i episode (kobles til Kanban/Kjøreplan)
  • Forkast / arkiver
  • Denne flaten er en filtrert visning av "Innboks"-channelen, ikke et eget system

2.4 Bruk i podcast

Lydmeldinger markert for bruk kan trekkes inn i Podcastfabrikkens pipeline. Lydfilen er allerede i media-mappen — den kan klippes inn i en episode direkte.

3. Dikteringsmodus

3.1 Brukerflyt

  1. Bruker åpner dikteringsmodus (mobil-first, men fungerer på desktop).
  2. Snakker fritt — kan være flere minutter. Visuell indikator viser at opptak pågår.
  3. Ved stopp opprettes to jobber i jobbkøen (sekvensielt):
    • whisper_transcribe — rå transkripsjon (medium + initial_prompt for kvalitet)
    • dictation_cleanup — AI rydder i teksten
  4. Brukeren ser resultatet: rå transkripsjon og AI-ryddet versjon side om side.
  5. Kan redigere den ryddede versjonen før lagring.
  6. Lagres som et notat (node i grafen). Lydfilen slettes etter vellykket transkripsjon.

3.2 AI-opprydding (dictation_cleanup)

AI Gateway (sidelinja/rutine) prosesserer transkripsjonen med instruksjon om å:

  • Fjerne fyllord, gjentakelser og ufullstendige setninger
  • Strukturere i avsnitt med overskrifter der det gir mening
  • Bevare talerens mening og tone — ikke omskrive til "AI-språk"
  • Foreslå #Tema- og @Aktør-tags basert på innholdet

3.3 Datamodell

Dikterte notater er meldinger i en channel:

nodes (node_type = 'melding')
  → messages (channel_id, message_type = 'text', body = ryddet tekst, metadata = { raw_transcript, source: 'dictation' })

Metadata bevarer rå-transkripsjonen slik at brukeren alltid kan se hva som faktisk ble sagt.

3.4 Målkanal

Brukeren velger hvor notatet havner:

  • Personlig notat-channel — default, kun synlig for brukeren selv
  • Tema-channel — delt med redaksjonen som et innspill
  • Direkte til en annen bruker — som en asynkron "talemelding i tekstform"

4. Felles infrastruktur

4.1 Opptak (klient)

Begge moduser bruker samme opptakskomponent i SvelteKit:

  • MediaRecorder API med WebM/Opus
  • Visuell feedback (lydnivå-indikator, varighet)
  • Modus-velger: Lydmelding / Diktering
  • Kontekst-velger: Tema, Aktør, eller Usortert

4.2 Opplasting

SvelteKit håndterer filopplasting strømmende. For korte klipp (<2 min) kan opplasting starte umiddelbart etter stopp. For lengre opptak bør det vises en fremdriftsindikator.

4.3 Whisper

Begge moduser bruker live transkripsjonspipelinen (se docs/features/live_transkripsjon.md) i batch-modus via jobbkøen. Modellvalg:

  • Lydmelding: small (rask, transkripsjonen er sekundær — lyden er master)
  • Diktering: medium + initial_prompt (kvalitet prioritert — teksten er master)

4.4 Personlig "Innboks"-channel

Ved brukeropprettelse opprettes en privat channel per bruker for usorterte lydmeldinger og notater. Config:

{
  "threads": false,
  "mentions": false,
  "attachments": true,
  "research_clips": false,
  "ttl_days": null
}

5. Dataklassifisering (ref. docs/arkitektur.md 2.2)

Data Kategori Detaljer
Lydfiler (voice memos) Kritisk (backup) Unikt råmateriale — lyden er innholdet, kan brukes direkte i podcasten
Diktert tekst (ryddet) Kritisk (PG) Brukerens innhold
Rå transkripsjon Avledet Kan regenereres fra lydfil
Lydfil fra diktering Flyktig (TTL) Lyden er kun et transportformat for teksten — slettes etter at brukeren har godkjent den ryddede teksten. Spart diskplass, og unngår å lagre halvformulerte tanker som lyd.

6. Jobbtyper

Jobbtype Modellalias Beskrivelse
whisper_transcribe Eksisterende jobbtype, gjenbrukes
dictation_cleanup sidelinja/rutine AI-opprydding av transkripsjon

7. Instruks for Claude Code

  • Opptakskomponenten skal være en gjenbrukbar Svelte-komponent med modus-prop (voice_memo / dictation).
  • Lydfiler lagres i media/voice/ — aldri i databasen.
  • Diktering: slett lydfilen først etter at brukeren har godkjent den ryddede teksten.
  • voice_memo er en ny message_type — utvid enum i migrasjonen.
  • Personlig innboks-channel opprettes automatisk for nye brukere.
  • Tilgang styres via node_access-matrisen.

================================================================ FILE: docs/features/meldingsboks.md

Feature: Meldingsboks — universell diskusjonsprimitiv

Filsti: docs/features/meldingsboks.md

1. Konsept

Meldingsboksen er den sentrale byggeklossen for alt ustrukturert innhold i Sidelinja. Én datamodell som erstatter separate tabeller for chat-meldinger, kanban-kort, kalenderhendelser, faktoider og notater. Samme objekt, samme diskusjon, vist i flere kontekster via view-config.

1.1 Hva meldingsboksen erstatter

Tidligere modell Egen tabell Blir nå
Chat-melding messages Meldingsboks (node)
Kanban-kort kanban_cards Meldingsboks + kanban_card_view
Kalenderhendelse calendar_events Meldingsboks + calendar_event_view
Faktoide factoids Meldingsboks med ABOUT-edge
Notat notes Meldingsboks med tittel

1.2 Hva som IKKE er meldingsbokser

Typed nodes med strukturelt unike skjemaer forblir egne detailtabeller:

Type Hvorfor egen tabell
Entitet name, type, aliases, avatar_url — autocomplete, Whisper-prompt, autoritativ navngiving
Episode title, guid (immutabel RSS-krav), published_at
Segment episode_id, start_time/end_time, transcript, FTS-indeks

Entiteter (erstatter actors + topics) er alt som kan nevnes med #: personer, organisasjoner, steder, temaer, konsepter. Se docs/features/kunnskapsgraf_og_relasjoner.md §3.2.

Typed nodes kan kobles til meldingsbokser via edges for diskusjon. En entitet har ikke innebygd diskusjon, men en meldingsboks kan knyttes til den med en DISCUSSED_IN-edge.

2. Alle meldinger er noder

Hver meldingsboks er en fullverdig node i kunnskapsgrafen (node_type = 'melding'). Ingen vektklasser, ingen promoteringslogikk. Opprettelse er alltid: INSERT INTO nodes + INSERT INTO messages i én transaksjon.

Hvorfor: De fleste meldinger i en aktiv redaksjon ender opp med å trenge graf-tilkobling uansett (mentions, svar, stemmer). Promoteringslogikk legger til kompleksitet uten reell gevinst. nodes-tabellen tåler volumet — TTL rydder opp i flyktige meldinger, og node_type-filter sikrer at spørringer aldri treffer hele tabellen.

Konsekvens: Ethvert svar er en rik entitet som kan kobles til kanban, kalender, graf — full fleksibilitet uten spesialtilfeller. Et svar på tredje nivå i en diskusjon kan bli en kalenderoppføring, og konteksten forsvinner ikke.

3. Datamodell

3.1 Messages (erstatter messages, kanban_cards, calendar_events, factoids, notes)

CREATE TABLE messages (
    id           UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    channel_id   UUID REFERENCES nodes(id) ON DELETE CASCADE,
    reply_to     UUID REFERENCES messages(id) ON DELETE SET NULL,
    author_id    TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
    message_type message_type NOT NULL DEFAULT 'text',
    title        TEXT,               -- Kanban-kort, notater, kalenderhendelser, faktoider
    body         TEXT NOT NULL,
    metadata     JSONB,              -- Ekstra data per message_type
    pinned       BOOLEAN NOT NULL DEFAULT false,
    visibility   TEXT NOT NULL DEFAULT 'shared',  -- 'shared' | 'private'
    edited_at    TIMESTAMPTZ,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_messages_channel ON messages(channel_id, created_at);
CREATE INDEX idx_messages_reply ON messages(reply_to) WHERE reply_to IS NOT NULL;

CREATE TRIGGER trg_messages_updated_at BEFORE UPDATE ON messages
    FOR EACH ROW EXECUTE FUNCTION set_updated_at();

Forskjeller fra gammel messages-tabell:

  • id er FK til nodes(id)alle meldinger er noder i kunnskapsgrafen
  • Ingen workspace_id — tilgang styres via node_access-matrisen
  • channel_id er nullable — notater og standalone-bokser trenger ikke en channel
  • title er førsteklasses felt — brukes av kanban-kort, notater, kalenderhendelser
  • pinned for manuell fritak fra TTL-sletting
  • visibility styrer synlighet — 'shared' (alle med tilgang via node_access) eller 'private' (kun forfatter). Private meldingsbokser kan brukes som kladd for notater, kanban-kort og kalenderhendelser. Endre til 'shared' for å dele.

3.1.1 Kontekst-navigering via reply_to

Når en meldingsboks har roller i flere kontekster (f.eks. et svar som også er en kalenderoppføring), gir reply_to-kjeden alltid vei tilbake til opprinnelig diskusjon:

📅 Kalenderen: "Planleggingsmøte for konferanse"
   ↩ Fra diskusjon i #Mediepolitikk → "Vi burde kanskje..."

UI-et følger reply_to → forelder → channel_id → parent node for å bygge brødsmulesti. Ingen ekstra data nødvendig — reply_to + view-config gir hele bildet i begge retninger:

  • Fra kalenderen: "Hvor kom denne fra?" → følg reply_to oppover
  • Fra chatten: "Hva ble dette?" → se at svaret har calendar_event_view → vis kalender-badge

3.2 Kanban view-config (erstatter kanban_cards)

CREATE TABLE kanban_card_view (
    message_id  UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
    column_id   UUID NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE,
    position    REAL NOT NULL DEFAULT 0,
    color       TEXT,
    assignee_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL
);

CREATE INDEX idx_kanban_card_view_column ON kanban_card_view(column_id, position);

Et kanban-kort er en meldingsboks + en rad i kanban_card_view. Tittel og beskrivelse lever i messages.title/messages.body. Flytt mellom kolonner = UPDATE kanban_card_view SET column_id = ....

kanban_boards og kanban_columns forblir uendret — de er strukturelle tabeller, ikke grafnoder (kolonner er intern organisering).

3.3 Kalender view-config (erstatter calendar_events)

CREATE TABLE calendar_event_view (
    message_id  UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
    calendar_id UUID NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
    starts_at   TIMESTAMPTZ NOT NULL,
    ends_at     TIMESTAMPTZ,
    all_day     BOOLEAN NOT NULL DEFAULT false,
    color       TEXT
);

CREATE INDEX idx_calendar_event_view_calendar ON calendar_event_view(calendar_id, starts_at);

calendars-tabellen forblir uendret.

3.4 Reaksjoner (erstatter factoid_votes og message_votes)

CREATE TABLE message_reactions (
    message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    user_id    TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE,
    reaction   TEXT NOT NULL,   -- 'upvote', 'downvote', '👍', '🔥', etc.
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (message_id, user_id, reaction)
);

Én tabell for alle interaksjoner — opp/ned-stemmer ('upvote'/'downvote') og emoji-reaksjoner ('👍', '🔥') i samme modell. Ikke graf-edges — reaksjoner er høyfrekvente, lav-semantiske operasjoner.

Sortering etter stemmer: SELECT COUNT(*) FILTER (WHERE reaction = 'upvote') - COUNT(*) FILTER (WHERE reaction = 'downvote') AS score.

3.5 Message revisions (uendret)

CREATE TABLE message_revisions (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    body       TEXT NOT NULL,
    edited_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT revision_order UNIQUE (message_id, edited_at)
);

3.6 Nye relasjonstyper

Eksisterende relasjonstyper dekker behovene — ingen nye nødvendig:

  • DISCUSSED_IN — kobler typed nodes til meldingsbokser (diskusjon på en aktør/tema)
  • ABOUT — kobler faktoide-meldinger til aktører/temaer
  • MENTIONS — chat-mentions via #-tags

3.7 node_type enum (opprydding)

Aktive verdier etter migrering:

  • 'entitet' — person, organisasjon, sted, tema, konsept (erstatter 'aktør' og 'tema')
  • 'episode', 'segment' — typed nodes
  • 'melding' — meldingsboks (chat, kanban-kort, kalenderhendelse, faktoide, notat)
  • 'channel' — gruppering av meldinger
  • 'kanban_board', 'calendar', 'meeting' — strukturelle

Utfasede verdier (kan ikke fjernes fra PG ENUM, men skal aldri brukes i ny kode): 'aktør', 'tema', 'faktoide', 'note', 'kanban_card', 'calendar_event'

4. Multi-rolle via view-config

Konvertering = legg til view-config. Ingen data flyttes:

Meldingsboks (messages + nodes)
  ├── kanban_card_view  → vises på kanban-brettet
  ├── calendar_event_view → vises i kalenderen
  ├── edge: ABOUT → aktør/tema → vises som faktoide
  ├── edge: DISCUSSED_IN ← typed node → diskusjon på aktør/tema/episode
  ├── reply_to → parent melding → tråd-kontekst
  └── reply_to ← svar → diskusjonstråd følger med i alle kontekster

En meldingsboks kan ha flere roller samtidig: et kanban-kort som også er en kalenderhendelse, med en diskusjonstråd under seg. Svar på meldingsboksen følger med uansett kontekst — diskusjonen er alltid tilgjengelig.

5. Channels

Channels forblir som grupperingsmekanisme. En channel samler meldingsbokser under ett scope — typisk knyttet til en typed node (tema, episode, møte).

Channels opprettes ved behov, ikke automatisk for alle noder. Når en bruker starter en diskusjon på en aktør, opprettes en channel i samme transaksjon.

Channel-config (threads, mentions, attachments, ttl_days) arves fra kontekst (se docs/features/chat.md §2.2).

6. Slette-semantikk: "Fjern" vs "Slett"

En meldingsboks kan ha flere roller (kanban-kort, kalenderhendelse, diskusjonstråd). UI-et må ha et skarpt, eksplisitt skille:

Handling Hva skjer Konsekvens
Fjern fra brett/kalender DELETE FROM kanban_card_view / calendar_event_view Meldingen lever videre i chatten og grafen. Kun visningen forsvinner.
Slett innhold DELETE FROM messages → cascader til nodes Alt borte: diskusjonstråd, view-configs, graf-edges. Irreversibelt.

UI-regler:

  • "Fjern fra brett" / "Fjern fra kalender" — standardhandling i kontekstmeny på kanban/kalender. Trygt.
  • "Slett permanent" — bak en bekreftelsesdialog ("Denne meldingen har 3 svar og er koblet til 2 entiteter. Slett alt?"). Viser konsekvensene eksplisitt.
  • En melding med aktive roller i andre views bør vise en advarsel: "Denne meldingen er også et kanban-kort i [brett]. Fjern fra brettet først, eller slett alt?"

7. Nesting og utskilling

Maks 3 nivåer visuelt (via reply_to-kjeding):

  1. Boks (trådstart)
  2. Svar på boks
  3. Svar på svar

Ved nivå 3 tilbyr systemet: "Skill ut som egen diskusjon?"

  • Svaret promoteres til boks (ny node)
  • Originaltråden får en lenke-melding: "→ Diskusjonen fortsetter her"
  • Den nye boksen lever sitt eget liv

8. Eierskap, kurasjon og prominens

8.1 Eierskap

Trådstarter og admin for samlings-noden deler eierskap over en diskusjonstråd. Eierskap gir tilgang til kurasjonsverktøy (se 7.2).

8.2 Kurasjon (TODO — UI-features, bygges inkrementelt)

Datamodellen trenger ikke endres for disse — alt håndteres via messages.metadata (JSONB):

  • Absorber svarmetadata.absorbed = true. Svaret kollapses visuelt, ikke slettet.
  • Kollapser utdaterte svarmetadata.collapsed = true. Alltid tilgjengelig med klikk.
  • Fest viktige svarmetadata.featured = true. Vises prominent i lang tråd.

8.3 Prominens (avledet, ikke lagret)

Hvor viktig en meldingsboks er, beregnes fra eksisterende data — aldri lagret som en score:

  • Antall svar (COUNTreply_to)
  • Stemmer (SUM fra message_votes)
  • Antall roller (finnes i kanban_card_view, calendar_event_view?)
  • Graf-koblinger (COUNT fra graph_edges)
  • Alder på siste aktivitet (MAX(created_at) fra svar)

Beregnes ved visning eller caches i materialized view ved behov. Algoritmen kan justeres uten migrasjoner eller skjemaendringer.

9. TTL og livsløp

9.1 To-trinns fading

  1. Skjult fra visning — meldingen forsvinner fra default UI etter TTL (arvet fra channel eller samlings-node). messages.metadata.hidden_at settes.
  2. Slettet — etter tilleggsperiode (dobbel TTL) fjernes raden permanent.

9.2 Alder som dynamisk faktor

Tid bidrar til utfasing — eldre meldinger uten aktivitet eller koblinger fader naturlig. Prominens-scoren (§8.3) synker med alder, og TTL-jobben bruker den til å avgjøre hva som skjules og slettes.

9.3 Fritak-regler

En melding slettes ikke hvis:

  • Den har graf-edge(s) (ABOUT, MENTIONS, DISCUSSED_IN, etc.) — koblet til noe varig i kunnskapsgrafen. Dette er det som gjør faktoider immune: en ABOUT-edge til en aktør/tema betyr at informasjonen har verdi utover konteksten den ble skrevet i.
  • Den har kanban_card_view-rad i en aktiv kolonne
  • Den har calendar_event_view-rad med fremtidig tidspunkt
  • Den har aktive svar (siste svar innenfor TTL)
  • pinned = true

Prinsippet: Grafen bestemmer hva som er varig. Ingen spesialhåndtering for faktoider — enhver melding med en graf-kobling overlever. Meldinger uten koblinger fader med tid.

Lever boksen, lever alt under den — svar beholdes uansett alder.

9.4 Konfigurerbarhet

Samlings-node default TTL: 30 dager (samlings-node metadata.default_ttl_days)
  └── Channel kan overstyre: config.ttl_days
        └── Individuelle meldinger frittes via reglene over

10. <MessageBox> Svelte-komponent

Én komponent som rendrer en meldingsboks i alle kontekster:

  • Kompakt modus — kanban: tittel + "3 svar" + fargekode
  • Kalender-modus — tittel + tidspunkt + fargekode
  • Utvidet modus — full diskusjon med innrykk (maks 3 nivåer)
  • Leser kontekst og tilpasser capabilities (stemmer, mentions, vedlegg)
  • Lazy-loader tråd ved expand (ytelse)

11. Konsekvenser for eksisterende kode

11.1 Tabeller som fjernes

  • kanban_cards → erstattes av kanban_card_view
  • calendar_events → erstattes av calendar_event_view
  • factoids + factoid_votes → erstattes av messages + message_reactions
  • message_votes → erstattes av message_reactions
  • notes → erstattes av messages med tittel

11.2 Tabeller som forblir uendret

  • kanban_boards, kanban_columns — strukturelle
  • calendars — strukturell
  • channels — gruppering
  • message_revisions — revisjonshistorikk
  • message_attachments, media_files — vedlegg

11.3 API-endringer

Eksisterende API-ruter (/api/kanban/, /api/calendar/, /api/notes/) refaktoreres til å bruke den nye datamodellen. Grensesnittet mot frontend kan holdes stabilt — endringene er i datalaget.

12. Migrering (0005_meldingsboks.sql)

Migrasjonen konverterer eksisterende data:

  1. Endre messages-tabellen: Legg til title, pinned. id → nodes(id) beholdes (alle meldinger er allerede noder).
  2. Migrer kanban-kort: For hver kanban_cards-rad: opprett messages-rad (med title, body fra description, gjenbruk eksisterende node-id) + kanban_card_view-rad.
  3. Migrer kalenderhendelser: For hver calendar_events-rad: opprett messages-rad + calendar_event_view-rad.
  4. Migrer faktoider: For hver factoids-rad: opprett messages-rad (med body, message_type = 'factoid'). Flytt factoid_votes til message_votes. Opprett ABOUT-edges i graph_edges.
  5. Migrer notater: For hver notes-rad: opprett messages-rad (med title, body fra content).
  6. Drop gamle tabeller: kanban_cards, calendar_events, factoids, factoid_votes, notes.

Merk: Migrasjonen bør ha en tilhørende down-migrering som gjenskaper de gamle tabellene og flytter data tilbake.

13. Instruks for Claude Code

  • Opprettelse av meldingsboks: Alltid INSERT i nodes (type 'melding') + INSERT i messages med samme id. Alt i én transaksjon.
  • Kanban-kort: INSERT i nodes + messages (med tittel) + kanban_card_view. Én transaksjon.
  • Kalenderhendelse: INSERT i nodes + messages (med tittel) + calendar_event_view. Én transaksjon.
  • Faktoide: INSERT i nodes + messages (message_type = 'factoid') + graph_edges med ABOUT-relasjon til aktør/tema. Én transaksjon.
  • Notat: INSERT i nodes + messages (med tittel + body). channel_id peker på en personlig eller delt channel, eller er NULL.
  • Konvertering mellom roller: Legg til view-config-rad (kanban/kalender). Aldri kopier data.
  • Kontekst-navigering: Bruk reply_to-kjeden for å bygge brødsmulesti tilbake til opprinnelig diskusjonskontekst.
  • TTL: Implementér som nattlig jobbkø-jobb (message_ttl_cleanup). Sjekk fritak-regler før sletting. Ved sletting: slett fra messages → cascade til nodes via FK.
  • Tilgang: Styres via node_access-matrisen. Visibility håndteres i applikasjonskode (SvelteKit): WHERE visibility = 'shared' OR author_id = $current_user.
  • Visibility: Default 'shared'. Sett 'private' for personlige kladder. Endre til 'shared' for å dele — ingen kopiering nødvendig.

================================================================ FILE: docs/features/notater.md

Feature: Notater (Scratchpad)

Filsti: docs/features/notater.md

1. Konsept

Et enkelt notatverktøy med automatisk lagring. Brukes som scratchpad i ulike kontekster — show notes, møtenotater, research-notater. Notater er nodes i kunnskapsgrafen og kan kobles til andre noder.

2. Status

PG-adapter ferdig og deployet (mars 2025). Rich text og SpacetimeDB-sync gjenstår.

Implementert

  • Migrering 0004_notes.sql: notes-tabell (FK→nodes)
  • Notater er nodes — tilgangsstyrt via node_access-matrise
  • Auto-save med 500ms debounce (visuell feedback: "Lagrer..."/"Lagret")
  • REST API: GET og PATCH (tittel + innhold)
  • PG polling-adapter med 10 sek intervall (tregere enn chat/kanban — notater endres sjeldnere)
  • NotesBlock.svelte: tittel-input + fritekst-textarea med auto-save
  • Polling pauses mens brukeren skriver (unngår overskriving av egne endringer)

Gjenstår — Fase 2

  • Markdown-editor (rich text med forhåndsvisning)
  • Versjonering / undo-historikk
  • Kobling til andre noder (temaer, episoder, aktører)
  • Flerbruker-redigering (conflict resolution)
  • SpacetimeDB-modul + hybrid-adapter
  • Eksport (Markdown, PDF)

3. Datamodell (implementert)

CREATE TABLE notes (
    id          UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    parent_id   UUID NOT NULL REFERENCES nodes(id),
    title       TEXT NOT NULL DEFAULT '',
    content     TEXT NOT NULL DEFAULT '',
    created_by  TEXT REFERENCES users(authentik_id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

updated_at oppdateres automatisk ved PATCH og brukes til "Lagret [tidspunkt]"-visning.

4. API-endepunkter

Metode Sti Beskrivelse
GET /api/notes/[noteId] Hent notat
PATCH /api/notes/[noteId] Oppdater tittel og/eller innhold

5. Brukes av

Konsept Bruk
Redaksjonen (Sidelinja) Show notes for episoder
Foreningen Liberalistene Møtenotater
Møterommet (fremtidig) Scratchpad under møter

6. Auto-save-mønster

  • Bruker skriver → 500ms debounce → PATCH til server
  • Under lagring vises "Lagrer..." (gul)
  • Etter vellykket lagring vises "Lagret [dato]" (grå)
  • Polling (10 sek) henter siste versjon, men hopper over overskriving mens saving-flagget er satt

7. Instruks for Claude Code

  • Tilgang til notater styres via node_access-matrisen
  • Auto-save bruker debounce — ikke send PATCH ved hvert tastetrykk
  • updated_at brukes til UI-feedback, ikke til conflict resolution (ennå)
  • Sjekk docs/erfaringer/adapter_moenster.md for hybrid-strategi

================================================================ FILE: docs/features/podcast_statistikk.md

Feature Spec: Podcast-Statistikk

Filsti: docs/features/podcast_statistikk.md

1. Konsept

IAB-kompatibel lytterstatistikk bygget fra bunnen av. Vi fanger all rådata via Caddy, og bruker asynkron batch-prosessering for å bygge grafer og tall uten å belaste webserveren eller databasen med sanntids-skriving.

2. Arkitektur & Dataflyt

  1. Rådata (Caddy): Caddy konfigureres til å skrive access-logs for stien /media/podcast/*.mp3 til en formatert JSON-fil (f.eks. /srv/synops/logs/caddy/podcast_access.log).
  2. Logrotate: Standard Linux logrotate arkiverer loggene nattlig.
  3. Rust Batch Processor (Jobbkø): Statistikkparseren kjøres som en stats_parse-jobb i den felles jobbkøen (se docs/infra/jobbkø.md), med scheduled_for satt 1 time frem for periodisk kjøring. Workeren re-enqueuer seg selv ved fullføring.
    • Steg A (Filtrering): Leser JSON-loggen. Fjerner treff fra kjente bots ved å krysjekke User-Agent mot OPAWG (Open Podcast Analytics Working Group) sine åpne bot-lister.
    • Steg B (Deduplisering): Slår sammen byte-range forespørsler. Hvis samme IP og User-Agent har lastet ned deler av samme fil innenfor et 24-timers vindu, telles det som KUN én (1) nedlasting.
    • Steg C (Geografi/Klient): Mapper User-Agent til Podcast-klient (Spotify, Apple) basert på OPAWG-regler.
  4. Lagring (PostgreSQL): Rust-programmet skriver det aggregerte resultatet inn i PostgreSQL (episode_stats tabell med felter for tenant_id, date, episode_id, client_name, unique_downloads). Tenant-tilhørighet utledes fra filstien i loggen (/media/{tenant_slug}/...).

3. Personvern og GDPR

Caddy access-logger inneholder IP-adresser og User-Agent — dette er personopplysninger under GDPR.

Tiltak:

  • IP-anonymisering: Rust-workeren hasher IP-adresser (SHA-256 med daglig roterende salt) før de lagres i episode_stats. Rå IP-er lagres aldri i PostgreSQL.
  • Loggretensjon: Caddy-logger roteres og slettes etter 90 dager (kategori 4, flyktig). Etter at statistikk-workeren har prosessert en loggfil, inneholder PG kun aggregerte tall — ingen identifiserbar data.
  • Ingen sporing på tvers: Vi bruker ikke cookies, fingerprinting eller tredjepartstracking. Deduplisering basert på IP + User-Agent i 24-timers vindu er IAB-standard og minimumsdata.
  • Dokumentasjon: Podcastens RSS-feed bør lenke til en personvernerklæring som forklarer at anonymisert nedlastingsstatistikk samles inn.

4. Instruks for Claude Code

  • Bruk Rust-biblioteket serde_json for rask parsing av Caddy-loggene.
  • Dette programmet må skrives robust med tanke på at filer kan være låst av Caddy. Det bør tåle å avbrytes, og må holde styr på hvilken linje i loggfilen det prosesserte sist (f.eks. via en liten cursor-fil).
  • Rålogger skal ALDRI lagres i PostgreSQL.
  • Statistikk er tenant-scopet. episode_stats merkes med tenant_id. Admin-visningen filtrerer per tenant.

================================================================ FILE: docs/features/prompt_lab.md

Feature: Prompt-Laboratorium

Filsti: docs/features/prompt_lab.md

NB: Dette dokumentet er en skisse fra v1 og må oppdateres til node/edge-modellen ved implementering.

1. Konsept

Et internt kvalitetssikringsverktøy der redaksjonen kan teste, sammenligne og godkjenne LLM-prompts mot faktiske data fra egen samlings-node — før de ruller ut i produksjon. Integrert med AI Gateway (LiteLLM) og Promptfoo-testsettene.

2. Problemet det løser

  • Modellkvalitet på norsk varierer kraftig mellom leverandører og versjoner.
  • Leverandører oppdaterer modeller uten varsel — kvaliteten kan degraderes over natten.
  • Redaksjonen må kunne verifisere at en prompt fungerer med deres data (transkripsjoner, artikler, aktørnavn) før den settes i produksjon.
  • I dag krever prompt-testing kommandolinje og Promptfoo — det bør være tilgjengelig i nettleseren.

3. Brukeropplevelse

3.1 Playground (Ad-hoc testing)

  1. Bruker velger en jobbtype (research_clip, whisper_postprocess, metadata_extract, dictation_cleanup, etc.).
  2. Systemet laster inn gjeldende system-prompt fra samlings-nodens metadata.
  3. Bruker kan redigere prompten i et tekstfelt.
  4. Velger testdata: enten paste inn tekst manuelt, eller velg fra faktiske transkripsjoner/artikler brukeren har tilgang til.
  5. Velger modeller å teste mot (f.eks. sidelinja/rutine + sidelinja/resonering).
  6. Kjører testen — ser resultatene side-om-side.
  7. Kan iterere: endre prompten, kjør igjen, sammenlign.

3.2 Batch-evaluering (Promptfoo-integrasjon)

  1. Bruker velger et lagret testsett (fra tests/prompts/ i Git).
  2. Kjører testsettene mot valgte modeller via AI Gateway.
  3. Ser en matrise: testcase × modell × resultat, med pass/fail-markering.
  4. Kan sammenligne mot tidligere kjøringer for å oppdage regresjoner.

3.3 Deploy

Når en prompt er verifisert:

  1. Bruker klikker "Deploy".
  2. Prompten skrives til samlings-nodens metadata (metadata.llm_prompts[jobbtype]).
  3. Alle fremtidige jobber av den typen bruker den nye prompten.
  4. Tidligere prompt lagres i en prompt_history-logg for rollback.

4. Teknisk arkitektur

4.1 Datamodell

prompt_test_runs (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    context_node  UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,  -- samlings-node
    job_type      TEXT NOT NULL,
    system_prompt TEXT NOT NULL,
    model_alias   TEXT NOT NULL,
    input_text    TEXT NOT NULL,
    output_text   TEXT,
    tokens_used   INTEGER,
    latency_ms    INTEGER,
    created_by    TEXT NOT NULL REFERENCES users(authentik_id),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_prompt_runs_context ON prompt_test_runs(context_node, job_type, created_at DESC);

prompt_test_runs er ikke en node i grafen — det er en intern verktøytabell for utviklere/redaktører.

4.2 Prompt-historikk

prompt_history (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    context_node  UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,  -- samlings-node
    job_type      TEXT NOT NULL,
    system_prompt TEXT NOT NULL,
    deployed_by   TEXT NOT NULL REFERENCES users(authentik_id),
    deployed_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

Muliggjør rollback: "forrige prompt for research_clip fungerte bedre, rull tilbake."

4.3 Kjøring

  • Ad-hoc tester: SvelteKit server-side sender request direkte til AI Gateway (http://ai-gateway:4000/v1) med brukerens prompt og testdata. Resultatet returneres synkront.
  • Batch-evaluering: Oppretter en prompt_eval-jobb i jobbkøen. Rust-worker kjører testsettene mot AI Gateway og lagrer resultatene.

5. Dataklassifisering

Data Kategori Detaljer
Test-kjøringer Flyktig (TTL 90 dager) For analyse, ryddes automatisk
Prompt-historikk Kritisk (PG) Muliggjør rollback
Testsett (Promptfoo) Gjenskapbar (Git) tests/prompts/ — versjonskontrollert

6. Jobbtyper

Jobbtype Modellalias Beskrivelse
prompt_eval (varierer) Batch-evaluering av testsett mot valgte modeller

7. Instruks for Claude Code

  • Prompt Lab er et admin-verktøy — krev admin-tilgang til samlings-noden.
  • Ad-hoc tester kjøres synkront (SvelteKit → AI Gateway → respons). Ikke bruk jobbkø for enkelt-tester.
  • Batch-evaluering kjøres via jobbkø for å unngå timeouts.
  • Vis alltid token-bruk og latens — dette er et kostnadsbevisst verktøy.
  • Testdata (transkripsjoner, artikler) lastes via SvelteKit server-side fra PG. Gjesten-UX vises aldri her.
  • prompt_test_runs og prompt_history er knyttet til en samlings-node og trenger ikke RLS — de er kun tilgjengelige for admins via applikasjonslogikk.
  • Tilgang styres via node_access-matrisen.

================================================================ FILE: docs/features/universell_overfoering.md

Feature: Universell overføring — flytt objekter mellom blokker

Filsti: docs/features/universell_overfoering.md

1. Konsept

Universell overføring er mekanikken som lar brukere flytte meldingsboks-objekter mellom vilkårlige blokker: fra storyboard til chat, fra kanban til kalender, fra chat til storyboard. Enhver blokk kan sende og motta objekter. Meldingsboksen er alltid det underliggende objektet — overføringen endrer kun view-config, ikke selve meldingen.

1.1 Grunnprinsipp

En meldingsboks (node i kunnskapsgrafen) kan ha flere samtidige roller via view-configs (se meldingsboks.md §4). Universell overføring gjør dette til en førsteklasses brukerinteraksjon:

Bruker drar kort fra Storyboard → slipper på Chat-blokken
  → Meldingen får en ny plassering i chatten (placement-record)
  → Meldingen beholder sin posisjon på storyboardet
  → Diskusjonstråden er synlig begge steder (samme objekt)

Alternativt kan brukeren flytte (fjerne fra kilde, legge til i mål) i stedet for å kopiere (beholde begge). Kontekstmeny gir valget.

2. Plasseringsrelasjon (Placement)

Når en melding vises i en kontekst (chat, kanban, storyboard, kalender), trenger vi metadata om hvordan den vises der. Dette er plasseringsrelasjonen — en edge i grafen mellom meldingen og konteksten, med metadata.

2.1 Datamodell

CREATE TABLE message_placements (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    message_id   UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    context_type TEXT NOT NULL,  -- 'chat', 'kanban', 'storyboard', 'calendar', 'notes'
    context_id   UUID NOT NULL,  -- channel_id, board_id, episode_id, calendar_id, note_id
    entered_at   TIMESTAMPTZ NOT NULL DEFAULT now(),  -- når objektet ankom denne konteksten
    position     JSONB,          -- kontekst-spesifikk posisjon (se §2.2)
    UNIQUE (message_id, context_type, context_id)
);

CREATE INDEX idx_placements_context ON message_placements(context_type, context_id, entered_at);
CREATE INDEX idx_placements_message ON message_placements(message_id);

2.2 Posisjonsdata per kontekst

position-feltet er JSONB og inneholder kontekst-spesifikk plassering:

Kontekst position-innhold Eksempel
Chat null (sorteres etter entered_at) null
Kanban { "column_id": "...", "position": 1.5 } Kolonne + rekkefølge
Storyboard { "x": 340, "y": 120 } Fritt canvas-posisjon
Kalender { "date": "2026-03-20", "all_day": true } Dato + tidspunkt
Notes { "position": 3 } Rekkefølge i notatet

2.3 Forhold til eksisterende view-configs

message_placements erstatter ikke de eksisterende view-config-tabellene (kanban_card_view, calendar_event_view) umiddelbart. Strategien er:

  1. Fase 1: message_placements brukes for nye kontekster (storyboard, notes) og for overføringsmekanikken
  2. Fase 2: Eksisterende view-configs migreres gradvis til message_placements (kanban-posisjon, kalender-dato)
  3. Fase 3: kanban_card_view og calendar_event_view kan fjernes når all logikk bruker placements

2.4 entered_at vs created_at

  • messages.created_at = når meldingen ble skrevet
  • message_placements.entered_at = når meldingen ankom denne konteksten

En melding opprettet mandag i chatten som dras til storyboardet onsdag har created_at = mandag og storyboard-plassering med entered_at = onsdag. I chatten sorteres den etter sin chat-plasserings entered_at — som er mandag (opprinnelig plassering). I storyboardet vises den på canvas-posisjonen, uavhengig av tid.

3. Sende-mekanikk (source)

3.1 Drag-and-drop

Brukeren drar et objekt ut av en blokk. Når objektet forlater blokk-grensen:

  1. Objektet blir en "ghost" (semi-transparent drag-representasjon)
  2. Andre blokker som kan motta objektet highlighter sin mottakssone
  3. Slipp på en mottakssone → overføring

3.2 Kontekstmeny: "Send til..."

For situasjoner der drag-and-drop er upraktisk (mobil, lang avstand):

Høyreklikk kort → "Send til..." →
  ├── 📋 Kanban: Episodeplanlegging
  ├── 💬 Chat: #studio-diskusjon
  ├── 📅 Kalender: Redaksjonskalender
  └── 🎬 Storyboard: Episode 47

Listen viser alle blokker brukeren har tilgang til som kan motta meldinger.

3.3 Flytt vs. kopier

  • Kopier (default): Meldingen får en ny plassering i mål-konteksten, beholder plasseringen i kilde-konteksten
  • Flytt (hold Shift ved drag, eller velg i kontekstmeny): Plasseringen i kilde-konteksten fjernes, ny plassering opprettes i mål

4. Mottak-mekanikk (target)

Hver blokk-type definerer en mottaker som bestemmer hva som skjer når et objekt ankommer:

4.1 Mottaker per blokk-type

Blokk Default-plassering Visuell feedback
Chat Ny melding i bunnen, entered_at = now() Kort blinker inn i chatflyten
Kanban Første kolonne (eller "Innboks"), posisjon øverst Kort glir inn i kolonnen
Storyboard Senter av viewport (eller slipp-posisjon) Kort fader inn
Kalender Dagens dato, heldagshendelse Dato highlighter
Notes Ny blokk i bunnen av notatet Tekst fades inn

4.2 Mottakssone-rendering

Hver blokk rendrer en visuell drop-target når en drag er aktiv:

  • Hele blokken lyser opp med en subtil border/glow
  • Spesifikke soner (f.eks. en kolonne i kanban, en dato i kalender) highlighter ved hover
  • Avviste drops (feil type objekt) viser en dempet tilstand

4.3 Mottaker-interface

interface BlockReceiver {
    /** Kan denne blokken motta dette objektet? */
    canReceive(message: Message): boolean;

    /** Opprett plassering for mottatt objekt */
    receive(message: Message, dropPosition?: { x: number, y: number }): Placement;

    /** Visuell feedback for aktiv drag */
    renderDropZone(): void;
}

Alle blokk-typer implementerer dette interfacet.

5. SpacetimeDB-integrasjon

Plasseringsdata for sanntidskontekster (storyboard, kanban) eies av SpacetimeDB:

#[table(name = message_placement, public)]
pub struct MessagePlacement {
    #[primary_key]
    pub id: String,
    pub message_id: String,
    pub context_type: String,
    pub context_id: String,
    pub entered_at: Timestamp,
    pub position_json: String,  // JSON-serialisert posisjon
}

#[reducer]
pub fn place_message(ctx: &ReducerContext, placement: MessagePlacement) { ... }

#[reducer]
pub fn remove_placement(ctx: &ReducerContext, message_id: String, context_type: String, context_id: String) { ... }

#[reducer]
pub fn move_on_canvas(ctx: &ReducerContext, placement_id: String, new_position_json: String) { ... }

Sync-workeren persisterer til PG message_placements-tabellen.

6. Responsivt design

  • Desktop: Drag-and-drop mellom blokker fungerer naturlig
  • Tablet: Drag-and-drop fungerer med touch, men "Send til..."-meny er primær
  • Mobil: Kun "Send til..."-meny (blokker er stacked i én kolonne, drag mellom dem er upraktisk)

7. Bygger på

  • Meldingsboks (meldingsboks.md): Alle overførte objekter er meldingsbokser
  • Kunnskapsgraf (kunnskapsgraf_og_relasjoner.md): Plasseringer er relasjoner i grafen
  • BlockShell / PageGrid: Blokk-rammen som rendrer mottakssoner
  • SpacetimeDB (synkronisering.md): Sanntidssynk av plasseringer

8. Konsekvenser for eksisterende kode

8.1 BlockShell utvidelse

BlockShell trenger:

  • onDragEnter/onDragLeave/onDrop handlers for visuell feedback
  • Prop for receiver: BlockReceiver fra innholdsblokken
  • Fullskjerm-toggle (knapp i header + Esc for å lukke)

8.2 Ny plasserings-tabell

message_placements er ny. Eksisterende kanban_card_view og calendar_event_view lever parallelt inntil migrering.

8.3 SpacetimeDB-modul

Ny tabell message_placement med reducers for place/remove/move.

9. Instruks for Claude Code

  • Overføring oppretter aldri en kopi av meldingen — kun en ny plassering (view-config)
  • entered_at er alltid now() ved overføring, aldri kopiert fra kilden
  • En melding uten noen plasseringer er "løs" — den eksisterer i grafen men vises ikke noe sted. UI skal advare om dette ved siste fjerning
  • Drag-and-drop bruker HTML5 Drag and Drop API for blokk-til-blokk, og pointer events for intra-canvas (storyboard/whiteboard)
  • Hold overføringslogikken i en sentral transferService — ikke spread ut i hver blokk-type
  • Mottaker-interfacet er obligatorisk for alle blokk-typer

================================================================ FILE: docs/features/visuell_graf.md

Feature: Visuell Kunnskapsgraf (Graph View)

Filsti: docs/features/visuell_graf.md

1. Konsept

En interaktiv graf-visning i SvelteKit som gjør Kunnskapsgrafen visuelt navigerbar og redigerbar. Brukes i Kunnskapsgrafen-konseptet (se docs/concepts/kunnskapsgrafen.md).

2. Funksjonalitet

  • Visualisering: Viser noder (Temaer, Aktører, Faktoider, Segmenter) og relasjoner som et interaktivt nettverkskart. Bygges med D3.js eller Vis.js i Svelte-frontenden.
  • Visuell redigering: Redaksjonen kan dra streker mellom noder for å opprette nye relasjoner i graph_edges-tabellen. Velg relasjonstype (PART_OF, CONTRADICTS, MENTIONS, etc.) via en kontekstmeny.
  • Hierarkier uten mapper: PART_OF-relasjoner muliggjør fleksible prosjekthierarkier uten stive mappestrukturer. Et Tema kan være PART_OF et annet Tema, en Aktør kan være PART_OF en organisasjon, osv.
  • Filtrering: Brukeren kan filtrere grafen etter nodetype, relasjonstype, tidsperiode eller fritekst.

3. Datakilde

  • SvelteKit server-side: Henter grafdata via Recursive CTE-spørringer mot PostgreSQL og returnerer { "nodes": [...], "edges": [...] } til klienten.
  • SpacetimeDB: Brukes ikke for graf-visualisering — dette er historiske data som lever i PostgreSQL.

4. Instruks for Claude Code

  • Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (D3.js, Vis.js).
  • Begrens traversering til 2-3 ledd ut fra startnode for å unngå eksplosjoner i store grafer.
  • Tilgangsstyrt: noder filtreres via node_access-matrisen.

================================================================ FILE: docs/features/whiteboard.md

Feature Spec: Whiteboard (Frihåndstavle)

Filsti: docs/features/whiteboard.md

1. Konsept

Et delt, sanntids tegnebrett for frihåndsskisser, diagrammer og visuell brainstorming. Whiteboardet er en selvstendig komponent som kan brukes i flere kontekster: i møterom, i chat-tråder knyttet til et Tema, eller alene som personlig skisseblokk.

2. Brukskontekster

Kontekst Deltakere Lagring
Møterom Alle møtedeltakere i sanntid Eksporteres som bilde ved møteslutt, knyttes til møtereferatet
Tema-chat Alle med tilgang til temaet Lagres som vedlegg til en melding, synlig i chat-historikken
Personlig Kun brukeren selv Privat skisse, kan deles til et Tema senere

3. Sanntidssynkronisering

  • SpacetimeDB synkroniserer strøk (penseltype, farge, koordinater) mellom alle deltakere i sanntid.
  • Hvert whiteboard har en unik ID og er en node i grafen.
  • Tilgangskontroll følger konteksten: møterom-deltakere, tema-medlemmer, eller kun eieren for personlige tavler.

4. Funksjonalitet

  • Tegneverktøy: Frihåndstegning, rette linjer, rektangler, ellipser, tekst, piler.
  • Interaksjon: Fargevelger, strektykkelse, viskelær, angre/gjør om.
  • Implementering: HTML Canvas eller SVG i SvelteKit. Vurder et lett bibliotek (f.eks. tldraw, Excalidraw) hvis frihåndskvaliteten krever det — men foretrekk egenutviklet for å holde avhengigheter nede.

5. Lagring og Eksport

  • Sanntidsdata (SpacetimeDB): Strøk-historikk holdes i minnet så lenge tavlen er aktiv.
  • Eksport (PostgreSQL + filsystem): Når tavlen "lukkes" eller deles, rendres den til PNG eller SVG og lagres som en media_file. Referansen knyttes til konteksten (melding, møte) via message_attachments eller tilsvarende.
  • Dataklassifisering: Strøk-data i SpacetimeDB er flyktig (kategori 4). Eksporterte bilder er avledet (kategori 3) — kan gjenskapes fra strøk-data så lenge tavlen er aktiv, men etter lukking er bildet den permanente kopien.

6. Instruks for Claude Code

  • Whiteboard-komponenten skal være en gjenbrukbar Svelte-komponent som kan mountes i møterom, chat og som frittstående side.
  • SpacetimeDB-tabellen for strøk bør være enkel: whiteboard_id, stroke_data (JSON), user_id, timestamp.
  • Ikke bygg et fullverdig tegneprogram — start med frihåndstegning, viskelær og farger. Utvid ved behov.
  • Tilgang til whiteboards styres via node_access-matrisen.

================================================================ FILE: docs/infra/ai_gateway.md

Infrastruktur: AI Gateway (LiteLLM)

Filsti: docs/infra/ai_gateway.md

1. Konsept

Synops bruker en sentralisert AI Gateway (LiteLLM) som eneste kontaktpunkt for alle AI-kall i systemet. All kode — Rust-workers, SvelteKit server-side — snakker med http://ai-gateway:4000/v1. Aldri direkte til leverandør-APIer.

Fordeler:

  • BYOK (Bring Your Own Key): Direkte API-nøkler til Anthropic, Google, xAI — ingen markup
  • OpenRouter som fallback: Tilgang til alle modeller vi ikke har direkte nøkler til, og sikkerhetsventil ved nedetid
  • Kostnadskontroll: Rutineoppgaver rutes til gratisnivå (Gemini), dyre modeller kun når det trengs
  • Sentralisert logging: Token-bruk per funksjon (Podcastfabrikken, Editor AI-behandling, Live-assistent) på ett sted
  • Redundans: Automatisk failover mellom leverandører — redaksjonen merker ikke nedetid

2. Leverandører og bruksmønster

Leverandør Nøkkeltype Primært bruksområde
Google Gemini BYOK (gratisnivå) Rutineoppgaver: transkripsjonsvasking, research-oppsummering, metadata-uttrekk
Anthropic (Claude) BYOK Oppgaver som krever høy resonneringsevne: live-assistent faktoid-vurdering, kompleks analyse
xAI (Grok) BYOK Alternativ for analyse, sanntidssøk (når tilgjengelig)
OpenRouter BYOK Fallback for alle modeller, sikkerhetsventil ved leverandør-nedetid

Merk: Kvaliteten på norsk tekst varierer mellom modeller. Test alltid med norsk innhold før en modell tildeles en produksjonsoppgave.

3. Modellruting

3.1 Arkitekturprinsipp: PG eier config, LiteLLM er stateløs

PostgreSQL er single source of truth for all modellkonfigurasjon. LiteLLM er en stateløs proxy som får generert config.yaml fra PG-data. Dette gir:

  • Ingen avhengighet til LiteLLM sitt admin API — de endrer API mellom versjoner
  • All konfig i samme backup/migrasjon som resten av systemet
  • Enkel bytte — hvis LiteLLM erstattes, er all konfig intakt i PG
  • Admin-UI i SvelteKit — gjenbruker eksisterende /admin/-mønster

3.2 Datamodell

-- Globale modellaliaser (server-nivå)
CREATE TABLE ai_model_aliases (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    alias         TEXT NOT NULL,           -- 'sidelinja/rutine', 'sidelinja/resonering'
    description   TEXT,                    -- 'Billig, høyt volum'
    is_active     BOOLEAN NOT NULL DEFAULT true,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(alias)
);

-- Leverandør-modeller med prioritert fallback per alias
CREATE TABLE ai_model_providers (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    alias_id      UUID NOT NULL REFERENCES ai_model_aliases(id) ON DELETE CASCADE,
    provider      TEXT NOT NULL,           -- 'gemini', 'openrouter', 'anthropic'
    model         TEXT NOT NULL,           -- 'gemini/gemini-2.5-flash', 'openrouter/anthropic/claude-sonnet-4'
    api_key_env   TEXT NOT NULL,           -- 'GEMINI_API_KEY', 'OPENROUTER_API_KEY'
    priority      SMALLINT NOT NULL,       -- lavere = prøves først
    is_active     BOOLEAN NOT NULL DEFAULT true,
    UNIQUE(alias_id, model)
);

-- Jobbtype → modellalias mapping
CREATE TABLE ai_job_routing (
    job_type      TEXT PRIMARY KEY,        -- 'ai_text_process', 'whisper_postprocess', etc.
    alias         TEXT NOT NULL,           -- 'sidelinja/rutine'
    description   TEXT
);

3.3 Config-generering

SvelteKit-serveren genererer config.yaml fra PG ved oppstart og ved endringer i admin-panelet:

  1. Les aktive aliaser og deres providers (sortert etter priority)
  2. Skriv config.yaml til volum delt med LiteLLM-containeren
  3. Restart LiteLLM (docker restart ai-gateway) eller send SIGHUP

Generert config inkluderer alltid router_settings og general_settings fra faste verdier — kun model_list er dynamisk.

3.4 Jobbkø-styrt modellvalg

Jobbkøen bruker ai_job_routing for å bestemme modellalias per jobbtype:

Jobbtype Standard alias Begrunnelse
ai_text_process (-behandling) sidelinja/rutine Tekstvasking, høyt volum
whisper_postprocess sidelinja/rutine Transkripsjonsvasking, høyt volum
research_clip sidelinja/rutine Research-oppsummering, høyt volum
live_factoid_eval sidelinja/resonering Krever presis vurdering under tidspress

Modellalias lagres som felt på jobben i PG — kan overstyres manuelt per jobb ved behov.

3.5 Admin-panel (/admin/ai)

Admin-panelet lar administrator:

  • Se og redigere modellaliaser og deres fallback-liste (drag-and-drop prioritering)
  • Aktivere/deaktivere individuelle leverandør-modeller
  • Endre jobbtype → alias mapping
  • Se live-status: hvilke leverandører som svarer, responstider
  • Trigge config-regenerering og LiteLLM-restart

4. Docker-oppsett

# docker-compose.dev.yml / docker-compose.yml
ai-gateway:
  image: ghcr.io/berriai/litellm:main-stable
  restart: unless-stopped
  command: --config /etc/litellm/config.yaml
  environment:
    LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
    GEMINI_API_KEY: ${GEMINI_API_KEY}
    ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
    XAI_API_KEY: ${XAI_API_KEY}
    OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
  volumes:
    - ./config/litellm/config.yaml:/etc/litellm/config.yaml:ro
  ports:
    - "127.0.0.1:4000:4000"  # kun localhost (dev), ingen port i prod
  networks:
    - sidelinja-dev  # eller sidelinja-net i prod

5. Prompt-kvalitetssikring (Promptfoo)

Alle LLM-prompts i Sidelinja testes systematisk med Promptfoo før de brukes i produksjon. Dette er spesielt viktig fordi vi jobber med norsk tekst, der modellkvaliteten varierer kraftig mellom leverandører.

5.1 Hva vi tester

Hver jobbtype som bruker LLM har et tilhørende testsett:

Jobbtype Testsett Eksempler på assertions
whisper_postprocess Norske transkripsjoner med kjente feil Egennavn korrigert, setningsflyt bevart
openrouter_analyze Episoder med kjent metadata Riktig tittel, kapitler matcher innhold
research_clip Nyhetsartikler med kjente aktører/fakta Aktører identifisert, faktoider korrekte
live_factoid_eval Transkripsjons-chunks med kjente entiteter Riktig entity-match, lav falsk-positiv-rate

5.2 Hva vi sammenligner

Promptfoo kjøres mot alle kandidatmodeller via AI Gateway:

# promptfoo-config.yaml
providers:
  - id: "openai:chat:sidelinja/rutine"
    config:
      apiBaseUrl: "http://localhost:4000/v1"
      apiKey: "${LITELLM_MASTER_KEY}"
  - id: "openai:chat:sidelinja/resonering"
    config:
      apiBaseUrl: "http://localhost:4000/v1"
      apiKey: "${LITELLM_MASTER_KEY}"

Dette lar oss svare på:

  • Klarer Gemini (gratis) denne oppgaven like bra som Claude (betalt)?
  • Fungerer prompten på norsk, eller trenger vi en annen formulering?
  • Har en modelloppgradering hos leverandøren degradert kvaliteten?

5.3 Når vi kjører tester

  • Ved ny prompt: Før den tas i bruk i produksjon
  • Ved modellbytte: Før en leverandør/modell settes som primær for en jobbtype
  • Periodisk (CI): Månedlig cron-jobb i Forgejo Actions kjører promptfoo eval mot alle testsett. Resultater postes som issue ved regresjoner. Leverandører oppdaterer modeller uten varsel — automatisk regresjonssjekk fanger dette opp.
  • Ved kvalitetsklager: Når redaksjonen rapporterer dårlig output

5.4 Lagring av testsett

Testsett og promptfoo-config versjonskontrolleres i Git under tests/prompts/. Testdata er norske eksempler fra faktiske episoder og artikler.

tests/prompts/
├── promptfooconfig.yaml
├── whisper_postprocess/
│   ├── prompt.txt
│   └── dataset.json
├── metadata_extract/
│   ├── prompt.txt
│   └── dataset.json
└── research_clip/
    ├── prompt.txt
    └── dataset.json

6. Tokenregnskap og kostnadskontroll

6.1 Token-logging per samlings-node

Rust-workeren logger tokenforbruk etter hvert AI-kall. Dataen lagres i PG:

CREATE TABLE ai_usage_log (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    collection_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    job_id          UUID REFERENCES job_queue(id) ON DELETE SET NULL,
    model_alias     TEXT NOT NULL,          -- 'sidelinja/rutine'
    model_actual    TEXT,                   -- 'gemini/gemini-2.5-flash' (fra LiteLLM-respons)
    prompt_tokens   INT NOT NULL,
    completion_tokens INT NOT NULL,
    total_tokens    INT NOT NULL,
    estimated_cost  NUMERIC(10, 6),         -- USD, beregnet fra kjente priser
    job_type        TEXT,                   -- 'ai_text_process', etc.
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_ai_usage_collection_month ON ai_usage_log (collection_node_id, created_at);

Flyten:

  1. Rust-worker sender AI-kall via gateway, får tilbake usage i responsen
  2. Worker skriver rad til ai_usage_log med collection_node_id, tokens og modellinfo
  3. Estimert kostnad beregnes fra en enkel prisliste i config (oppdateres manuelt)

6.2 Visning -- to nivåer

Admin (/admin/ai): Aggregert oversikt over alle samlings-noder. Tabell med totaler per samlings-node/modell/periode. Identifiserer kostnadsdrivere.

Samlings-node (sidebar-widget): Enkel tekst-indikator i sidebar: 12.4k tokens denne uken. Klikk åpner detaljert visning med fordeling per jobbtype og modell. Ingen speedometer -- det krever et definert budsjett for å gi mening, og det er overkill for MVP.

6.3 Budsjett per samlings-node (fase 2)

Når token-logging er på plass, kan budsjett-tak legges til:

  • Budsjett lagres som JSONB-metadata på samlings-noden: { "ai_budget": { "monthly_limit_usd": 50 } }
  • Rust-worker sjekker aggregert forbruk før AI-kall
  • Ved budsjett nær: fall tilbake til sidelinja/rutine (billigste)
  • Ved budsjett nådd: sett jobb i paused med varsel i samlings-nodens chat

6.4 Per-episode maks-kostnad

Podcastfabrikken-jobber (whisper + metadata + oppsummering) kan estimere totalkostnad basert på lydlengde. Jobben avbrytes med varsel hvis estimert kostnad overstiger max_cost_per_episode (default: $5).

7. Dataklassifisering (ref. docs/arkitektur.md 2.2)

Data Kategori Detaljer
LiteLLM config.yaml Gjenskapbar (Git) Versjonskontrollert
API-nøkler Kritisk (.env) Aldri i Git
Token-bruk-logger Flyktig (TTL 90 dager) For kostnadsoversikt, ryddes automatisk
Promptfoo testsett Gjenskapbar (Git) tests/prompts/ — versjonskontrollert
Promptfoo testresultater Flyktig (lokal) Kjøres on-demand, ikke lagret permanent

8. Kildevern-modus (proposal)

For sensitive redaksjonelle diskusjoner kan en lokal LLM-leverandør (Ollama/vLLM) registreres som sidelinja/lokal i config. Channels/møter med kildevern: true ruter all AI-prosessering til denne modellen — data forlater aldri serveren. Se docs/proposals/kildevern_modus.md.

9. Instruks for Claude Code

  • All AI-kode skal peke på http://ai-gateway:4000/v1 — aldri direkte til leverandør
  • Bruk modellaliaser (sidelinja/rutine, sidelinja/resonering) — aldri hardkod leverandør-spesifikke modellnavn i applikasjonskode
  • API-nøkler i .env, aldri i config-filer eller kode
  • Test alltid med norsk innhold før en ny modell/leverandør tas i bruk for en produksjonsoppgave
  • Kjør promptfoo eval før du endrer prompts eller bytter modell for en jobbtype
  • Nye jobbtyper som bruker LLM skal ha et tilhørende testsett i tests/prompts/ før de merges

================================================================ FILE: docs/infra/api_grensesnitt.md

Infrastruktur: API-grensesnitt og Tjenesteansvar

Filsti: docs/infra/api_grensesnitt.md

1. Konsept

Definerer hvordan SvelteKit-frontenden kommuniserer med backend-tjenestene. Prinsippet er: SvelteKit er web-serveren, Rust er workeren. Ingen separat Rust HTTP API.

2. Kommunikasjonskart

┌─────────────────────────────────────────────────────────────┐
│  Brukerens nettleser (SvelteKit klient)                     │
└──────────┬──────────────────────┬───────────────────────────┘
           │                      │
           │ WebSocket            │ HTTP (forms, fetch)
           ▼                      ▼
┌──────────────────┐   ┌─────────────────────────────────────┐
│  SpacetimeDB     │   │  SvelteKit Server                   │
│                  │   │  (load functions, form actions,      │
│  - Chat          │   │   API routes)                        │
│  - Kanban        │   │                                      │
│  - Live events   │   │  Ansvar:                             │
│  - Autocomplete  │   │  - Les/skriv PostgreSQL direkte      │
│  - Studio-       │   │  - Opprett jobber i job_queue        │
│    markører      │   │  - Filopplasting (streaming)         │
│                  │   │  - RSS-generering                    │
│                  │   │  - Kunnskapsgraf-spørringer          │
└──────────────────┘   └──────────────┬───────────────────────┘
                                      │
                                      │ SQL
                                      ▼
                        ┌──────────────────────────┐
                        │  PostgreSQL               │
                        │                           │
                        │  - Kunnskapsgraf          │
                        │  - Episodemetadata        │
                        │  - Statistikk             │
                        │  - Jobbkø (job_queue)     │
                        │  - Brukerdata             │
                        └──────────────┬────────────┘
                                       │
                                       │ Poll (SELECT FOR UPDATE)
                                       ▼
                        ┌──────────────────────────┐
                        │  Rust Workers             │
                        │                           │
                        │  - whisper_transcribe     │
                        │  - openrouter_analyze     │
                        │  - research_clip          │
                        │  - stats_parse            │
                        │  - sync_to_pg (SpaceDB→PG)│
                        └──────────────────────────┘

3. Ansvarsfordeling

Komponent Rolle Snakker med
SvelteKit (klient) UI, brukerinteraksjon SpacetimeDB (WS), SvelteKit server (HTTP)
SvelteKit (server) Web-API, PG-tilgang, jobb-trigger PostgreSQL (SQL)
SpacetimeDB Sanntids state, push til klienter Klienter (WS), sync-worker (intern)
Rust Workers Tunge bakgrunnsjobber, synk PostgreSQL (SQL), SpacetimeDB, OpenRouter, faster-whisper

4. Viktige avklaringer

  • Rust er ikke en API-server. Rust kjører kun som workers/prosessorer som poller jobbkøen
  • SvelteKit server-side er trygt. Load functions og form actions kjører på serveren og kan snakke direkte med PG uten sikkerhetsproblemer
  • Filopplasting håndteres av SvelteKit (streaming for store filer), som lagrer filen på disk og oppretter en jobb i køen
  • SpacetimeDB nås aldri via SvelteKit server — kun direkte fra klienten via WebSocket

5. Instruks for Claude Code

  • Ikke opprett et separat Rust HTTP API/webserver-prosjekt
  • Bruk SvelteKit +server.ts (API routes) eller +page.server.ts (form actions/load) for all HTTP-kommunikasjon
  • Rust-kode skal struktureres som worker-binærer som konsumerer fra job_queue
  • For PG-tilgang i SvelteKit, bruk et bibliotek som postgres.js eller drizzle-orm

================================================================ FILE: docs/infra/jobbkø.md

Infrastruktur: Jobbkø (PostgreSQL-basert)

Filsti: docs/infra/jobbkø.md

1. Konsept

Et felles, sentralisert køsystem for alle asynkrone bakgrunnsjobber i Sidelinja. Bygget som en enkel tabell i PostgreSQL med Rust-workers som konsumerer jobber. Ingen ekstern message broker — PostgreSQL er køen.

2. Hvorfor PostgreSQL?

  • Allerede i stacken, ingen ny infrastruktur å drifte
  • Transaksjonell garanti: jobben og resultatet kan committes sammen med dataendringer
  • Lavt volum (titalls jobber/time) gjør polling neglisjerbart
  • Enkel feilsøking via SQL (SELECT * FROM job_queue WHERE status = 'error')
  • SELECT ... FOR UPDATE SKIP LOCKED gir trygg concurrent polling uten låsekonflikt

3. Datastruktur

CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'error', 'retry');

CREATE TABLE job_queue (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    collection_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,  -- Samlings-node jobben tilhører
    job_type      TEXT NOT NULL,          -- 'whisper_transcribe', 'openrouter_analyze', 'stats_parse', 'research_clip'
    payload       JSONB NOT NULL,         -- Inputdata (filsti, tekst, tema_id, etc.)
    status        job_status NOT NULL DEFAULT 'pending',
    priority      SMALLINT NOT NULL DEFAULT 0,  -- Høyere = viktigere
    result        JSONB,                  -- Resultatet ved fullført jobb
    error_msg     TEXT,                   -- Feilmelding ved error
    attempts      SMALLINT NOT NULL DEFAULT 0,
    max_attempts  SMALLINT NOT NULL DEFAULT 3,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    started_at    TIMESTAMPTZ,
    completed_at  TIMESTAMPTZ,
    scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now()  -- For utsatte jobber / retry med backoff
);

CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for ASC)
    WHERE status IN ('pending', 'retry');

4. Worker-arkitektur (Rust)

4.1 Designprinsipp: Orkestrator, ikke prosesseringsmotor

Workeren gjør lite tung prosessering selv. Den er en orkestrator som koordinerer eksterne tjenester:

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

Ny jobbtype = ny handler-funksjon (bygg request, håndter respons, feilhåndtering). Tynt glue-code. Rekompilering er triviell og inkrementell.

4.2 Én worker, prioritetsstyrt

Én enkelt worker-prosess håndterer alle jobbtyper. Prioritering skjer via priority-kolonnen i køen — SQL-spørringen plukker alltid viktigste jobb først. Ingen behov for separate prosesser per jobbtype.

┌──────────────────────────────────────────────────┐
│  Rust Worker (sidelinja-worker)                  │
│                                                  │
│  Konfigurasjon:                                  │
│    --max-concurrent 3  (samtidige jobber)        │
│    --poll-interval 1s                            │
│                                                  │
│  Loop (per ledig slot):                          │
│    1. SELECT ... FOR UPDATE SKIP LOCKED          │
│       WHERE status IN ('pending','retry')        │
│       AND scheduled_for <= now()                 │
│       ORDER BY priority DESC, scheduled_for      │
│       LIMIT 1                                    │
│                                                  │
│    2. UPDATE status = 'running'                  │
│    3. Dispatch til handler basert på job_type    │
│    4a. OK:    UPDATE status = 'completed'        │
│    4b. Feil:  attempts += 1                      │
│               Hvis attempts < max_attempts:      │
│                 status = 'retry'                 │
│                 scheduled_for = now()            │
│                   + backoff(attempts)            │
│               Ellers: status = 'error'           │
│                                                  │
└──────────────────────────────────────────────────┘

4.3 Prioritetsmodell

Prioritet Kategori Eksempler
10 Brukerrettet / sanntid dictation_cleanup, research_clip
5 Normal whisper_transcribe, openrouter_analyze, srt_parse
1 Bakgrunn stats_parse, generate_embeddings, prompt_eval

Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på kontekst. En manuelt trigget transkripsjon kan få høyere prioritet enn en automatisk nattjobb.

4.4 Ressursstyring

  • Concurrency: --max-concurrent begrenser antall samtidige jobber. Default 3 — passer for 8 vCPU der noen slots er Whisper (CPU-tung) og resten er HTTP-kall (ventetid).
  • Resource Governor (Whisper): Når et LiveKit-rom er aktivt, reduserer workeren Whisper-tråder (--threads 2 i HTTP-kall til faster-whisper) for å beskytte lydkvaliteten. Sjekkes via LiveKit room-status før Whisper-kall.
  • Skalering senere: To nivåer:
    1. Worker-splitting: Workeren splittes til to binærer fra samme crate (worker-heavy, worker-light) via CLI-argument (--types whisper_transcribe,openrouter_analyze). Ingen kodeendring nødvendig — kun deploy-konfigurasjon.
    2. Compute-separasjon: Flytt Rust-worker + faster-whisper til en separat Hetzner-node (evt. ARM/Ampere for pris/ytelse). LiveKit er ekstremt sensitivt for CPU-stotring — ved samtidig WebRTC og Whisper på samme maskin risikerer vi audio glitches uansett cgroups. Worker-noden poller jobbkøen i PostgreSQL over internt nettverk — arkitekturen støtter dette uten kodeendring.

Backoff-strategi: Eksponentiell: 30s × 2^(attempts-1) (30s, 60s, 120s).

5. Jobbtyper

job_type Konsument Beskrivelse
whisper_transcribe Podcastfabrikken Transkriber MP3 via faster-whisper
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
meeting_summarize Møterommet Generer møtereferat og action points fra transkripsjon
valgomat_generate_profile Valgomat Generer syntetiske kandidatprofiler fra partiprogrammer
valgomat_moderation Valgomat Semantisk deduplisering og nøytralitetsvask av brukerspørsmål
dictation_cleanup Lydmeldinger AI-opprydding av diktert transkripsjon til strukturert notat
generate_embeddings Kunnskaps-Bridge Generer vector embeddings for noder (pgvector)
prompt_eval Prompt-Laboratorium Batch-evaluering av testsett mot valgte modeller
url_ingest Web Clipper (proposal) Hent URL, oppsummer via AI, opprett research-klipp med graf-koblinger
generate_waveform Waveforms (proposal) Generer audio-peaks fra lydfil for visuell bølgeform

6. Tilgangsisolasjon

Alle jobber merkes med collection_node_id. Rust-workers kjører som superuser (bypasser RLS) og sikrer isolasjon i applikasjonskode:

  • Worker leser collection_node_id fra jobben og bruker det til å lagre resultater tilbake i riktig samlings-node
  • Per samlings-node config (AI-prompts, navnelister) hentes fra samlings-nodens JSONB-metadata
  • Feilede jobber vises kun for brukere med tilgang til samlings-noden (via node_access) i admin-visningen

7. Observabilitet

  • Jobber med status = 'error' skal være synlige i admin-visningen (SvelteKit /admin/jobs)
  • Valgfritt: SpacetimeDB-event ved statusendring slik at UI kan vise fremdrift i sanntid (f.eks. "Transkriberer... 2/3 forsøk")

8. Instruks for Claude Code

  • Én binær: sidelinja-worker. Én Rust-crate med polling-loop + handler-dispatch
  • Hver jobbtype implementeres som en handler-funksjon som registreres i en HashMap<String, Handler>
  • Bruk tokio med semaphore for concurrency-kontroll (--max-concurrent)
  • Aldri lagre lydfiler i payload — bruk filstier
  • Opprett alltid jobber med riktig collection_node_id — hent fra konteksten (innlogget bruker, webhook, etc.)
  • Ved stats_parse: denne erstatter den frittstående cronjobben beskrevet i podcast_statistikk.md — bruk jobbkøen med scheduled_for for periodisk kjøring
  • Splitt til flere binærer kun hvis det blir eksplisitt bedt om — start med én

================================================================ FILE: docs/infra/synkronisering.md

Synkronisering: SpacetimeDB ↔ PostgreSQL

Konsept

SpacetimeDB holder hele grafen (alle noder og edges) i minne. PostgreSQL er persistent backup og arkiv. Skriving går til begge. Lesing går fra SpacetimeDB.

GUI (SvelteKit)
  │ skriv                    │ les (sanntid)
  ▼                          ▼
Maskinrommet (Rust)          SpacetimeDB ──→ GUI
  │                            ▲
  ├──→ PostgreSQL (persistent) │
  └──→ SpacetimeDB ───────────┘

Hvorfor hele grafen i SpacetimeDB

Node- og edge-skjemaet er minimalt: åtte kolonner på nodes, åtte på edges. For en liten brukerbase er hele grafen triviell å holde i minne. Dette forenkler alt:

  • Ingen sync-logikk for å bestemme hva som er "aktivt"
  • Ingen henting fra PG når noen åpner noe gammelt
  • Frontend har alltid alt tilgjengelig via WebSocket
  • Én lesekilde — ingen tvetydighet

Når dette blir problematisk (hundretusenvis av noder, minnepress), innføres eviction basert på aksessmønstre. Men det er en optimaliseringsbeslutning, ikke en arkitekturbeslutning. Modellen endres ikke.

Skrivestien

Maskinrommet validerer, så skriver i to steg:

  1. Validering — tilgangssjekk, unique-constraints, forretningsregler
  2. SpacetimeDB først — frontend oppdateres umiddelbart (~10μs)
  3. PG asynkront — persistent backup i bakgrunnen

Frontend er allerede oppdatert mens PG-skrivingen skjer. Brukeren merker ingenting.

Hvis PG-skrivingen feiler: maskinrommet logger og prøver igjen. SpacetimeDB har dataen — den er bare ikke persistert ennå. Ingen datatap så lenge SpacetimeDB kjører.

Lesestien

Frontend leser kun fra SpacetimeDB via WebSocket-subscriptions. Ingen PG-kall fra frontend for data som vises i sanntid.

Unntak: tunge spørringer som ikke passer sanntidslaget — statistikk, fulltekstsøk, pgvector-søk, AGE-traverseringer. Disse går GUI → Maskinrommet → PG.

Oppvarming (PG → SpacetimeDB)

Ved oppstart av SpacetimeDB laster maskinrommet hele grafen fra PG:

  1. Alle noder
  2. Alle edges
  3. node_access-matrisen

Rekkefølge: noder først (edges refererer til noder). CAS-noder lastes uten binærinnhold (bare metadata).

Feilhåndtering

Scenario Konsekvens Håndtering
SpacetimeDB krasjer Sanntid forsvinner, upersistert data kan tapes Restart + oppvarming fra PG. Tap begrenset til skrivinger som ikke rakk PG.
PG nede Persistering stopper SpacetimeDB serverer lesing og mottar skrivinger. PG-backlog tas igjen ved recovery.
Maskinrommet krasjer Ingen skriving Frontend ser siste state. Restart plukker opp.

SpacetimeDB som utbyttbar

SpacetimeDB er en sanntidscache, ikke en avhengighet. Hvis den fjernes fra stacken:

  • Sanntid: PG LISTEN/NOTIFY → SvelteKit SSE
  • Skriving: Maskinrommet → PG direkte
  • Lesing: Maskinrommet → PG → GUI

Denne fallbacken trenger ikke implementeres, men arkitekturen skal aldri gjøre den umulig.

Tilgangsmatrise i SpacetimeDB

node_access-matrisen lastes inn i SpacetimeDB ved oppvarming. Når maskinrommet oppdaterer matrisen i PG (ved edge-endring), oppdaterer den også SpacetimeDB. SpacetimeDB-modulen bruker matrisen for å filtrere subscriptions — klienter ser kun noder de har tilgang til.

Konflikthåndtering

SpacetimeDB er single-threaded per modul — reducer-funksjoner serialiseres automatisk. Ingen klassiske race conditions.

Samtidig redigering (to brukere endrer samme node): maskinrommet serialiserer via SpacetimeDB. Last-write-wins. SpacetimeDB kringkaster resultatet til alle klienter. PG oppdateres asynkront med det endelige resultatet.

================================================================ FILE: docs/setup/lokal.md

Oppsett: Lokalt Utviklingsmiljø (WSL2)

Filsti: docs/setup/lokal.md

Det lokale miljøet er et utviklingsmiljø for kode. Frontend (SvelteKit) kjøres lokalt med HMR, Rust bygges lokalt. Alle tjenester (PG, SpacetimeDB, AI Gateway, etc.) kjører på produksjonsserveren — ingen lokal Docker-replika.

Hva som gjøres hvor

Aktivitet Hvor Hvorfor
Skrive/teste kode (Rust, SvelteKit, TypeScript) Lokalt Rask iterasjon, HMR
PG-skjema og migrasjoner Mot server-PG Én sannhetskilde
Whisper/AI-eksperimentering Via AI Gateway på server Felles tjeneste
Docker-compose endringer Direkte på server Serveren er dev-miljø
Caddy/Authentik/Forgejo config Direkte på server Avhenger av domener, sertifikater, SSO

0. Forutsetninger

  • Windows 11 med WSL2 (Ubuntu 24.04 LTS)
  • Node.js 20+ (via nvm i WSL2)
  • Rust toolchain (via rustup i WSL2)
  • SSH-nøkkel konfigurert mot serveren og Forgejo (~/.ssh/id_ed25519)

1. Installer verktøy i WSL2

# Node.js via nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
nvm use 20

# Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env

2. Klon prosjektet

cd ~
git clone ssh://git@git.sidelinja.org:222/vegard/synops.git
cd synops

3. Miljøvariabler (.env.local)

cp .env.example .env.local
# Fyll inn API-nøkler (Gemini, xAI, etc.)

.env.local inneholder kun lokale variabler (API-nøkler for dev, Authentik-innstillinger). Tjenestekonfigurasjon lever på serveren.

4. Utviklingsflyt

# SvelteKit med HMR
cd web && npm run dev

# Rust maskinrom
cd rust && cargo run

5. Deploy

# 1. Commit og push
git push forgejo main

# 2. Deploy til prod (krever eksplisitt godkjenning)
ssh vegard@157.180.81.26 "cd /srv/synops && git pull && docker compose up -d --build"

6. Forskjeller fra produksjon (bevisste)

Aspekt Lokalt Produksjon
SvelteKit npm run dev (HMR) Docker container
Rust cargo run Docker container
Database/tjenester Kobler til server Kjører lokalt i Docker
HTTPS Ikke nødvendig Let's Encrypt via Caddy
Auth Authentik bypass eller dev-token Full Authentik OIDC

================================================================ FILE: docs/setup/migration_safety.md

Migration Safety Checklist

Merk: Denne sjekklisten er skrevet for v1-arkitekturen der RLS var basert på workspace_id-kolonner og SET app.current_workspace_id. Workspace-modellen er erstattet av en node-basert tilgangsmatrise (se docs/retninger/bruker_ikke_workspace.md). Sjekklisten må skrives om for det nye mønsteret: node_access-matrise, edge-basert tilgang, og RLS-policies som opererer på bruker→node-edges i stedet for workspace-scope.

Seksjonene under er bevart som referanse for v1-mønsteret.

Sjekkliste for alle som kjører PostgreSQL-migrasjoner — lokalt eller i prod.

Før migrering

  • Les migrasjonfilen og forstå hva den gjør
  • Har migrasjonen en tilhørende down-migrering? (påkrevd for skjema-endringer)
  • Tar migrasjonen backup-hensyn? (dropper den kolonner/tabeller med data?)

Etter migrering

RLS-verifisering (KRITISK)

Etter enhver migrering som oppretter eller endrer tabeller med workspace_id:

-- 1. Verifiser at RLS er aktivert på alle workspace-tabeller
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
  AND tablename IN ('nodes', 'graph_edges', 'messages', 'channels',
                     'media_files', 'job_queue', 'message_attachments')
ORDER BY tablename;
-- Forventet: rowsecurity = true for alle

-- 2. Verifiser at policies eksisterer
SELECT tablename, policyname, cmd, qual
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename;
-- Forventet: workspace_isolation_* policy for hver tabell

-- 3. Test isolasjon: sett workspace A, forsøk å lese workspace B
SET app.current_workspace_id = '<workspace_a_uuid>';
SELECT count(*) FROM nodes WHERE workspace_id = '<workspace_b_uuid>';
-- Forventet: 0 rader (RLS blokkerer)

-- 4. Verifiser at superuser IKKE blokkeres (Rust workers trenger dette)
RESET app.current_workspace_id;
SET ROLE postgres;
SELECT count(*) FROM nodes;
-- Forventet: alle rader synlige

Indeksverifisering

-- Sjekk at viktige indekser finnes
SELECT indexname, tablename
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename IN ('nodes', 'graph_edges', 'messages')
ORDER BY tablename;

Constraint-verifisering

-- Sjekk at foreign keys er intakte
SELECT tc.table_name, tc.constraint_name, tc.constraint_type
FROM information_schema.table_constraints tc
WHERE tc.table_schema = 'public'
  AND tc.constraint_type IN ('FOREIGN KEY', 'CHECK', 'UNIQUE')
ORDER BY tc.table_name;

RLS Leak Hunter (CI-test)

SET app.current_workspace_id er en skjult single point of failure — en glemt SET i en ny feature, en feil i connection-pool, eller en ny tjeneste som kobler til PG uten middleware kan føre til cross-workspace datalekkasje. Denne testen fanger det opp.

Automatisk CI-test (to-workspace leak detection)

Kjøres i migrasjonstester og som egen CI-steg:

-- Opprett to test-workspaces
INSERT INTO workspaces (id, name, slug) VALUES
  ('aaaaaaaa-0000-0000-0000-000000000001', 'Workspace A', 'ws-a'),
  ('aaaaaaaa-0000-0000-0000-000000000002', 'Workspace B', 'ws-b');

-- Seed testdata i begge
INSERT INTO nodes (id, node_type, workspace_id) VALUES
  ('bbbbbbbb-0000-0000-0000-000000000001', 'tema', 'aaaaaaaa-0000-0000-0000-000000000001'),
  ('bbbbbbbb-0000-0000-0000-000000000002', 'tema', 'aaaaaaaa-0000-0000-0000-000000000002');

-- TEST 1: Sett workspace A, forsøk å lese workspace B
SET app.current_workspace_id = 'aaaaaaaa-0000-0000-0000-000000000001';
DO $$
BEGIN
  IF (SELECT count(*) FROM nodes WHERE workspace_id = 'aaaaaaaa-0000-0000-0000-000000000002') > 0 THEN
    RAISE EXCEPTION 'RLS LEAK: Workspace A kan lese Workspace B sine noder!';
  END IF;
END $$;

-- TEST 2: Uten SET (tom current_setting) skal returnere 0 rader
RESET app.current_workspace_id;
DO $$
BEGIN
  -- For vanlig bruker (ikke superuser) bør dette returnere 0
  IF (SELECT count(*) FROM nodes) > 0 AND current_setting('is_superuser') = 'off' THEN
    RAISE EXCEPTION 'RLS LEAK: Uautentisert tilkobling kan lese data!';
  END IF;
END $$;

Audit-trigger (produksjon)

Valgfri trigger som logger mistenkelige queries i prod:

-- Tabell for RLS-audit
CREATE TABLE IF NOT EXISTS rls_audit_log (
    id BIGSERIAL PRIMARY KEY,
    table_name TEXT NOT NULL,
    operation TEXT NOT NULL,
    current_workspace TEXT,
    session_user TEXT NOT NULL,
    query_timestamp TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Funksjon som logger når current_workspace_id ikke er satt
CREATE OR REPLACE FUNCTION audit_rls_context() RETURNS TRIGGER AS $$
BEGIN
  IF current_setting('app.current_workspace_id', true) IS NULL
     OR current_setting('app.current_workspace_id', true) = '' THEN
    IF current_setting('is_superuser') = 'off' THEN
      INSERT INTO rls_audit_log (table_name, operation, current_workspace, session_user)
      VALUES (TG_TABLE_NAME, TG_OP, current_setting('app.current_workspace_id', true), session_user);
    END IF;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Kjør leak hunter mot ALLE tabeller med workspace_id — ikke bare de som er listet over. Nye tabeller legges til i listen automatisk via introspeksjon:

-- Finn alle tabeller med workspace_id-kolonne (bør alle ha RLS)
SELECT t.tablename
FROM pg_tables t
JOIN information_schema.columns c ON c.table_name = t.tablename
WHERE c.column_name = 'workspace_id'
  AND t.schemaname = 'public'
  AND NOT EXISTS (
    SELECT 1 FROM pg_policies p WHERE p.tablename = t.tablename
  );
-- Forventet: 0 rader. Enhver rad her = tabell med workspace_id UTEN RLS-policy.

Automatisering

Disse sjekkene kjøres automatisk i migrasjonstestene (se docs/arkitektur.md §10.2). Manuell kjøring er kun nødvendig ved prod-migrasjoner til automatiserte tester er på plass. RLS Leak Hunter bør prioriteres som første CI-steg — den beskytter mot den mest alvorlige feilkategorien (cross-workspace datalekkasje).

================================================================ FILE: docs/setup/produksjon.md

Oppsett: Produksjonsserver (Hetzner VPS)

Filsti: docs/setup/produksjon.md

Denne oppskriften tar en fersk Ubuntu VPS fra null til en komplett Synops-installasjon. Hvert steg er sekvensielt — ikke hopp over noe.

0. Forutsetninger

  • Hetzner VPS med Ubuntu 24.04 LTS (8 vCPU, 16 GB RAM minimum)
  • DNS A-records som peker til VPS-ens IP:
    • sidelinja.org + *.sidelinja.org
    • vegard.info + *.vegard.info
  • SSH-tilgang med nøkkelpar (passordautentisering deaktiveres i steg 1)

1. Grunnsikring av VPS

# Oppdater systemet
apt update && apt upgrade -y

# Opprett tjenestebruker (ikke kjør alt som root)
adduser sidelinja
usermod -aG sudo sidelinja

# Kopier SSH-nøkkel til ny bruker
mkdir -p /home/sidelinja/.ssh
cp ~/.ssh/authorized_keys /home/sidelinja/.ssh/
chown -R sidelinja:sidelinja /home/sidelinja/.ssh

# Deaktiver passordautentisering og root-login
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart sshd

# Brannmur: kun SSH, HTTP, HTTPS
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Logg ut og logg inn som sidelinja fra nå av.

2. Installer Docker

# Docker Engine (offisiell repo)
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

# Kjør Docker uten sudo
sudo usermod -aG docker sidelinja
newgrp docker

3. Opprett mappestruktur

sudo mkdir -p /srv/synops/{config,data,media,logs}
sudo mkdir -p /srv/synops/config/{caddy,authentik}
sudo mkdir -p /srv/synops/data/{postgres,spacetimedb,forgejo,authentik}
sudo mkdir -p /srv/synops/media/podcast
sudo mkdir -p /srv/synops/logs/caddy
sudo chown -R sidelinja:sidelinja /srv/synops

Resultat:

/srv/synops/
├── docker-compose.yml
├── .env
├── config/
│   ├── caddy/Caddyfile
│   └── authentik/
├── data/
│   ├── postgres/
│   ├── spacetimedb/
│   ├── forgejo/
│   └── authentik/
├── media/
│   └── podcast/
└── logs/
    └── caddy/

4. Miljøvariabler (.env)

cat > /srv/synops/.env << 'EOF'
# === Domener ===
DOMAIN_SIDELINJA=sidelinja.org
DOMAIN_VEGARD=vegard.info
DOMAIN_AUTH=auth.sidelinja.org
COMPOSE_PROJECT_NAME=sidelinja

# === PostgreSQL ===
POSTGRES_USER=sidelinja
POSTGRES_PASSWORD=<generer med: openssl rand -hex 32>
POSTGRES_DB=sidelinja

# === Authentik ===
AUTHENTIK_SECRET_KEY=<generer med: openssl rand -hex 64>
AUTHENTIK_POSTGRESQL_PASSWORD=<generer med: openssl rand -hex 32>
# Authentik bruker sin egen database i samme PostgreSQL-instans
AUTHENTIK_POSTGRESQL_HOST=postgres
AUTHENTIK_POSTGRESQL_USER=authentik
AUTHENTIK_POSTGRESQL_NAME=authentik

# === Forgejo ===
FORGEJO_DB_PASSWD=<generer med: openssl rand -hex 32>

# === LiveKit ===
LIVEKIT_API_KEY=<generer>
LIVEKIT_API_SECRET=<generer med: openssl rand -hex 32>

# === OpenRouter ===
OPENROUTER_API_KEY=<fra openrouter.ai>

# === Intern ===
# Ingen porter eksponeres utenom 80/443. Alt rutes internt via Docker-nettverket.
EOF

chmod 600 /srv/synops/.env

5. Tjeneste-installasjon (rekkefølge)

Tjenestene startes i rekkefølge fordi noen avhenger av andre. Alle defineres i docker-compose.yml, men vi verifiserer hvert lag før vi går videre.

Lag A: Fundament (ingen avhengigheter mellom seg)

  1. Docker-nettverk: Opprett internt nettverk sidelinja-net
  2. PostgreSQL: Start, opprett databaser for Authentik og Forgejo, verifiser (pg_isready)
  3. Caddy: Start med Caddyfile for alle domener, verifiser at HTTPS fungerer
  4. Authentik: Start, gjennomfør initial setup via https://auth.sidelinja.org
  5. Forgejo: Start med Authentik som OAuth2-provider, opprett organisasjon og repo

Lag B: Sanntid (krever nettverk)

  1. SpacetimeDB: Start, verifiser tilkobling
  2. LiveKit: Start, verifiser at WebRTC fungerer

Lag C: Applikasjon (krever alt over)

  1. SvelteKit: Bygg og start container, verifiser at frontenden laster
  2. Rust Workers: Bygg og start container(e), verifiser at jobbkøen polles

6. docker-compose.yml (skjelett)

# Fullstendig docker-compose.yml bygges ut når tjenestene implementeres.
# Denne seksjonen dokumenterer strukturen og viktige regler.

# REGLER:
# - Ingen "ports:" mot host UTENOM Caddy (80, 443)
# - Alle tjenester på samme interne nettverk (sidelinja-net)
# - Volumer bruker bind mounts til /srv/synops/
# - .env-filen lastes automatisk av Docker Compose
# - RESSURSGRENSER: Worker-containere (Whisper) MÅ ha deploy.resources.limits
#   for å forhindre at de sultefôrer LiveKit og PostgreSQL.
#   Eksempel: workers: deploy: resources: limits: cpus: '4' memory: 8G

networks:
  sidelinja-net:
    driver: bridge

services:
  caddy:        # Eneste tjeneste med eksponerte porter (80, 443)
  postgres:     # data:/srv/synops/data/postgres
  authentik:    # SSO for alle domener, på auth.sidelinja.org
  forgejo:      # data:/srv/synops/data/forgejo, på git.sidelinja.org
  spacetimedb:  # data:/srv/synops/data/spacetimedb
  livekit:      # Intern port, proxyet via Caddy
  sveltekit:    # Intern port, proxyet via Caddy
  workers:      # Rust job workers, ingen porter

7. Caddy (Caddyfile grunnstruktur)

# === SSO (felles for alle domener) ===
auth.sidelinja.org {
    reverse_proxy authentik:9000
}

# === Sidelinja (hovedapplikasjon) ===
sidelinja.org {
    # SvelteKit (frontend + API)
    reverse_proxy sveltekit:3000

    # LiveKit (WebSocket upgrade)
    handle_path /livekit/* {
        reverse_proxy livekit:7880
    }

    # SpacetimeDB (WebSocket)
    handle_path /spacetime/* {
        reverse_proxy spacetimedb:3000
    }

    # Podcast media (statiske filer med byte-range support)
    handle_path /media/* {
        root * /srv/synops/media
        file_server
    }

    # Podcast access log (kun media-forespørsler)
    log {
        output file /srv/synops/logs/caddy/podcast_access.log
        format json
    }
}

# === Forgejo (Git) ===
git.sidelinja.org {
    reverse_proxy forgejo:3000
}

# === Vegard.info ===
vegard.info {
    # Konfigureres når innhold er klart
    respond "Under construction" 200
}

8. PostgreSQL: Initielle databaser

Ved første oppstart må det opprettes separate databaser og brukere for Authentik og Forgejo:

-- Kjøres mot PostgreSQL etter første start
-- (eller via init-script montert til /docker-entrypoint-initdb.d/)

CREATE USER authentik WITH PASSWORD '<AUTHENTIK_POSTGRESQL_PASSWORD>';
CREATE DATABASE authentik OWNER authentik;

CREATE USER forgejo WITH PASSWORD '<FORGEJO_DB_PASSWD>';
CREATE DATABASE forgejo OWNER forgejo;

9. Authentik: Initial konfigurasjon

Etter oppstart, gå til https://auth.sidelinja.org/if/flow/initial-setup/:

  1. Opprett admin-konto
  2. Opprett OAuth2/OpenID Connect-provider for Forgejo
  3. Opprett OAuth2/OpenID Connect-provider for SvelteKit (senere)
  4. Konfigurer brukergrupper etter behov (redaksjon, admin)

10. Forgejo: Koble til Authentik

Forgejo konfigureres med Authentik som OAuth2-kilde:

  • Authentication Source: OAuth2
  • Provider: OpenID Connect
  • Discovery URL: https://auth.sidelinja.org/application/o/<slug>/.well-known/openid-configuration
  • Etter oppsett: opprett organisasjon sidelinja, opprett repo sidelinja

11. Backup-strategi

Se docs/arkitektur.md seksjon 2.2 for full dataklassifisering. Kun kategori 1 (kritisk) og Forgejo-data backupes.

11.1 PostgreSQL (daglig dump, 03:00)

# pg_dump er konsistent selv under last — ingen nedetid
docker compose exec -T postgres pg_dump -U sidelinja -Fc sidelinja \
  > /srv/synops/backup/pg/sidelinja_$(date +%Y%m%d).dump

# Behold 30 dager, slett eldre
find /srv/synops/backup/pg/ -name "*.dump" -mtime +30 -delete

11.1b PostgreSQL WAL-arkivering (kontinuerlig, PITR)

Daglig dump gir opptil 24 timers datatap. WAL-arkivering muliggjør Point-In-Time Recovery til minuttet.

# Installer pgBackRest (i PostgreSQL Docker-containeren eller som sidecar)
# Alternativt: WAL-G for enklere S3-oppsett

# postgresql.conf (legg til i Docker-volumet eller via environment)
archive_mode = on
archive_command = 'pgbackrest --stanza=sidelinja archive-push %p'
wal_level = replica

# pgbackrest.conf
[sidelinja]
pg1-path=/var/lib/postgresql/data

[global]
repo1-type=s3
repo1-s3-bucket=sidelinja-backup
repo1-s3-endpoint=fsn1.your-objectstorage.com
repo1-s3-region=fsn1
repo1-path=/pgbackrest
repo1-retention-full=4
repo1-retention-diff=14

# Ukentlig full backup (søndag kl. 02:00)
# 0 2 * * 0 sidelinja pgbackrest --stanza=sidelinja --type=full backup
# Daglig differensiell (man-lør kl. 02:00)
# 0 2 * * 1-6 sidelinja pgbackrest --stanza=sidelinja --type=diff backup

# Recovery-eksempel (gjenopprett til spesifikt tidspunkt):
# pgbackrest --stanza=sidelinja --target="2026-03-15 13:59:00" \
#   --target-action=promote restore

Merk: WAL-arkivering erstatter IKKE daglig pg_dump — dumpen er en enkel, portabel backup som fungerer uavhengig av pgBackRest. WAL-arkivering er et tillegg for finkornet recovery.

11.2 Media-filer (daglig, 03:30)

# Inkrementell med rsync til lokal backup-disk eller ekstern lagring
rsync -a --delete /srv/synops/media/ /srv/synops/backup/media/

11.3 Forgejo-data (daglig, 04:00)

# Forgejo-repos kan gjenskapes, men det er tidkrevende.
# Sikkerhetsnett-backup av hele data-mappen:
rsync -a --delete /srv/synops/data/forgejo/ /srv/synops/backup/forgejo/

11.4 Hemmeligheter (.env)

# Manuell kopi ved endring — ALDRI i Git
cp /srv/synops/.env /srv/synops/backup/env_$(date +%Y%m%d)
chmod 600 /srv/synops/backup/env_*

11.5 Off-site backup (rclone → Hetzner Object Storage)

Lokal backup beskytter kun mot logiske feil. Ved fysisk nodefeil tapes alt. Kategori 1-data pushes daglig til Hetzner Object Storage via rclone.

# Installer og konfigurer rclone
curl https://rclone.org/install.sh | sudo bash
rclone config
# Opprett remote "hetzner-s3" med Hetzner Object Storage credentials
# (S3-kompatibelt, endpoint: fsn1.your-objectstorage.com eller nbg1)

# /srv/synops/scripts/backup-offsite.sh
#!/bin/bash
set -euo pipefail
BUCKET="s3:hetzner-s3/sidelinja-backup"

# PG-dump (siste lokale dump)
LATEST_DUMP=$(ls -t /srv/synops/backup/pg/*.dump 2>/dev/null | head -1)
if [ -n "$LATEST_DUMP" ]; then
  rclone copy "$LATEST_DUMP" "$BUCKET/pg/"
fi

# Media (inkrementell sync)
rclone sync /srv/synops/media/ "$BUCKET/media/" --transfers 4

# Behold 90 dager PG-dumper off-site
rclone delete "$BUCKET/pg/" --min-age 90d

echo "$(date): Off-site backup ferdig" >> /srv/synops/logs/backup-offsite.log

11.6 Cron-oppsett

# /etc/cron.d/sidelinja-backup
0  3 * * * sidelinja /srv/synops/scripts/backup-pg.sh
30 3 * * * sidelinja /srv/synops/scripts/backup-media.sh
0  4 * * * sidelinja /srv/synops/scripts/backup-forgejo.sh
30 4 * * * sidelinja /srv/synops/scripts/backup-offsite.sh

11.7 Hva som IKKE backupes (bevisst)

  • Redis — cache, regenereres automatisk
  • Caddy-data — sertifikater regenereres av Let's Encrypt
  • Avledede data i PG (ren tekst, segmenter, søkeindeks) — regenereres fra Git
  • Logger — rulleres med logrotate, arkiveres separat ved behov
  • Whisper-modeller — re-download fra HuggingFace
  • SpacetimeDB — sanntidsdata synkes til PG, in-memory state er flyktig

11.8 Restore-prosedyre

# 1. PostgreSQL
docker compose exec -T postgres pg_restore -U sidelinja -d sidelinja --clean \
  < /srv/synops/backup/pg/sidelinja_YYYYMMDD.dump

# 2. Media
rsync -a /srv/synops/backup/media/ /srv/synops/media/

# 3. Forgejo
docker compose down forgejo
rsync -a /srv/synops/backup/forgejo/ /srv/synops/data/forgejo/
docker compose up -d forgejo

# 4. Avledede data: trigges automatisk ved webhook eller manuelt
# Rust-worker reimporterer alle SRT-filer fra Git til PG

12. Deploy-workflow (etter initial setup)

Etter at serveren er satt opp, er dette den daglige deploy-flyten:

# Fra lokal maskin (WSL2):
git push forgejo main

# SSH inn til server:
ssh sidelinja@<server-ip>
cd /srv/synops
git pull
docker compose build --no-cache <tjeneste>
docker compose up -d <tjeneste>

13. Verifisering etter oppsett

Lag A (minimum fungerende server)

  • https://auth.sidelinja.org viser Authentik login
  • https://git.sidelinja.org viser Forgejo, innlogging via Authentik fungerer
  • PostgreSQL: docker compose exec postgres pg_isready returnerer OK
  • SSH-push fra lokal WSL2 til Forgejo fungerer

Lag B-C

  • https://sidelinja.org laster SvelteKit-appen (deployet 2025-03-15)
  • https://sidelinja.org/api/health returnerer 200
  • Authentik OIDC-innlogging fungerer fra nettleser (verifisert 2025-03-15)
  • Chat: meldinger sendes og vises med riktig brukernavn (verifisert 2025-03-15)
  • https://vegard.info svarer
  • SpacetimeDB: WebSocket-tilkobling fra nettleser fungerer
  • LiveKit: Test-rom med video/lyd fungerer
  • Media: curl -I https://sidelinja.org/media/podcast/test.mp3 returnerer Accept-Ranges: bytes

================================================================ FILE: docs/erfaringer/README.md

Erfaringer — Ting vi lærte av å feile

Denne mappen samler praktiske lærdommer fra implementering — ikke hva vi valgte, men hva vi lærte som ikke er åpenbart fra koden eller arkitekturdokumentene.

Formålet er å treffe raskere blink med neste komponent. Hver fil dekker én teknologi eller ett mønster og inneholder konkrete feller, anti-patterns og løsninger vi landet på.

Innhold

Fil Tema
svelte5_reaktivitet.md Svelte 5 $state, SSR, reaktivitet gjennom funksjoner
spacetimedb_integrasjon.md SDK-konvensjoner, TypeScript-bindings, BigInt, tilkobling
adapter_moenster.md Adapter/factory for PG↔SpacetimeDB, hybrid-tilnærming
authentik_oidc.md Authentik sub-claim format, @auth/sveltekit JWT-quirks

Retningslinjer

  • Kort og konkret. Maks 12 sider per fil. Fellen først, forklaring etter.
  • Bare ting som ikke er åpenbare. Ikke dokumenter at npm install installerer pakker.
  • Oppdater fremfor å legge til. Hvis en erfaring utdypes, oppdater eksisterende fil.
  • Kodereferanser. Vis til filer der mønsteret er implementert, så man kan lese koden.

================================================================ FILE: docs/erfaringer/adapter_moenster.md

Erfaring: Adapter-mønster for chat (PG ↔ SpacetimeDB)

1. Mønsteret

Et felles interface (ChatConnection) med to implementasjoner:

  • SpacetimeDB-adapter (primær) — all data fra SpacetimeDB, worker håndterer warmup + sync
  • PG-adapter (fallback) — polling hvert 3 sek, brukes kun når SpacetimeDB ikke er konfigurert

Factory-funksjon velger adapter basert på miljøvariabel (VITE_SPACETIMEDB_URL).

ChatBlock.svelte  →  createChat()  →  SpacetimeDB-adapter (primær)
                                    →  PG-adapter (fallback, readonly)

Fordeler:

  • Kan teste PG-adapter isolert uten Docker/SpacetimeDB
  • Fallback er trivielt — fjern env-variabelen
  • Komponenten vet ingenting om hvilken adapter som brukes
  • ChatConnection-interface har edit(), delete(), react() — ingen direkte PG API-kall fra komponenten

Referanse: web/src/lib/chat/ — hele mappen er organisert etter dette mønsteret.

2. Historisk anti-pattern: Lazy wrapper som byttet adapter

Vi prøvde først en "lazy wrapper" som startet med PG-adapter og byttet til SpacetimeDB-adapter når tilkoblingen var klar. Problemet:

  • En plain let activeConnection i wrapperen er ikke reaktiv i Svelte 5
  • Når wrapperen byttet adapter, forsvant meldingene — ny adapter startet med tom liste
  • Svelte-komponentene så aldri byttet fordi proxy-referansen ikke oppdaterte seg

Lærdom: Ikke bytt adapter runtime. Velg én ved oppstart.

3. Historisk anti-pattern: Hybrid-adapter (PG + SpacetimeDB samtidig)

Den andre iterasjonen brukte en hybrid-adapter som hentet historikk fra PG via REST og lyttet på SpacetimeDB for nye meldinger. Dette skapte:

  • Kompleks dedup-logikk (deletedIds Set, merge av PG- og ST-meldinger)
  • Race conditions mellom PG-polling og SpacetimeDB-callbacks
  • BigInt-konverteringer og workarounds i frontend

Løsningen: SpacetimeDB som cache foran PG. Worker gjør warmup (PG → ST) ved oppstart, frontend snakker kun med SpacetimeDB. Ingen merge-logikk nødvendig.

4. Nåværende arkitektur

SpacetimeDB er en varm cache foran PostgreSQL:

  • Worker warmup: Ved oppstart lastes meldinger + reaksjoner fra PG → SpacetimeDB per kanal
  • Frontend → SpacetimeDB: Subscription gir alle meldinger (historikk fra warmup + nye)
  • SpacetimeDB → PG: Sync-worker poller sync_outbox hvert sekund
  • PG er autoritativ — ved SpacetimeDB-restart oppvarmes fra PG

Fordeler over hybrid:

  • Ingen dedup, merge eller deletedIds
  • Frontend-koden er dramatisk enklere
  • Konsistent datamodell — alt kommer fra én kilde
  • Reaksjoner håndteres via SpacetimeDB-tabeller, ikke PG API

5. Historisk anti-pattern: "PG-lekkasje" i SpacetimeDB-adapteren

Gjentatt feil (mars 2026, minst 3 iterasjoner): Når en ny feature trenger data som SpacetimeDB-modulen ikke har (metadata, edited_at, revisjoner), er det fristende å legge til en enrichFromPg()-funksjon som henter fra PG direkte. Dette bryter hele poenget med caching-laget.

Symptomer:

  • SpacetimeDB-adapteren har fetch('/api/messages/...') kall
  • Worker skriver til PG først, SpacetimeDB er "best-effort"
  • Etter AI-vask vises ikke metadata/revisjoner fordi de bare finnes i PG
  • Race conditions mellom SpacetimeDB-oppdateringer og PG-fetch

Hvorfor det skjer: Det er raskere å lage en PG API-rute enn å utvide SpacetimeDB-modulen (Rust compile, publish, regenerer bindings). Men det skaper teknisk gjeld som akkumulerer og undergraver hele arkitekturen.

Riktig løsning når SpacetimeDB mangler et felt:

  1. Legg til feltet i SpacetimeDB Rust-modul (spacetimedb/src/lib.rs)
  2. Utvid warmup til å laste feltet fra PG
  3. Utvid sync til å persistere feltet til PG
  4. Worker skriver til SpacetimeDB via reducer
  5. Frontend leser kun fra SpacetimeDB

Aldri: Legg til fetch('/api/.../metadata') i SpacetimeDB-adapteren.

6. Anbefaling for neste komponent

Når Kanban eller Whiteboard skal bygges med SpacetimeDB:

  1. Start med PG-adapter. Få hele flyten til å fungere med REST/polling først.
  2. Lag SpacetimeDB-adapter med warmup. Worker laster data fra PG ved oppstart.
  3. Bruk samme factory-mønster. Felles interface, env-variabel for valg.
  4. Legg til warmup-config i channels.config (eller tilsvarende config-felt).
  5. Test begge adaptere uavhengig før du integrerer i UI-komponenten.
  6. Sjekk at alle felter frontend trenger finnes i SpacetimeDB-modulen før du implementerer adapteren. Utvid modulen først hvis nødvendig.

================================================================ FILE: docs/erfaringer/authentik_oidc.md

Erfaring: Authentik OIDC-integrasjon

1. profile.sub er IKKE Authentik sin PostgreSQL-UUID

Authentik sin OIDC sub-claim er en SHA256-hash, ikke UUID-kolonnen fra authentik_core_user. Eksempel:

Felt Verdi
Authentik DB uuid 0ac94e00-015b-4e78-9f32-269fa6ce3f44
OIDC sub claim 6af61f43c6647a237cbb381ee7788376a9bc20299c2c06281d9954d763e854f0

Bruk alltid sub-verdien fra OIDC som nøkkel i users.authentik_id. For å finne den riktige verdien for en bruker: logg inn og les profile.sub fra callback, eller sjekk JWT-tokenet.

2. @auth/sveltekit sin user.id er IKKE profile.sub

@auth/sveltekit genererer sin egen interne UUID for user.id i JWT. Denne overlever ikke mellom sesjoner og matcher ingenting i vår database.

For å bruke Authentik sub som bruker-ID:

callbacks: {
    jwt({ token, user, profile }) {
        if (user) token.id = user.id;
        if (profile?.sub) token.authentik_sub = profile.sub;
        return token;
    },
    session({ session, token }) {
        if (session.user) {
            // Bruk Authentik sub, IKKE token.id
            session.user.id = (token.authentik_sub ?? token.id) as string;
        }
        return session;
    }
}

profile er kun tilgjengelig i JWT-callbacken ved innlogging (ikke ved token-refresh), derfor må authentik_sub lagres i tokenet.

Referanse: web/src/lib/server/auth.ts

3. Redirect-URI i Authentik

@auth/sveltekit bruker callback-URL https://<domain>/auth/callback/<provider-id>. For oss: https://sidelinja.org/auth/callback/authentik.

Denne MÅ være registrert som redirect-URI i Authentik sin OAuth2-provider. Verifiser via:

SELECT _redirect_uris FROM authentik_providers_oauth2_oauth2provider
WHERE client_id = '<din client_id>';

Tips: Legg til en regex-variant for lokal utvikling: http://localhost:\d+/auth/callback/authentik med matching_mode: "regex".

================================================================ FILE: docs/erfaringer/spacetimedb_integrasjon.md

Erfaring: SpacetimeDB-integrasjon

1. Genererte bindings — navnekonvensjoner

SpacetimeDB sin spacetime generate --lang typescript produserer bindings med inkonsistente konvensjoner. Sjekk alltid de genererte filene i stedet for å gjette.

Hva Konvensjon Eksempel
Tabell-accessor på conn.db snake_case conn.db.chat_message
Reducer-accessor på conn.reducers camelCase conn.reducers.sendMessage()
Felt-navn i tabellrader camelCase row.channelId, row.authorName
Reducer-parametere Enkelt objekt sendMessage({ id, channelId, body, ... })

Felle: Man forventer at tabell-accessorer er camelCase (chatMessage), men de er snake_case.

2. DbConnection.builder() — API-detaljer (SDK v2)

DbConnection.builder()
  .withUri(spacetimeUrl)
  .withDatabaseName(moduleName)   // IKKE withModuleName
  .withToken(token)
  .onConnect((connection) => {    // første param er DbConnection, ikke EventContext
    connection.subscriptionBuilder()
      .subscribe([`SELECT * FROM chat_message WHERE channel_id = '${id}'`]);
  })
  .build();

Feller:

  • withModuleName() finnes ikke — bruk withDatabaseName()
  • onConnect-callback mottar DbConnection, ikke EventContext
  • onConnectError-callback har signatur (ctx, errMessage) der errMessage er en string

3. Timestamps — bruk SDK-metoder, ikke interne felter

SpacetimeDB Timestamp-objektet har en intern property __timestamp_micros_since_unix_epoch__ (BigInt). Ikke bruk den direkte — bruk SDK-metodene:

// FEIL — microsSinceEpoch finnes ikke, __timestamp_micros_since_unix_epoch__ er internt
const micros = row.createdAt?.microsSinceEpoch;

// RIKTIG — bruk SDK-metoder
const iso = row.createdAt?.toISOString();       // "2026-03-15T23:57:11.677139Z"
const date = row.createdAt?.toDate();            // Date-objekt
const ms = row.createdAt?.toDate()?.getTime();   // millisekunder for sortering

Timestamp-parsing i Rust-modul (warmup)

PG returnerer timestamps som "2026-03-15 23:57:11.677139+00". chrono parser IKKE +00 — krever +00:00:

// FEIL — chrono gir ParseError(TooShort) på "+00"
let dt = s.parse::<chrono::DateTime<chrono::FixedOffset>>();

// RIKTIG — normaliser PG-offset først
let normalized = if s.ends_with("+00") { format!("{}:00", s) } else { s.to_string() };
let dt = chrono::DateTime::parse_from_str(&normalized, "%Y-%m-%d %H:%M:%S%.f%:z");

Uten korrekt parsing faller load_messages tilbake til ctx.timestamp (nåtidspunkt), og alle meldinger får samme klokkeslett.

Referanse: spacetimedb/src/lib.rsparse_timestamp(), web/src/lib/chat/spacetime.svelte.tsspacetimeRowToMessage().

4. Rust-modul — borrow checker med SpacetimeDB-makroer

SpacetimeDB-makroer genererer kode som tar eierskap over struct-felter. Bruk verdier før du flytter dem inn i structs:

// FEIL — channel_id er moved inn i ChatMessage, kan ikke bruke i log!() etterpå
let msg = ChatMessage { channel_id, body, ... };
log::info!("Melding i kanal {}", channel_id);  // borrow after move

// RIKTIG — bruk verdien før struct-opprettelse
let log_msg = format!("Melding i kanal {}", channel_id);
let msg = ChatMessage { channel_id, body, ... };
log::info!("{}", log_msg);

5. Publisering og lokal testing

# Publiser modul mot lokal SpacetimeDB (må kjøre i Docker først)
cd spacetimedb
spacetime publish sidelinja-realtime --server local

# Generer TypeScript-bindings
spacetime generate --lang typescript --out-dir ../web/src/lib/chat/module_bindings \
  --module-path .

Merk: SpacetimeDB-modulen publiseres manuelt med spacetime publish mot server-instansen.

6. Arkitekturendring: SpacetimeDB som cache foran PG (mars 2026)

Tidligere: Hybrid-adapter der frontend merget data fra PG (historikk) og SpacetimeDB (sanntid) med dedup, deletedIds og BigInt-workarounds.

Ny modell:

  • PG autoritativ — all persistent data i PostgreSQL
  • SpacetimeDB = varm cache — worker gjør warmup (PG → ST) ved oppstart
  • Frontend snakker KUN med ST — ingen PG API-kall fra chat-adapteren
  • Worker håndterer toveissynk — ST → PG for nye/redigerte/slettede meldinger og reaksjoner

Warmup-flyt

  1. Worker starter → warmup::run() leser kanaler med config fra PG
  2. Per kanal: sjekker channels.config.warmup_mode (all/messages/days/none)
  3. Kaller clear_channel reducer (unngår duplikater ved restart)
  4. Trådbasert henting: Finner kvalifiserende tråder, henter alle meldinger i disse (komplett med svar)
    • messages-modus: de N nyeste trådene (sortert etter siste aktivitet)
    • days-modus: alle tråder med minst én melding i tidsvinduet
    • Et svar som kvalifiserer tar med hele tråden (inkludert eldre trådstarter)
  5. Kaller load_messages reducer med JSON-array
  6. Laster også reaksjoner via load_reactions reducer

Per-kanal konfigurasjon

  • Lagres i channels.config JSONB: warmup_mode + warmup_value
  • Admin-UI: /admin/channels — tabell med inline-redigering
  • Default: "all" (last alt). Andre: "messages" (siste N tråder), "days" (siste N dager), "none" (inaktiv)

Sync-flyt (ST → PG)

  • SyncOutbox-events prosesseres hver 1. sekund
  • Støtter: messages/insert, messages/delete, messages/update, messages/ai_update, message_reactions/insert, message_reactions/delete
  • ai_update-action: oppdaterer body + metadata + edited_at i PG, inserter revisjon

7. Subscription-begrensninger

SpacetimeDB-subscriptions støtter IKKE JOINs. En subscription-query som SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE ... feiler stille — onApplied kalles aldri, og ingen data vises.

Bruk kun enkle SELECT * FROM tabell WHERE ...-queries i .subscribe([...]). Filtrer heller klient-side etter at data er lastet.

Eksempel:

// FEIL — feiler stille, ingen data
.subscribe([
  `SELECT * FROM chat_message WHERE channel_id = '${id}'`,
  `SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE cm.channel_id = '${id}'`
]);

// RIKTIG — last alle reaksjoner, filtrer i koden
.subscribe([
  `SELECT * FROM chat_message WHERE channel_id = '${id}'`,
  `SELECT * FROM message_reaction`
]);

Fallback

PG-polling adapter (pg.svelte.ts) brukes kun når SpacetimeDB ikke er konfigurert. Markeres som readonly: true.

8. Reducer-parameternavn — unngå underscore-prefix

Kodeeksemplene i denne seksjonen er fra v1 og bruker workspace_id-parametere. Workspace-modellen er erstattet av noder og edges (se docs/retninger/bruker_ikke_workspace.md), men lærdommen om underscore-prefix gjelder generelt for alle SpacetimeDB-reducere.

SpacetimeDB eksponerer Rust-parameternavn direkte i HTTP JSON API-et. Underscore-prefix (_workspace_id) blir til _workspace_id i JSON, ikke workspace_id:

// FEIL — HTTP-kall med {"workspace_id": "..."} feiler med 400
pub fn set_ai_processing(ctx: &ReducerContext, id: String, _workspace_id: String) { ... }

// RIKTIG — bruk vanlig navn, suppress warning med let _ = &var;
pub fn set_ai_processing(ctx: &ReducerContext, id: String, workspace_id: String) -> Result<(), String> {
    let _ = &workspace_id;
    // ...
}

9. Schema-migrering ved nye kolonner

Å legge til kolonner på eksisterende SpacetimeDB-tabeller krever --delete-data ved publish. Dette sletter all data og krever warmup på nytt:

# Feiler uten --delete-data:
# "Adding a column metadata to table chat_message requires a default value annotation"
echo "y" | spacetime publish sidelinja-realtime --server local --delete-data

10. AI-worker-flyt via SpacetimeDB

Worker som gjør AI-behandling av meldinger:

  1. Leser meldingens body fra PG (OK — PG er persistent lager)
  2. Kaller set_ai_processing reducer → frontend ser pulsering umiddelbart
  3. Kaller AI Gateway med prompt
  4. Kaller ai_update_message reducer → SpacetimeDB oppdaterer body/metadata/edited_at atomisk, lagrer revisjon, legger outbox-entry
  5. Sync-worker persisterer til PG via ai_update action
  6. Ved feil: clear_ai_processing reducer rydder flagget

================================================================ FILE: docs/erfaringer/svelte5_reaktivitet.md

Erfaring: Svelte 5 Reaktivitet

1. $state i .svelte.ts krever getters

Svelte 5 sin $state lager reaktive proxyer. Når en funksjon returnerer et objekt med $state-verdier, mister man reaktiviteten hvis man returnerer verdien direkte:

// FEIL — mister reaktivitet, verdien fryses ved retur
function createThing() {
  let count = $state(0);
  return { count };  // snapshot, ikke reaktiv
}

// RIKTIG — getter bevarer proxy-tilgang
function createThing() {
  let count = $state(0);
  return {
    get count() { return count; }
  };
}

Referanse: web/src/lib/chat/pg.svelte.ts — alle returnerte verdier bruker getters.

2. SSR kjører alt utenfor onMount

SvelteKit server-renderer kjører komponent-script ved SSR. Alt som bruker browser-APIer (WebSocket, fetch til relative URLer, setInterval, sessionStorage) krasjer på serveren hvis det ikke er beskyttet.

// FEIL — krasjer ved SSR
let chat = createChat(channelId);  // kjøres på server

// RIKTIG — kun i browser
import { onMount } from 'svelte';
let chat = $state<ChatConnection | null>(null);
onMount(() => {
  chat = createChat(channelId);
  return () => chat?.destroy();
});

Alternativt kan factory-funksjonen selv sjekke:

import { browser } from '$app/environment';
if (browser) { /* ... */ }

Referanse: web/src/lib/chat/create.svelte.tsbrowser-guard i factory, web/src/lib/blocks/ChatBlock.svelteonMount for opprettelse.

3. $derived og $effect med null-initialisert state

Når en $state-variabel starter som null (fordi den settes i onMount), må $derived og $effect håndtere null-tilfellet:

let chat = $state<ChatConnection | null>(null);
let messages = $derived(chat?.messages ?? []);  // fallback til tom liste

$effect(() => {
  const count = messages.length;  // trygt, alltid array
  if (count > prevCount) scrollToBottom();
  prevCount = count;
});

Referanse: web/src/lib/blocks/ChatBlock.svelte$derived med optional chaining.

4. Polling: full-fetch slår inkrementell akkumulering

Vi prøvde først å bruke en latestTimestamp-cursor for å hente kun nye meldinger og appende dem. Dette ga duplikater — telleren vokste mens man tastet (polling i bakgrunnen).

Løsning: Enkel refresh() som alltid henter full liste og erstatter messages i sin helhet. For et lite volum meldinger er dette enklere og tryggere enn inkrementell logikk.

Referanse: web/src/lib/chat/pg.svelte.tsrefresh() gjør full fetch, ingen akkumulering.

================================================================ FILE: docs/proposals/README.md

Forslag (Proposals)

Halvtenkte idéer, kreative innfall og ting vi vil utforske når vi får tid. Ikke spesifisert, ikke forpliktet — bare parkert.

Pipeline

retninger/    →    påvirker alt (arkitektoniske teser)
proposals/    →    features/ eller concepts/
(idé)              (spesifisert, klar for implementering)

Når en idé modnes nok til å bli implementert, skrives en full spec i docs/features/ eller docs/concepts/ og forslaget slettes herfra. Idéer som er for store og fundamentale for proposals — arkitektoniske teser om prosjektets retning — hører hjemme i docs/retninger/.

Oversikt

Forslag Innsats Wow-faktor Bygger på
Auto-Clipper Middels Høy Live transkripsjon, jobbkø, Caddy byte-range
Graph Health Monitor Lav Middels Kunnskapsgraf, pgvector, jobbkø
Serendipity Roulette Lav Høy Kunnskapsgraf, Live AI
Podcast Time Machine Lav Høy Segmenter, Caddy byte-range, Live AI
Meme Generator Lav Høy Whiteboard, transkripsjon, AI Gateway
Valgomat Roast Lav Middels Valgomat, kunnskapsgraf
Live Audience Q&A Middels Høy Valgomat, LiveKit, SpacetimeDB
Guest Prep Simulator Middels Høy Kunnskapsgraf, AI Gateway
Debate Club Middels Middels Kunnskapsgraf, AI Gateway, jobbkø
Ghost Host TTS Stor Høy LiveKit, AI Gateway, ny TTS-infra
Tekst-primitiv LavMiddels MiddelsHøy Meldingsboks, view-configs
Editor MiddelsStor Høy Tekst-primitiv, Tiptap/ProseMirror, KaTeX
Artikkel-publisering MiddelsStor Høy Tekst-primitiv, kunnskapsgraf, Caddy, jobbkø
Sosial publisering LavMiddels Høy Chat, jobbkø, workspace settings
Komponerbare sider Lav (Fase 1) MiddelsHøy Workspace-modell, SvelteKit, alle feature-komponenter
Contradiction Detector Middels Høy Live AI, kunnskapsgraf, pgvector, segmenter
Auto-Highlight Reel Middels Høy Podcastfabrikken, jobbkø, AI Gateway, Caddy byte-range
Audience Voice Memo Lav Høy Den Asynkrone Gjesten, Live transkripsjon, Live AI
Avisvisning LavMiddels Høy Meldingsboks, kunnskapsgraf, prominens-score
Personlig workspace LavMiddels MiddelsHøy Workspace-modell, meldingsboks, tekst-primitiv
Kildevern-modus LavMiddels Høy AI Gateway, Ollama/vLLM, Møterommet
Podcasting 2.0 Lav Høy Podcastfabrikken, kunnskapsgraf, RSS
Web Clipper LavMiddels Høy Jobbkø, AI Gateway, meldingsboks, kunnskapsgraf
Visuelle Waveforms LavMiddels Høy Podcastfabrikken, jobbkø, editor
Innspilling & Storyboard
Storyboard MiddelsStor Høy Canvas-primitiv, meldingsboks, universell overføring, Studioet, Podcastfabrikken
Card Chaining Lav Middels Kunnskapsgraf, Storyboard, AI Gateway
Ghost Cards LavMiddels Høy Storyboard, meldingsboks, kunnskapsgraf
Pinboard Mode Lav Høy Storyboard, kanban
Flow Meter Lav Middels Storyboard
Emotion Tags Lav Middels Meldingsboks, kanban, storyboard
Samarbeid
Collaborative Cursors Lav Middels SpacetimeDB, Svelte
Card Heat Map Lav Middels Meldingsboks, kanban/storyboard

Forfremmet til feature: Meldingsboks — universell diskusjonsprimitiv (erstatter separate modeller for chat, kanban-kort, kalenderhendelser, faktoider, notater).

Lavthengende frukter (lav innsats, høy wow): Serendipity Roulette, Podcast Time Machine, Meme Generator, Audience Voice Memo, Pinboard Mode, Ghost Cards.

Format

Forslagsfiler er lette — ingen streng mal. Minimum:

  • Hva er idéen?
  • Hvorfor er den interessant?
  • Hva bygger den på? (eksisterende features/infra)
  • Innsats (Lav / Middels / Stor) og Wow-faktor (Lav / Middels / Høy)
  • Åpne spørsmål

================================================================ FILE: docs/proposals/artikkel_publisering.md

Forslag: Artikkel-publisering og publikasjonsmodell

Idé

Utvide Sidelinja til en publiseringsplattform der individuelle skribenter og redaksjonelle team kan skrive, samarbeide på, og publisere tekster. Inspirert av Substack (individuell publisering), men med en kollaborativ og kuratorisk dimensjon: en tekst eies av noen, samarbeides med noen, og publiseres av én eller flere.

Designfilosofi

Vakre tekster

Sidelinja-artikler skal føles som noe mellom en Substack-essay og en akademisk publikasjon. Ingen sidebar-rot, ingen widget-helvete. Bare tekst, typografi og innhold.

Prinsipper:

  • Typografi først. Seriffont for brødtekst (Georgia, Literata), god linjehøyde (1.61.8), komfortabel lesbredde (6075 tegn). Overskrifter i sans-serif for kontrast.
  • Luft. Generøse marginer. Innholdet puster.
  • Mørk/lys. Respekterer prefers-color-scheme. Begge moduser like gjennomtenkte.
  • Matematikk er førsteklasses. KaTeX for LaTeX-notasjon, server-side-rendret.
  • Ingen visuell støy. Metadata diskret plassert. Fokus er teksten.

Inspirasjon: Gwern.net (typografi + fotnoter), Distill.pub (interaktive figurer), Stratechery (ren leseopplevelse), Edward Tufte (informasjonstetthet uten rot).

Teksten som primitiv

En artikkel er ikke en egen ting — den er en melding med article_view (se tekst_primitiv.md). Samme meldingsboks-filosofi: én primitiv, flere formål. Det som gjør dette til noe mer enn "bare en editor" er publikasjonsmodellen.

Hvorfor er dette interessant?

Publiseringslandskapet har et hull

De fleste plattformer tvinger deg til å velge:

  • Individuell blogg (WordPress, Substack) — du skriver alene, du publiserer alene
  • Redaksjonelt fellesprosjekt (nettavis, magasin) — alt er felles, individet forsvinner

Virkeligheten er mer nyansert:

  • Individuelle skribenter skriver sitt eget
  • Samarbeidende team gjør sin kollaborative greie
  • Alle publiserer sitt eget (personlig feed/blogg)
  • En redaktør/publikasjon kan kuratere — plukke opp individuelle tekster til en felles utgivelse
  • Lesere abonnerer fast på noen skribenter, velger selektivt fra andre

Sidelinja kan modellere alt dette fordi primitiven er riktig: en tekst eies av en forfatter, kan ha medforfattere, og kan publiseres i flere kontekster.

Naturlig forlengelse av eksisterende features

  • Redaksjonen har chat, AI-behandling og kunnskapsgraf. Artikler er neste steg — fra intern diskusjon til publisert innhold.
  • Grafkobling: en artikkel arver automatisk koblinger til temaer, aktører og episoder via #-mentions.
  • SEO: podcast-innhold er vanskelig å søkemotor-indeksere. Artikler gir tekstlig innhold som rangerer.
  • Show notes 2.0: kan bli fullverdige artikler med egne URL-er.

Hva bygger den på?

  • Tekst-primitiv (proposal) — article_view, WYSIWYG-editor, lagringsformat
  • Kunnskapsgraf — artikkel er en melding (node) med edges til temaer/aktører
  • Personlig workspace (proposal) — personlig publisering
  • Jobbkø — AI-assistert skriving, faktasjekk, oppsummering
  • Caddy — servering av publiserte artikler

Skisse

Publikasjonsmodellen

Publikasjon som node

En publikasjon er en ny node_type — en kuratert samling tekster med egen identitet:

ALTER TYPE node_type ADD VALUE 'publikasjon';

CREATE TABLE publications (
    id           UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    name         TEXT NOT NULL,
    slug         TEXT NOT NULL UNIQUE,      -- URL-prefiks
    description  TEXT,
    avatar_url   TEXT,
    owner_id     TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

Typer publikasjoner:

  • Personlig feed — implisitt, én per bruker, knyttet til personlig workspace
  • Redaksjonell publikasjon — opprettet manuelt, flere kuratorer
  • Tematisk feed — automatisk generert fra graf-spørringer ("alt tagget med #Skolepolitikk")

PUBLISHED_IN-edge

En tekst publiseres i en publikasjon via en graf-edge:

-- Ny relation_type: 'PUBLISHED_IN'
-- source_id = melding (artikkel)
-- target_id = publikasjon
-- origin = 'user' (forfatter publiserer selv) eller 'curator' (redaktør plukker opp)
-- context_id = NULL (eller referanse til kurateringsforespørsel)

En tekst kan ha flere PUBLISHED_IN-edges — publisert i forfatterens personlige feed og i redaksjonens magasin. Ingen kopiering — bare flere edges til samme node.

Samarbeid: medforfattere

En tekst har én author_id (eier) i messages. Medforfattere modelleres som:

-- Ny relation_type: 'CONTRIBUTED_BY'
-- source_id = melding (artikkel)
-- target_id = entitet (person) eller bruker-node
-- confidence = NULL
-- origin = 'user'

Medforfattere kan redigere teksten (tilgangskontroll i appkode). Kreditering vises i publisert artikkel.

URL-struktur

sidelinja.org/@vegard/                      → Vegards personlige feed
sidelinja.org/@vegard/skolepolitikk-2026    → Enkeltartikkel (personlig)
sidelinja.org/pub/sidelinja-magasinet/      → Redaksjonell publikasjon
sidelinja.org/pub/sidelinja-magasinet/artikkel-slug  → Artikkel i publikasjon
sidelinja.org/feed/@vegard.xml              → Personlig Atom-feed
sidelinja.org/feed/pub/sidelinja-magasinet.xml  → Publikasjons-feed

Kurateringsflyt

Forfatter skriver tekst i sitt workspace
  → Publiserer i personlig feed (@vegard/slug)
  → Redaktør ser teksten (følger forfatteren, eller finner via graf)
  → Redaktør "kuraterer" teksten til sin publikasjon
     → Ny PUBLISHED_IN-edge med origin: 'curator'
     → Teksten dukker opp i publikasjonens feed
     → Forfatter varsles, kan godkjenne/avslå

Abonnementer

Lesere kan følge:

  • En forfatter (personlig feed)
  • En publikasjon (redaksjonell feed)
  • Et tema (automatisk feed fra graf)

Ekstern distribusjon: Atom/RSS-feeds for alt. Ingen e-postavhengighet (anti-Substack). Lesere bruker sin egen feed-reader.

Intern distribusjon (fremtidig): Notifikasjoner i appen. Men RSS er minimum viable — funker fra dag 1 uten notifikasjonssystem.

Innholdsformat og rendering

Se tekst_primitiv.md for editor og lagringsformat. Tillegg for publiserte artikler:

  • KaTeX server-side renderingbody_html i article_view inneholder ferdig-rendret HTML med KaTeX. Ingen JavaScript for lesere.
  • Sidemerknad-fotnoter (Tufte-stil) — vises i margen på brede skjermer (>1200px), popup på mobil.
  • Podcast-embeds{{segment:uuid}} rendrer innebygd lydspiller med transkripsjon.
  • OG-tags — automatisk generert Open Graph-metadata per artikkel.

Typografi-stack (CSS)

Brødtekst:    Literata / Georgia / serif      (1.6 linjehøyde, 6075ch bredde)
Overskrifter: Inter / system-ui / sans-serif  (stramt, tydelig hierarki)
Kode:         JetBrains Mono / monospace      (med ligaturer)
Matematikk:   KaTeX default                   (skalerer med brødtekst)

Åpne spørsmål

Publikasjonsmodell

  • Bør en forfatter godkjenne at teksten kurateres av en publikasjon, eller er det implisitt tillatt?
  • Kan en publikasjon ha eksklusivitet (teksten publiseres bare der)?
  • Hvem eier kommentarfeltet på en kuratert tekst — forfatteren eller publikasjonen?

Samarbeid

  • Er CONTRIBUTED_BY-edge nok for medforfatterskap, eller trengs en egen article_collaborators-tabell med rolleinfo (forfatter, redaktør, korrekturleser)?
  • Sanntids samredigering (Yjs) fra dag 1, eller auto-save + manuell koordinering?

Kommentarer: to kanaler, én node

En publisert artikkel har to separate diskusjonsrom:

  1. Intern kontekst — den opprinnelige reply_to-kjeden og workspace-channelen artikkelen tilhører. Usynlig utenfra. En artikkel som startet som et svar i #Mediepolitikk beholder hele den interne tråden — men den lekker aldri ut.

  2. Offentlig kommentarkanal — en separat channel med visibility: 'public', knyttet til artikkelen via article_view.comment_channel_id. Workspace-medlemmer ser begge kanaler. Publikum ser bare den offentlige.

Åpne spørsmål rundt offentlige kommentarer:

  • Hvem kan kommentere? Anonymt, autentisert (Authentik), kun inviterte?
  • Moderasjon: forfatter, workspace-admin, eller begge?
  • Enkleste start: ingen offentlige kommentarer (read-only for publikum). Arkitekturen tillater det via nullable comment_channel_id, men det bygges først når behovet er reelt.

Tematiske feeds

  • Automatisk feed basert på graf-spørringer ("alle artikler med MENTIONS-edge til #Skolepolitikk") — er dette en publikasjon, eller et separat konsept?
  • Trolig: en publikasjon med type: 'auto' og en lagret graf-spørring. Men det er et steg videre.

Versjonering

  • Bør publiserte artikler ha synlig versjonering (à la Wikipedia)? message_revisions gir historikk allerede, men det er et spørsmål om det eksponeres til lesere.

Bilder og media

  • Content-addressable via media_files (eksisterende), eller CDN?
  • Bildeoptimalisering (responsive srcset) for publiserte artikler?

Innsats: Middels-Stor

Datamodellen (publikasjoner, PUBLISHED_IN-edges) er overkommelig. Typografi og leseopplevelse krever CSS-arbeid. Kurateringsflyten er det mest komplekse — men kan bygges inkrementelt.

Wow-faktor: Høy

Kombinasjonen av vakker typografi, individuelle forfattere, kollaborativt forfatterskap og kuratoriske publikasjoner — integrert med kunnskapsgraf og podcast-embeds — er noe som ikke finnes andre steder. En publiseringsplattform der en politisk analyse kan ha formler, lydklipp fra intervjuer, og koblinger til kunnskapsgrafen.

Relasjon til andre proposals

  • Tekst-primitiv — fundament: editor, article_view, lagringsformat
  • Personlig workspace — kontekst: der individet skriver og publiserer fra
  • Denne proposalen handler om hva som skjer etter at teksten er skrevet — publisering, kurasjon, distribusjon, leseopplevelse

================================================================ FILE: docs/proposals/audience_voice_memo.md

Forslag: Audience Voice Memo (Live publikums-innspill)

Innsats: Lav | Wow-faktor: Høy

Idé

Under live-innspilling vises en QR-kode (eller kort-URL) som publikum kan skanne. Den åpner en minimal nettside (gjenbruker Den Asynkrone Gjestens tech) der de kan sende voice memos. Memoene dukker opp i studio-chatten som voice_memo-meldinger, transkriberes live, og AI matcher innholdet til kunnskapsgrafen:

"Lytter 'Kari fra Bergen' spør om vindkraft — du har 3 faktoider om dette fra Episode 12 og 17."

Hvorfor

  • Gjør live-innspilling interaktiv uten at publikum trenger app eller konto
  • Gjenbruker nesten alt fra Den Asynkrone Gjesten (guest_tokens, lydopplasting, Whisper)
  • Kombinert med Live AI gir det programlederen kontekst på publikums-spørsmål i sanntid
  • Viralt: "Send oss en voice memo LIVE mens vi spiller inn"

Bygger på

  • Den Asynkrone Gjesten (guest_tokens, /guest/[token]-rute, lydopplasting)
  • Live transkripsjon (Whisper transkriberer voice memos via jobbkø)
  • Live AI (matcher transkriberte memos mot kunnskapsgraf)
  • SpacetimeDB / PG-polling (memos dukker opp i studio-chat i sanntid)

Forskjell fra Den Asynkrone Gjesten

  • Asynkron gjest: Én person, navngitt, forberedte spørsmål, tidsbegrenset
  • Audience Voice Memo: Mange anonyme/pseudonyme lyttere, fritt innhold, kun aktivt under innspilling

Teknisk skisse

  1. Redaksjonen oppretter en "Live Q&A-sesjon" (spesiell guest_token med type: 'audience')
  2. QR-kode genereres med kort-URL → /live/[token]
  3. Publikum åpner, skriver inn kallenavn, tar opp voice memo (maks 30 sek)
  4. Voice memo lastes opp, Whisper transkriberer, AI matcher mot graf
  5. Studio-chatten viser: "[Kari fra Bergen]: " + AI-kontekst

Dataklassifisering

  • Audience voice memos: Flyktig (TTL 7 dager) — kun relevant rundt innspilling
  • Transkripsjoner av memos: Flyktig (TTL 7 dager)
  • Kuraterte memos (valgt ut av redaksjonen): Kritisk (flyttes til workspace media/)

Åpne spørsmål

  • Moderering: skal alle memos dukke opp automatisk, eller må en produsent godkjenne først?
  • Skalering: hva om 100+ lyttere sender memos samtidig? Whisper-kø kan bli overbelastet
  • Kan dette kombineres med Live Audience Q&A-forslaget (stemmegiving på spørsmål)?
  • Personvern: skal lytterne akseptere at memoet kan brukes i podcasten?

================================================================ FILE: docs/proposals/auto_clipper.md

Forslag: Auto-Clipper (Sosiale medier-klipp)

Innsats: Middels | Wow-faktor: Høy

Idé

Under studio-innspilling (eller møterom) kjører en bakgrunnsprosess som lytter til Whisper-chunks + Aha-markører. Når AI-en oppdager en "punchline", en sterk mening eller et morsomt øyeblikk, genereres automatisk 1545 sekunders lydklipp + thumbnail-tekst. Klippene havner i en "Clip Inbox"-channel med ett-klikk eksport.

Hvorfor

  • Podcast-klipp er gull for vekst, men ingen vil klippe manuelt etter innspilling
  • Utnytter allerede eksisterende live-transkripsjon + faktoid-oppslag
  • Gir umiddelbar ROI: redaksjonen får 510 klipp per episode "gratis"

Bygger på

  • Live transkripsjon + Live AI (studio-modus)
  • Jobbkø (clip_extract-jobb)
  • Caddy byte-range (klippene streames direkte fra original MP3 uten å duplisere filer)
  • Kunnskapsgraf (klipp kobles automatisk til #Tema og @Aktør)

Dataklassifisering

  • Lydklipp: Avledet (kategori 3) — kan regenereres fra original MP3 + tidsstempler
  • Klipp-metadata (tidsstempler, score, thumbnail-tekst): Kritisk (PG)

Åpne spørsmål

  • Scoring-modell: hva gjør et øyeblikk "klippverdig"? Humor, nyhet, kontrovers, emosjonell intensitet?
  • Skal klippene genereres som faktiske MP3-filer umiddelbart, eller lagre kun tidsstempler og generere on-demand ved eksport?
  • Eksportmål: TikTok/Instagram/YouTube Shorts — trenger vi video med waveform/teksting, eller holder lyd?
  • Kan dette kombineres med Podcast Time Machine for "throwback-klipp"?

================================================================ FILE: docs/proposals/auto_highlight_reel.md

Forslag: Auto-Highlight Reel (Post-innspilling)

Innsats: Middels | Wow-faktor: Høy

Idé

Etter innspilling analyserer Podcastfabrikken transkripsjonen for humor, emosjonelle topper, sterke meninger og "punchlines". AI genererer automatisk 5-10 klipp (15-45 sek) med:

  • Tidsstempler (start/slutt) i originalt opptak
  • Foreslått teksting (fra transkripsjon, formatert for sosiale medier)
  • Auto-generert thumbnail-tekst (det sterkeste sitatet)
  • Foreslått hashtags basert på kunnskapsgraf-tags

Klippene havner i en "Highlights"-channel i workspace-chatten for review, med ett-klikk godkjenning og auto-posting via sosial publisering.

Hvorfor

  • Podcast-klipp er den viktigste vekstmotoren, men manuell klipping er tidkrevende
  • Bygger på eksisterende Whisper-transkripsjon + jobbkø + AI Gateway
  • Kombinert med sosial publisering-forslaget gir dette en komplett "innspilling → distribusjon"-pipeline
  • Differensiator: ingen annen podcast-plattform gjør dette automatisk med kvalitetskontroll

Bygger på

  • Podcastfabrikken (Whisper SRT + AI-metadata — allerede spesifisert)
  • Auto-Clipper (eksisterende forslag — dette er post-innspilling-versjonen)
  • Jobbkø (highlight_extract-jobb, kjøres etter whisper_postprocess)
  • AI Gateway (sidelinja/resonering for klipp-vurdering)
  • Caddy byte-range (klipp serveres som range-requests mot original MP3)
  • Sosial publisering (eksisterende forslag — ett-klikk posting)

Forskjell fra Auto-Clipper

Auto-Clipper kjører live under innspilling og fanger øyeblikk i sanntid. Auto-Highlight Reel kjører etter innspilling og har tilgang til hele transkripsjonen — kan dermed finne narrative buer og tematiske høydepunkter som bare er synlige i kontekst.

Dataklassifisering

  • Klipp-metadata (tidsstempler, teksting, score): Kritisk (PG)
  • Klipp-lydfiler: Avledet (kategori 3) — genereres on-demand fra original MP3 + tidsstempler
  • Highlight-forslag (før godkjenning): Flyktig (TTL 30 dager)

Åpne spørsmål

  • Scoring: hva gjør et øyeblikk "klippverdig"? Humor, nyhet, kontrovers, emosjon?
  • Videostøtte: trenger vi waveform-video med teksting for TikTok/Shorts, eller holder lyd + bilde?
  • Skal AI-en foreslå rekkefølge/gruppering av klipp til en "highlight reel" (2-3 min sammenklipp)?
  • Kan den lære av hvilke klipp redaksjonen godkjenner over tid (feedback loop)?

================================================================ FILE: docs/proposals/avisvisning.md

Forslag: Avisvisning

Idé

En read-only blokk som rendrer workspace-aktivitet som en avis — sortert etter prominens (klikk, svar, reaksjoner, graf-koblinger, alder). Ingen redigering, ingen forvaltning, bare et annet blikk inn på det som allerede finnes. Med et søkefelt som lar deg filtrere på en entitet, slik at du kan få "Jonas Gahr Støre-avisen" eller "Skolepolitikk-avisen" — alt vi har om akkurat det temaet/den personen, rangert etter viktighet.

Hvorfor er dette interessant?

Et nytt perspektiv uten ny data

All data finnes allerede: meldinger, artikler, graf-koblinger, reaksjoner, svar-tråder, kalender-hendelser. Avisvisningen er bare en spørring + et layout. Ingen ny datamodell, ingen ny input — bare en ny måte å lese på.

Entitet-filter som superkraft

Kunnskapsgrafen kobler alt til entiteter via MENTIONS-edges. Et filter på én entitet gir deg øyeblikkelig alt workspace vet om den — som en personlig nyhetsfeed:

  • #Jonas Gahr Støre → alle meldinger, artikler, faktoider, episodesegmenter som nevner ham
  • #Skolepolitikk → alt om temaet, på tvers av channels og tidsperioder
  • #Episode 42 → alt knyttet til den episoden: research, diskusjon, show notes, publiserte artikler
  • Ingen filter → hele workspacets aktivitet, rangert etter prominens

Arkiv

Innhold som overlever TTL-opprydding (har graf-koblinger, er pinned, har mange svar) er per definisjon det viktige. Avisvisningen har to moduser:

Dagsaktuelt (default): Rangert med alders-boost — nytt innhold scorer høyere. Viser det som skjer .

Arkiv: Samme spørring uten alders-boost. Alt som noensinne har hatt tyngde for denne entiteten, kronologisk eller rangert etter total prominens. En historisk oversikt: "Hva har vi sagt og skrevet om skolepolitikk gjennom tidene?"

Hva bygger den på?

  • Meldingsboks — alt innhold er meldinger med view-configs
  • Prominens-score (meldingsboks §7.3) — beregnes fra svar, stemmer, roller, graf-koblinger, alder
  • KunnskapsgrafMENTIONS-edges gir entitets-filtrering
  • Komponerbare sider — avisvisningen er en blokk som kan plasseres på en side

Skisse

UI

Header med søk:

┌─────────────────────────────────────────────────┐
│ 📰 Avis        [# Søk entitet...          ▼]   │
│                  Skolepolitikk                   │
│                  [Dagsaktuelt] [Arkiv]           │
├─────────────────────────────────────────────────┤

Søkefeltet er #-autocomplete mot entities-tabellen — samme mekanisme som i editoren og chatten. Velg en entitet, avisen filtrerer seg.

Avis-layout (dagsaktuelt):

┌──────────────────────┬──────────────────────────┐
│                      │ Artikkel: Ny rapport fra  │
│  TOPPSAK             │ Utdanningsforbundet       │
│  (høyest prominens)  │ 12 reaksjoner · 2t siden  │
│                      ├──────────────────────────┤
│  Tittel, ingress,    │ Chat: Interessant tråd i  │
│  forfatter, bilde    │ #Mediepolitikk            │
│  23 svar · 45 min    │ 8 svar · 5t siden         │
│                      ├──────────────────────────┤
│                      │ 📅 I morgen: Intervju med │
│                      │ Kunnskapsministeren        │
├──────┬───────┬───────┴──────┬───────────────────┤
│Fakto-│Segment│ Kanban-kort  │ Notat: Research   │
│ide   │Ep. 42 │ "Skriv intro"│ om PISA-tall      │
│⬆12   │14:23  │ → In Progress│ Oppdatert i går   │
└──────┴───────┴──────────────┴───────────────────┘

Størrelsen på hver "sak" bestemmes av prominens-scoren. Toppsaken er størst. Lavere score = mindre kort. Ren CSS grid med dynamisk grid-row: span N basert på score-kvantiler.

Arkiv-layout:

┌─────────────────────────────────────────────────┐
│ 📰 Arkiv: Skolepolitikk       [Dagsaktuelt] [Arkiv]│
├─────────────────────────────────────────────────┤
│ 2026                                            │
│ ├── Mars: "Ny PISA-rapport" (artikkel, 45 ⬆)   │
│ ├── Mars: Diskusjon om budsjettkutt (23 svar)   │
│ ├── Feb: Episode 42 segment om skolepolitikk    │
│ 2025                                            │
│ ├── Nov: "Lærermangel i distriktene" (artikkel) │
│ ├── Sep: Faktoide: Antall lærerstudenter 2024   │
│ └── ...                                         │
└─────────────────────────────────────────────────┘

Arkivet er en kronologisk liste gruppert etter tidsperiode. Enklere layout, tyngre på metadata (type, score, alder). Fungerer som et oppslagsverk.

Rangering: Alder først, prominens som tiebreaker

Dette er en avis, ikke et oppslagsverk. Nytt innhold skal alltid dominere. En fersk melding med 2 svar slår en gammel med 50. Prominens avgjør bare innenfor omtrent samme tidsperiode — hvilken av dagens saker er toppsaken.

Modell: Tidsvinduer med intern prominens-rangering

Innholdet deles i tidsvinduer. Innenfor hvert vindu rangeres det etter prominens. Vinduer vises i rekkefølge — nyeste først.

Siste 24 timer     → rangert etter prominens (dette er "forsiden")
13 dager siden    → rangert etter prominens (side 2 / bla-ned)
37 dager siden    → rangert etter prominens (eldre nyheter)
730 dager siden   → bare det mest prominente overlever (highlights)

Brukeren blar nedover for å gå bakover i tid, men innenfor hver tidsperiode ser de det viktigste først.

SQL-tilnærming:

WITH scored AS (
    SELECT
        m.id,
        m.title,
        m.body,
        m.created_at,
        -- Prominens (tiebreaker innenfor tidsvindu)
        (SELECT COUNT(*) FROM messages r WHERE r.reply_to = m.id) * 2
      + (SELECT COUNT(*) FILTER (WHERE reaction = 'upvote')
              - COUNT(*) FILTER (WHERE reaction = 'downvote')
         FROM message_reactions mr WHERE mr.message_id = m.id) * 3
      + (SELECT COUNT(*) FROM graph_edges ge WHERE ge.source_id = m.id) * 5
      + (CASE WHEN EXISTS (SELECT 1 FROM article_view a WHERE a.message_id = m.id) THEN 10 ELSE 0 END)
        AS prominence,
        -- Tidsvindu-bucket
        CASE
            WHEN m.created_at > now() - interval '1 day'  THEN 1
            WHEN m.created_at > now() - interval '3 days' THEN 2
            WHEN m.created_at > now() - interval '7 days' THEN 3
            ELSE 4
        END AS time_bucket
    FROM messages m
    JOIN nodes n ON n.id = m.id
    WHERE n.workspace_id = $workspace_id
      AND ($entity_id IS NULL OR EXISTS (
          SELECT 1 FROM graph_edges ge
          WHERE ge.source_id = m.id
            AND ge.target_id = $entity_id
            AND ge.relation_type = 'MENTIONS'
      ))
      -- Minimumsterskel: ikke vis støy
      AND (
          m.pinned = true
          OR m.title IS NOT NULL
          OR EXISTS (SELECT 1 FROM messages r WHERE r.reply_to = m.id)
          OR EXISTS (SELECT 1 FROM graph_edges ge WHERE ge.source_id = m.id)
          OR EXISTS (SELECT 1 FROM message_reactions mr WHERE mr.message_id = m.id)
      )
)
SELECT *
FROM scored
ORDER BY time_bucket ASC, prominence DESC
LIMIT 30;

Hvorfor tidsvinduer og ikke en multiplikativ formel?

En score * recency_decay-formel er vanskelig å balansere universelt. Med høy decay dominerer alltid det nyeste — prominens betyr ingenting. Med lav decay kryper gamle tungvektere opp og skyver bort nyhetene. Tidsvinduer unngår dette: nytt er alltid først, men innenfor "i dag" får prominens avgjøre hva som er toppsaken.

Minimumsterskel: Ikke alt hører hjemme i avisen. En enkel "hei" uten svar, reaksjoner eller graf-koblinger er støy. Spørringen filtrerer bort meldinger som ikke har noen form for tyngde (tittel, svar, reaksjoner, edges, pinned). Dette er avisen — ikke firehosen.

Tuning over tid: Vektene (2, 3, 5, 10) og tidsvindu-grensene (1d, 3d, 7d) er konfigurerbare uten migrasjoner. Kan lagres i workspaces.settings og justeres per workspace etter behov. Men vi starter med én universell default og justerer basert på faktisk bruk.

Arkiv-modus: Ingen tidsvinduer. Sortert etter created_at DESC (kronologisk) med prominens som sekundær sortering. Alt som har overlevd TTL-opprydding vises — gruppert etter måned/år.

Kort-typer

Ulike meldingstyper rendres med ulike kort i avisen:

Kilde Kort-layout
Melding med mange svar Tittel/ingress + svar-tall + siste aktivitet
Artikkel (article_view) Tittel + excerpt + forfatter + bilde
Kanban-kort Tittel + kolonne + assignee
Kalenderhendelse Tittel + dato/tid + "om 2 dager"
Faktoide (ABOUT-edge) Innhold + stemme-score + tilknyttet entitet
Episodesegment Episode-tittel + tidsrom + transkripsjon-utdrag
Notat med tittel Tittel + oppdatert-dato

Alle er lenker — klikk fører deg til meldingen i sin naturlige kontekst (chatten, kanban-brettet, kalenderen, etc.).

Blokk i komponerbare sider

Avisvisningen er en blokk-komponent (<NewsView>) som kan plasseres på en komponerbar side:

{
  "type": "news",
  "config": {
    "entity_id": null,        // null = hele workspacet, UUID = filtrert
    "mode": "current",        // "current" eller "archive"
    "limit": 20
  },
  "span": 2                   // tar gjerne full bredde
}

Kan også stå alene som en dedikert side i workspacet — "Avisen" i navigasjonen.

Åpne spørsmål

Flere entiteter

  • Filtrere på flere entiteter samtidig? "Alt om #Skolepolitikk OG #Støre" (AND) vs "Alt om #Skolepolitikk ELLER #Støre" (OR)?
  • Trolig: start med én entitet. Kombiner-logikk er et steg videre.

Caching

  • Prominens-scoren er en tung spørring med subqueries. For en aktiv workspace med mange meldinger bør den caches.
  • Materialized view som refreshes periodisk (hvert 5. minutt)? Eller beregnes on-demand med cache-TTL?
  • For arkiv-modus er caching enklere — dataen endres sjelden.

Personalisering

  • Ser alle samme avis, eller kan den vektes mot brukerens interesser?
  • Start: alle ser det samme. Personalisering er et stort steg (og potensielt en filterboble-felle).

Layout-algoritme

  • Hvor mange "størrelser" av kort? Tre nivåer (stor/medium/liten) er trolig nok.
  • Hvordan fordeles plass? Kvantiler: topp-10% er store, neste 30% medium, resten små?
  • Responsivt: på mobil stacker alt vertikalt, største sak øverst.

Oppdatering

  • Sanntid (nytt innhold dukker opp)? Eller "pull to refresh" / periodisk?
  • Trolig: periodisk (hvert minutt) + manuell refresh-knapp. Sanntid gir en "flimrende" opplevelse i et avis-layout.

Innsats: LavMiddels

Spørringen er rett frem (prominens-faktorer finnes allerede). Layout er CSS grid. Kort-komponentene gjenbruker eksisterende <MessageBox>-varianter. Den tyngste delen er å tuning av rangering og layout-algoritme for at det skal føles som en avis.

Wow-faktor: Høy

"Skriv #Støre i søkefeltet og få en hel avis med alt vi vet om ham" er en øyeblikkelig aha-opplevelse. Kombinasjonen av graf-filtrering og avis-layout gjør kunnskapsgrafen synlig på en måte som graf-visualisering aldri klarer for folk flest.

Relasjon til andre proposals

  • Komponerbare sider — avisvisningen er en blokk
  • Tekst-primitiv / Meldingsboks — all data som vises er meldinger med view-configs
  • Kunnskapsgraf — entitets-filtrering er en graf-spørring
  • Artikkel-publisering — publiserte artikler får prominente kort i avisen

================================================================ FILE: docs/proposals/card_chaining.md

Card Chaining — Automatisk kobling av relaterte kort

Idé

Når to kort plasseres ved siden av hverandre (i kanban, storyboard eller kalender), opprettes automatisk en graf-edge mellom dem. Systemet kan også foreslå overganger: "Og apropos drømmer..."

Hvorfor interessant?

Podcast-segmenter henger ofte sammen tematisk, men koblingen er implisitt. Card chaining gjør den eksplisitt — uten manuelt arbeid. Gir bedre flyt under innspilling og bedre metadata for kunnskapsgrafen.

Fungerer slik

  1. Dra kort A ved siden av kort B i storyboard
  2. System oppretter graph_edge med relation_type: 'sequence' og origin: 'proximity'
  3. Valgfritt: AI foreslår overgangssetning basert på begge kortenes innhold
  4. Ved eksport/arkivering: sekvensen bevares som episode-struktur

Bygger på

  • Kunnskapsgraf (graph_edges)
  • Storyboard (proximity detection)
  • AI Gateway (overgangsforslag)

Innsats

Lav — graph_edges finnes, bare UI for proximity + auto-edge.

Wow-faktor

Middels — subtilt, men forbedrer metadata-kvaliteten dramatisk over tid.

================================================================ FILE: docs/proposals/card_heat_map.md

Card Heat Map — Visuell indikator for engasjement

Idé

Kort på storyboard/kanban gløder basert på hvor mye oppmerksomhet de har fått: hover-tid, antall redigeringer, diskusjonstråd-lengde, og tid brukt i innspilling.

Hvorfor interessant?

Hjelper med å se hva teamet faktisk er engasjert i — uten å lese alt. Under innspilling: "det kortet gløder mest, kanskje vi bør ta det først."

Fungerer slik

  1. Klient tracker hover-tid per kort (lokal state)
  2. Server aggregerer: antall edits, tråd-lengde, reaksjoner
  3. Kombinert score → CSS-variabel (--heat: 0.01.0) → glow-effekt
  4. Valgfritt: "Hot topics"-filter som sorterer kort etter heat

Bygger på

  • Meldingsboks (reaksjoner, tråd-lengde)
  • Kanban/Storyboard (visuell rendering)

Innsats

Lav — ren frontend-logikk med enkel server-aggregering.

Wow-faktor

Middels — subtilt men nyttig for redaksjonell prioritering.

================================================================ FILE: docs/proposals/collaborative_cursors.md

Collaborative Cursors — Sanntids-pekere for flerbrukermiljø

Idé

Alle brukere som er på samme side ser hverandres musepekere som fargede prikker med navn. Fungerer på storyboard, kanban, whiteboard og kalender.

Hvorfor interessant?

Gir "jamming together"-følelse under innspilling og planlegging. Produsent og host ser hverandre jobbe i sanntid uten å snakke om det.

Fungerer slik

  1. Klient sender { user_id, x, y, page } til SpacetimeDB ved musebevegelse (throttlet til ~10 Hz)
  2. Andre klienter renderer fargede SVG-sirkler med brukernavn
  3. Prikken fader ut etter 5 sekunder uten bevegelse
  4. Valgfritt: kort "trail" som viser bevegelsesretning

Bygger på

  • SpacetimeDB (pub/sub for posisjoner)
  • Svelte ($state store for cursor-map)

Innsats

Lav — under 50 linjer Svelte + en SpacetimeDB-reducer.

Wow-faktor

Middels — visuelt tiltalende, men ikke kritisk funksjonalitet.

Åpne spørsmål

  • Bør pekere vises i chat-visning også, eller bare canvas-baserte views?
  • Throttling-strategi: SpacetimeDB-reducer eller klient-side debounce?

================================================================ FILE: docs/proposals/contradiction_detector.md

Forslag: Contradiction Detector (Live i Studioet)

Innsats: Middels | Wow-faktor: Høy

Idé

Under live-innspilling matcher Live AI nye utsagn mot eksisterende CONTRADICTS-edges og gamle segmenter i kunnskapsgrafen. Når en selvmotsigelse oppdages, popper det opp et diskret varsel i studio-UI:

"Du sa akkurat «vi må kutte støtte til vindkraft» — men i Episode 17 (segment 3, 14:22) sa du «vindkraft er fremtiden». Vil du adressere det?"

Programlederen kan:

  1. Ignorere (ingen handling)
  2. Markere for oppfølging (Aha-markør)
  3. Spille inn et 12-sekunders "correction clip" på stedet

Hvorfor

  • Den ultimate "live co-host"-funksjonen — AI som faktisk gjør programlederen bedre
  • Bygger direkte på eksisterende infrastruktur (Live AI + segmenter + kunnskapsgraf)
  • Øker troverdigheten til podcasten (selvkorreksjon er sterkere enn å bli tatt i feil)
  • Viralt potensial: "Denne podcasten har en AI som fanger selvmotsigelser i sanntid"

Bygger på

  • Live transkripsjon (Whisper-chunks i sanntid)
  • Live AI (eksisterende faktoid-oppslag-pipeline)
  • Kunnskapsgraf (segmenter med NER-tags, CONTRADICTS-edges)
  • pgvector (semantisk matching for "lignende men motstridende" utsagn)
  • Caddy byte-range (for å hente originalt lydklipp fra gammel episode)

Teknisk skisse

  1. Whisper-chunk → NER-uttrekk (aktører, temaer, påstander)
  2. Søk i kunnskapsgrafen: finnes det segmenter med samme aktør/tema men motstridende innhold?
  3. pgvector cosine similarity for semantisk matching + LLM-vurdering via sidelinja/resonering
  4. Resultat med confidence score > terskel → push til studio-UI via SpacetimeDB

Dataklassifisering

  • Contradiction-alerts: Flyktig (TTL 24t) — kun relevant under/etter innspilling
  • Godkjente contradictions → nye CONTRADICTS-edges i kunnskapsgrafen (kritisk)

Åpne spørsmål

  • Terskel for confidence: for lav = støy under innspilling, for høy = misser reelle motstridelser
  • Skal den kun matche mot egne episoder, eller også mot eksterne faktoider?
  • Kan dette kombineres med Ghost Host for å "lese opp" motstridelsen?
  • Latens-krav: må fungere innen 10-15 sek etter utsagnet for å være nyttig live

================================================================ FILE: docs/proposals/debate_club.md

Forslag: Debate Club (Simulerte debatter)

Idé

Velg to aktører + et tema. Systemet genererer en simulert debatt der AI-en spiller begge roller basert på faktiske faktoider og sitater fra kunnskapsgrafen.

Kan brukes i studioet som research-verktøy: "Hva ville Støre og Solberg sagt om dette i dag?" — og eksporteres som lydklipp til episoden via TTS.

Hvorfor

  • Kreativt bruk av kunnskapsgrafen som går utover oppslag
  • Research-verktøy: hjelper programlederne å forberede motargumenter
  • Underholdningsverdi: genuint morsomt når det treffer (og når det går galt)
  • Kan brukes i valgomaten som "hør hva partiene mener" basert på AI-profiler

Bygger på

  • Kunnskapsgrafen (faktoider, aktør-profiler, CONTRADICTS-edges)
  • AI Gateway (sidelinja/resonering for nyansert debatt)
  • Jobbkø (generering tar tid)
  • Valgfritt: TTS for lydklipp (se ghost_host_tts.md)

Åpne spørsmål

  • Etikk: OK å legge ord i munnen på ekte politikere? Trenger tydelig "AI-generert"-merking
  • Skal debatten baseres kun på faktoider vi har, eller kan AI-en fylle inn basert på offentlig kjent posisjon?
  • Node-type debate i grafen, eller bare en melding med spesiell message_type?
  • Kobling til valgomaten: kan debatter genereres fra AI-kandidatprofiler?

================================================================ FILE: docs/proposals/editor.md

Forslag: Universell editor

Idé

Én editor-komponent som brukes overalt i Sidelinja — chat, notater, artikler, kanban-kort, show notes. Editoren autodetekterer format (plaintext, markdown, LaTeX) og rendrer riktig uten at brukeren velger modus. Avansert funksjonalitet er alltid tilgjengelig, aldri påtvunget.

Kjerneprinsipp: Brukeren bare skriver

Editoren forstår hva brukeren skriver og rendrer det riktig — live, uten konfigurasjon:

Bruker skriver              Autodetektert           Rendres som
──────────────              ─────────────           ───────────
hei                         plaintext               tekst
**viktig**                  markdown                bold
$E = mc^2$                  LaTeX inline            formel
$$\int_0^1 f(x)dx$$        LaTeX blokk             sentrert formel
```python\n...              kodeblokk               syntax highlight
# Overskrift               markdown heading         H1
#Skolepolitikk              mention                 graf-edge + lenke
{{segment:uuid}}            podcast-embed           lydspiller
bilde dratt inn             media                   inline bilde med bildetekst
https://youtu.be/xyz        YouTube-embed           innebygd videospiller
https://example.com         lenke                   rik forhåndsvisning (OG-kort)

En bruker som kan markdown bruker markdown. En bruker som kan LaTeX bruker LaTeX. En bruker som bare skriver vanlig tekst får vanlig tekst. Ingen modusvelger, ingen forhåndsvalg.

Hvorfor er dette et eget prosjekt?

Editoren er det mest komplekse enkeltkomponenten i Sidelinja:

  • Autodeteksjon av formater (markdown, LaTeX, mentions, embeds)
  • Progressiv toolbar (fra usynlig til fullverdig)
  • Live rendering av alt innhold
  • #-mention med autocomplete og graf-integrasjon
  • Podcast-embeds, bilder, vedlegg
  • Versjonering og auto-save
  • Format-kontekstsensitivitet (emoji i chat vs sirlig typografi i artikler)
  • Fremtidig: collaborative editing (Yjs)
  • Mobilopplevelse

Alt dette fortjener sin egen spec, sin egen utviklingssyklus, og sin egen iterasjon — uavhengig av tekst-primitiven (arkitektur) og artikkel-publisering (distribusjon).

Hva bygger den på?

  • Tekst-primitiv — filosofien om at enhver melding kan vokse
  • Meldingsboks — datamodellen editoren skriver til (messages.body)
  • Kunnskapsgraf#-mentions oppretter graf-edges
  • Message revisions — editoren trigge lagring av revisjoner

Skisse

Teknologivalg: Tiptap (ProseMirror)

Tiptap er det naturlige valget for SvelteKit:

  • ProseMirror-basert, modular, godt vedlikeholdt
  • Headless — full kontroll over UI og toolbar
  • Lagrer som JSON (strukturert, transformerbart)
  • Utvidbar med custom nodes og marks
  • Svelte-kompatibelt (@tiptap/core headless + egen Svelte-wrapper)
  • Collaborative editing via Yjs-plugin (fase 2)

Progressiv toolbar

Editoren er én komponent (<Editor>) med ulik toolbar-konfigurasjon basert på kontekst:

Kompakt (default i chat, kanban-kort, quick reply):

[tekst-input .......................... ↑] [Send]

Ingen synlig toolbar. #-mentions og inline-formatering (bold, italic, lenker) via keyboard shortcuts. Enter = send. -knappen utvider til full modus.

Utvidet (notater, lengre tekster, "↑" fra kompakt):

[Tittel                                            ]
[B I S ~ | H1 H2 H3 | • — ✓ | 🔗 📎 # | ↓ ]
[                                                  ]
[ editor-innhold med live-rendering                ]
[                                                  ]

Full toolbar. Enter = ny linje. Auto-save. kollapser tilbake.

Publisering (artikler med article_view):

[Tittel                                            ]
[B I S ~ | H1 H2 H3 | • — ✓ | 🔗 📎 # | ƒ 🎙️ |📝]
[                                                  ]
[ editor-innhold med sidemerknad-støtte            ]
[                                                  ]
[Slug: ________] [Status: Utkast ▼] [Forhåndsvis] [Publiser]

Alt fra utvidet + LaTeX-toolbar, podcast-embeds, fotnoter, publiseringskontroller. 📝-knappen åpner sidemerknad-panel.

Modusene er ikke låst. Brukeren kan alltid utvide eller kollapse. Toolbar-nivå er en UI-preferanse per kontekst, ikke en datadistinksjon.

Raw / Rendered — brukeren bestemmer

Editoren har to visninger, alltid tilgjengelig via en enkel toggle (Ctrl+/ eller knapp i toolbar):

Raw: Viser kildekoden. Markdown som markdown, LaTeX som LaTeX, mentions som #Skolepolitikk. En monospace-editor der du ser nøyaktig hva som er lagret. Ingen magi, ingen overraskelser. Syntax highlighting for lesbarhet, men ingen transformasjon av det du skriver.

# Min analyse av skolepolitikken

Gini-koeffisienten $G = \frac{1}{2n^2\bar{x}}$ viser at...

**Viktig:** Se også #Utdanningsforbundet sin rapport.

{{segment:550e8400-e29b-41d4-a716-446655440000}}

Rendered: WYSIWYG-visning. Overskrifter er store, bold er bold, LaTeX er rendret som formler, mentions er klikkbare lenker, podcast-embeds er lydspillere. Du kan skrive direkte i denne visningen — toolbar-knapper og formatering fungerer som i et vanlig tekstbehandlingsverktøy.

┌──────────────────────────────────────────────────┐
│ Min analyse av skolepolitikken          [Raw/Rendered]
│                                                  │
│ Gini-koeffisienten G = ½n²x̄ viser at...         │
│                                                  │
│ Viktig: Se også Utdanningsforbundet sin rapport. │
│                                                  │
│ ▶ [Episode 42, 14:2321:07] ░░░░░░░░            │
└──────────────────────────────────────────────────┘

Prinsippet: Rendered er standard for de fleste. Raw er alltid ett tastetrykk unna for de som vil ha kontroll. Begge visningene redigerer samme data — switch er øyeblikkelig og tapsfri.

Teknisk: Raw-modus er en CodeMirror-instans (eller enkel textarea med syntax highlighting) som opererer på serialisert markdown/LaTeX. Rendered-modus er Tiptap WYSIWYG. Begge skriver til samme Tiptap JSON — raw via en markdown→JSON parser, rendered direkte. Switch mellom dem re-serialiserer.

Viktig detalj: I rendered-modus forsvinner ikke kildekoden. Klikker du på en rendret formel, ser du LaTeX-kilden inline (som Obsidian gjør med markdown). Klikker du utenfor, rendres den igjen. Rendered-modus er altså ikke et "preview" — det er en visuell editor der kildekoden er tilgjengelig ved klikk.

Autodeteksjon

Editoren forstår hva brukeren skriver — i begge moduser:

Markdown: **bold**, # Heading, - item, > quote, `code`. I raw: syntax highlighted. I rendered: rendret som formatering.

LaTeX: $...$ inline, $$...$$ blokk. I raw: syntax highlighted. I rendered: rendret med KaTeX.

Mentions: # trigger autocomplete i begge moduser. Ved valg opprettes en mention-node som rendres som lenke (rendered) eller #Navn (raw), og oppretter MENTIONS-edge ved lagring.

Podcast-embeds: {{segment:uuid}}. I raw: syntax highlighted. I rendered: mini-lydspiller.

Kodeblokker: Triple backtick med språk-hint. Syntax highlighting i begge moduser.

Media, lenker og embeds

Editoren håndterer bilder, lenker og eksterne embeds som førsteklasses innhold — ikke som vedlegg på siden, men inline i teksten.

Bilder:

  • Drag-and-drop, paste fra utklippstavle, eller opplastingsknapp i toolbar
  • Lastes opp til media_files (eksisterende tabell) via API
  • Inline i teksten med valgfri bildetekst og alt-tekst
  • Resize ved å dra i hjørnet (rendered-modus)
  • I raw: ![alt-tekst](media:uuid) eller standard markdown bildesyntaks
  • Responsiv srcset genereres ved opplasting (jobbkø) for publiserte artikler

Lenker:

  • Paste en URL → autodetekteres som lenke
  • Rik forhåndsvisning (Open Graph): tittel, beskrivelse, miniatyrbilde — hentes asynkront ved paste, caches
  • Brukeren kan velge mellom rik forhåndsvisning (kort) og enkel tekstlenke
  • I raw: standard markdown [tekst](url) eller bare URL

Externe embeds:

  • YouTube, Vimeo, Twitter/X, o.l. — paste en URL, editoren gjenkjenner domenet og rendrer innebygd spiller/visning
  • Basert på oEmbed-protokollen der tilgjengelig, ellers sandboxed iframe
  • I raw: bare URL-en på egen linje. I rendered: innebygd player med riktig aspekt-ratio
  • Whitelist-basert: kun kjente domener får embed-behandling. Ukjente URL-er vises som rik lenke-forhåndsvisning

Filvedlegg:

  • PDF, dokumenter, andre filer — lastes opp til media_files, vises som nedlastbar lenke med filtype-ikon og størrelse

Teknisk:

  • Alle opplastinger går via POST /api/media → lagres content-addressable i media_files
  • Referanser i Tiptap JSON er media:uuid — aldri direkte fil-URL-er. Gjør det mulig å flytte lagring (lokal → CDN) uten å endre innhold
  • Bildeoptimalisering (resize, WebP-konvertering) som jobbkø-oppgave ved opplasting
  • oEmbed/OG-metadata caches i en enkel tabell for å unngå gjentatte oppslag

AI-behandling — universell knapp

Editoren har en AI-knapp () som behandler innholdet i boksen. Originalteksten bevares alltid som revisjon (message_revisions), og AI-resultatet tar over som nytt innhold — klart for videre redigering av brukeren.

Det som opprinnelig var tenkt som en separat "AI Research-Klipper"-modal er nå bare én av handlingene her: paste inn hva som helst → trykk → AI-en behandler det.

Flyten:

Bruker limer inn rotete Ctrl+A-tekst fra en nettavis
  → Trykker ✨ (standard: "Fiks tekst")
  → Originalen lagres som revisjon (alltid tilgjengelig)
  → AI-resultatet erstatter innholdet i editoren
  → Brukeren redigerer videre, legger til tittel, justerer
  → AI-en foreslår #-mentions basert på innholdet → graf-edges opprettes
  → Meldingen lever videre: kan få prominens i avisen, bli et kanban-kort, publiseres

Alternativt: Brukeren kan velge at resultatet publiseres som ny melding (svar på originalen) i stedet for å erstatte innholdet. Nyttig for "trekk ut fakta" der originalen og resultatet er to ulike ting.

Standard-prompt: "Fiks tekst" ()

Standardhandlingen — den brukeren får ved å trykke uten å åpne menyen — er en generisk "magi"-prompt:

Fiks denne teksten. Output på norsk.
- Fiks skrivefeil og grammatikk
- Start med en kort oppsummering av det viktigste (23 setninger)
- Fjern metainformasjon, navigasjon, annonser og annen støy fra innlimt webinnhold
- Dersom det er tydelig hva kilden er, oppgi den etter innledende oppsummering
- Behold saklig innhold og fakta intakt
- Bruk markdown-formatering der det gir bedre lesbarhet

Denne prompten kan justeres per workspace i Prompt Lab (se docs/features/prompt_lab.md). Men standarden skal være god nok til at brukeren bare kan lime inn og trykke uten å tenke.

Handlingsmeny (lang-trykk eller ▼ ved siden av )

For mer spesifikke behov åpnes en meny:

Handling Hva AI-en gjør
Fiks tekst (standard) Rens, oppsummer, fiks feil, identifiser kilde
Trekk ut fakta Identifiserer påstander, tall, sitater — som separate faktoider (ny melding)
Skriv om for publisering Omskriver til artikkelformat med tittel, ingress, struktur
Oversett Oversetter til valgt språk
Custom Brukerens egne prompts fra Prompt Lab

Revisjon og sporbarhet

Når brukes:

  1. Nåværende innhold lagres i message_revisions (originalen er alltid tilgjengelig)
  2. AI-resultatet erstatter messages.body
  3. messages.metadata oppdateres med { ai_processed: true, ai_action: 'fix_text', ai_prompt_id: '...' }
  4. Brukeren ser resultatet i editoren og kan redigere videre, angre (gå tilbake til revisjon), eller kjøre igjen

Revisjonshistorikken viser tydelig hva som var original og hva som er AI-behandlet. Brukeren kan alltid gå tilbake.

Teknisk

  • POST /api/ai/process med { message_id, action, prompt_id? }
  • Oppretter jobbkø-oppgave (ai_text_process)
  • Rust-worker sender til AI Gateway (http://ai-gateway:4000/v1)
  • Ved "erstatt innhold": lagre revisjon + oppdater messages.body
  • Ved "ny melding": opprett ny melding med reply_to = original_message_id
  • AI-foreslåtte #-mentions vises for bruker-godkjenning før edges opprettes

Kontekst-bevisst (Agentic RAG)

AI-en kjenner konteksten meldingen lever i. Hvis den er i en channel knyttet til #Skolepolitikk, brukes det som hint for å identifisere relevante entiteter og fakta. Workspace-kontekst + graf-nabolag gir bedre resultater enn en kontekstløs prompt.

RAG-berikelse via kunnskapsgrafen: Når brukeren skriver et utkast og nevner #Skolepolitikk, gjør SvelteKit et usynlig vektorsøk (pgvector) i bakgrunnen mot kunnskapsgrafen før prompten sendes til AI Gateway. System-prompten oppdateres dynamisk:

Sidelinja har tidligere etablert disse faktaene om Skolepolitikk:
- [Faktoide 1 fra grafen]
- [Faktoide 2 fra grafen]
- [Relatert segment fra Episode 42]
Ta hensyn til dette i behandlingen.

Resultatet: AI-en ikke bare retter skrivefeil, men fyller inn kontekst spesifikk for redaksjonens kunnskapsbase. Krever pgvector-migrasjon (0006) og generate_embeddings-jobbtype.

Format-kontekst

Ikke alt passer overalt. En emoji-rik chatmelding og en sirlig publisert artikkel har ulike estetiske forventninger:

Chat-kontekst: Alt tillatt. Emojis, GIFs, korte meldinger, uformelt.

Publiserings-kontekst: Editoren tilbyr et "publiseringsfilter" — en valgfri siste-sjekk som flagger potensielle stilbrudd (emojis i overskrifter, manglende alt-tekst på bilder, etc.). Aldri blokkerende — bare forslag.

Publiseringskonteksten tilbyr en "forhåndsvisning som leser" der teksten rendres i den ferdige typografi-stacken (Literata, marginer, sidemerknad-fotnoter) slik at forfatteren ser hvordan det blir. Dette er en tredje visning — read-only, ren leseopplevelse — tilgjengelig via [Forhåndsvis]-knappen i publiserings-modus.

Brukerinnstillinger

Skriftstørrelse, linjehøyde, font, tema og andre visuelle preferanser styres per bruker. Se docs/features/brukerinnstillinger.md for full spec — inkludert datamodell (users.settings JSONB), CSS custom properties, innstillingspanel og editor-spesifikke preferanser (standard Raw/Rendered, stavekontroll, tegnteller).

Lagringsformat

Tiptap JSON som universelt format:

{
  "type": "doc",
  "content": [
    { "type": "paragraph", "content": [
      { "type": "text", "text": "Gini-koeffisienten: " },
      { "type": "math_inline", "attrs": { "latex": "G = \\frac{1}{2n^2\\bar{x}}" } }
    ]},
    { "type": "mention", "attrs": { "id": "uuid", "label": "Skolepolitikk" } }
  ]
}

En enkel "hei" og en 3000-ords artikkel med LaTeX bruker samme format.

Body-strategi:

messages.body        → Tiptap JSON (universelt kildeformat)
messages.metadata    → { body_html: '...', body_format: 'tiptap' }

Bakoverkompatibilitet: Eksisterende ren-tekst-meldinger (der body ikke er gyldig JSON) tolkes som plaintext. Editoren wrapper dem i Tiptap-paragraph ved redigering. Ingen migrering — bare fallback i lesekoden.

Pre-rendret HTML: body_html beregnes ved lagring. Brukes for:

  • Rask visning i feeds og lister (ingen klient-parsing)
  • Publiserte artikler (KaTeX ferdig-rendret, ingen JS for lesere)
  • RSS/Atom-feeds
  • Søkeindeksering

Auto-save og versjonering

Auto-save: 500ms debounce etter siste tastetrykk (identisk med dagens notat-mønster). Visuell feedback: "Lagrer..." → "Lagret [tidspunkt]".

Versjonshistorikk: message_revisions lagrer body ved hver lagring (eller ved signifikant endring — delta-basert for å unngå å lagre hvert tastetrykk). Brukeren kan bla gjennom tidligere versjoner og tilbakestille.

Fremtidig: Navngitte snapshots ("Kladd 1", "Sendt til review", etc.) via metadata på revisjonen. Ikke dag 1.

Keyboard shortcuts

Konsistente overalt:

Ctrl+B          → bold
Ctrl+I          → italic
Ctrl+K          → lenke
Ctrl+Shift+M    → math (LaTeX-blokk)
#               → mention-autocomplete
Tab/Shift+Tab   → innrykk i lister

Enter-oppførsel:

  • Kompakt modus: Enter = send. Shift+Enter = linjeskift.
  • Utvidet/publisering: Enter = ny linje. Ctrl+Enter = eksplisitt lagre (auto-save gjør det uansett).

Toggle:

  • Ctrl+/ → switch mellom Raw og Rendered.

Lazy loading av extensions

Kompakt modus laster bare:

  • Plaintext
  • Mentions (#-autocomplete)
  • Inline formatting (bold, italic, lenke)

Ved Expand lastes:

  • Overskrifter, lister, blokk-quotes
  • Bilder, vedlegg
  • Kodeblokker med syntax highlighting

Ved publiserings-modus lastes:

  • KaTeX (LaTeX-rendering)
  • Podcast-embeds
  • Sidemerknad-fotnoter

God for bundle-størrelse og oppstartstid.

Åpne spørsmål

Tekniske

  • Tiptap JSON vs Markdown som kildeformat? JSON er editor-vennlig. Markdown er portabelt. Anbefaling: JSON som primær, Markdown-import/-eksport som transformasjon.
  • Ytelse: Tiptap JSON for millioner av chatmeldinger? ~60 bytes overhead per melding. Trolig neglisjerbart, men verdt å måle.
  • KaTeX i editoren: Live-rendering av LaTeX krever KaTeX lastet i editoren. ~300KB gzipped. Akseptabelt for utvidet/publisering, for mye for kompakt? Lazy load løser det.
  • Collaborative editing: Tiptap + Yjs er veletablert. Ikke dag 1. Auto-save + message_revisions + optimistic locking (updated_at) er nok initialt.

UX

  • Overgangen kompakt → utvidet: Hvordan føles det? Smooth animasjon? Teksten forblir, toolbar glir inn? Eller instant switch?
  • Autodeteksjon av LaTeX i kompakt modus: Rendres $E=mc^2$ i en kort chatmelding? Ja — rendring er universell, toolbar er kontekstbetinget.
  • Mobile: Toolbar på liten skjerm? Trolig: floating toolbar som dukker opp ved tekstseleksjon (Medium-stil) i stedet for fast toolbar.
  • Paste fra eksterne kilder: Paste av HTML (fra nettside), Markdown (fra Obsidian), ren tekst? Tiptap håndterer HTML-paste. Markdown-paste krever custom paste handler.

Format-kontekst

  • Emoji-filtrering i publisering: For rigid? Brukeren bør ha full frihet. Kanskje bare en visuell advarsel i forhåndsvisning, aldri blokkering.
  • Ulike typografi-profiler? En personlig blogg kan ha annen estetikk enn et magasin. Tema per publikasjon (se artikkel-publisering)?

Innsats: MiddelsStor

Tiptap-integrasjon er rett frem. Autodeteksjon, progressiv toolbar, mentions, LaTeX, podcast-embeds, auto-save, versjonering, mobilopplevelse — summen er betydelig. Bør bygges inkrementelt:

  1. Fase 1: Tiptap med plaintext + mentions + markdown formatting. Kompakt og utvidet modus. Auto-save.
  2. Fase 2: LaTeX (KaTeX), kodeblokker, bilder/vedlegg. Publiserings-modus.
  3. Fase 3: Podcast-embeds, sidemerknad-fotnoter, collaborative editing (Yjs).

Wow-faktor: Høy

En editor der du bare skriver — markdown rendres automatisk, LaTeX rendres automatisk, mentions oppretter graf-koblinger, og alt kan vokse til en publisert artikkel — er en opplevelse de fleste verktøy ikke tilbyr. Det nærmeste er Notion, men uten graf-integrasjon og uten podcast-embeds.

Relasjon til andre proposals og features

  • Tekst-primitiv — filosofien editoren realiserer
  • Artikkel-publisering — publiseringslaget som bruker editoren
  • Personlig workspace — konteksten der editoren brukes daglig
  • Meldingsboks (feature) — datamodellen editoren skriver til
  • Komponerbare sider — maximize gir editoren plass til å gå fra chatboks til fullskjerm skriveverksted
  • Chat (feature) — kompakt modus erstatter dagens chat-input
  • Notater (feature) — utvidet modus erstatter dagens textarea

================================================================ FILE: docs/proposals/emotion_tags.md

Emotion Tags — Hurtigkategorisering av segmenter

Idé

Kort i storyboard/kanban kan tagges med stemnings-ikoner — morsom, seriøs, kontroversiell, personlig. Taggen farger kortets ramme og fungerer som filter i etterarbeid.

Hvorfor interessant?

Under redigering er det nyttig å filtrere på stemning: "vis alle kontroversielle segmenter" eller "vi trenger en morsom bit mellom to tunge tema." Raskere enn å lese alle kortene.

Fungerer slik

  1. Predefinerte tags med ikoner og farger (konfigurerbart per workspace)
  2. Klikk-basert tagging — ett klikk per tag, toggle on/off
  3. Visuelt: farget border + ikon-badge på kortet
  4. Filter: "vis kun 🔥-kort" i storyboard og kanban
  5. Lagres som message.metadata.emotion_tags: string[]

Passer inn i eksisterende

  • Meldingsboks: tags i metadata-feltet, ingen ny tabell
  • Reaksjoner: kan gjenbruke reaksjons-mekanismen (emoji = emotion tag)

Innsats

Lav — ren frontend + metadata-felt.

Wow-faktor

Middels — nyttig for store episoder med mange segmenter.

================================================================ FILE: docs/proposals/flow_meter.md

Flow Meter — Visuell episodeprogresjon

Idé

En tynn progresjonslinje langs toppen av storyboardet som fylles etter hvert som kort dras til "Tatt opp". Grønn = solid episode, gul = trenger mer, rød = for kort. Basert på antall segmenter og total opptakstid.

Hvorfor interessant?

Under innspilling er det lett å miste oversikten over om man har nok materiale. Flow meter gir et intuitivt "mage-sjekk" uten å telle manuelt.

Fungerer slik

  1. Konfigurerbar målvarighet per workspace (f.eks. 45 min)
  2. Summerer varighet for alle "Tatt opp"-kort
  3. Fargeovergang: rød (030%) → gul (3070%) → grønn (70100%)
  4. Valgfritt: pulserer sakte når man nærmer seg mål

Bygger på

  • Storyboard (episode-sekvens med tidsstempler)

Innsats

Lav — én beregning + CSS gradient.

Wow-faktor

Middels — liten ting, men fjerner mental overhead.

================================================================ FILE: docs/proposals/ghost_cards.md

Ghost Cards — Visuelle spor fra tidligere episoder

Idé

Når en episode er ferdig og arkivert, etterlater kort som ble "Tatt opp" svake, semi-transparente spøkelseskort på storyboardet. De fungerer som påminnelser: "vi snakket om dette forrige uke — har vi oppfølging?"

Hvorfor interessant?

Podcast-temaer henger sammen over tid. Ghost cards gir visuell kontinuitet mellom episoder uten manuell sporing. Perfekt for serier og løpende historier.

Fungerer slik

  1. Ved arkivering: kort som var "Tatt opp" får ghost_episode_id i metadata
  2. Ved neste episode: ghost cards vises med opacity: 0.3 og episode-nummer
  3. Klikk på ghost → se original diskusjonstråd og tidsstempel
  4. Dra ghost → promoter til nytt aktivt kort for oppfølging (beholder graf-edge til originalen)
  5. Konfigurerbart: vis ghosts fra siste N episoder (default: 3)

Bygger på

  • Storyboard (visuell rendering)
  • Meldingsboks (message med view-config)
  • Kunnskapsgraf (edge mellom original og oppfølger)

Innsats

LavMiddels — mest metadata-design og UI for ghost-rendering.

Wow-faktor

Høy — gir podcasten "hukommelse" som oppleves magisk for brukeren.

================================================================ FILE: docs/proposals/ghost_host_tts.md

Forslag: Ghost Host (AI Text-to-Speech i Studio)

Idé

Under innspilling kan programlederne trykke "Ghost Host"-knappen. AI-en genererer en kort kommentar (10-15 sek) basert på kunnskapsgrafen og tidligere episoder, og spiller den av med syntetisk stemme direkte i LiveKit-rommet.

"Vegard, du sa akkurat 'det er jo helt bananas', men i episode 17 sa du det samme om vindkraft — skal vi sette inn et klipp?"

Hvorfor

  • Tar live AI-assistenten fra passiv (tekst-popup) til aktiv (snakker med i rommet)
  • Kan gi ikoniske podcast-øyeblikk
  • Unik feature som ingen andre podcast-plattformer har

Bygger på

  • Live AI-assistent (faktoid-oppslag, NER)
  • Kunnskapsgrafen (faktoider, segmenter)
  • LiveKit (lydstrøm)
  • AI Gateway (tekst-generering)

Ny avhengighet

  • Text-to-Speech (TTS) — dette krever ny infrastruktur:
    • Ekstern: ElevenLabs API (kan rutes via LiteLLM?)
    • Lokal: Piper TTS, Coqui TTS, eller Tortoise-TTS (Docker-container)
    • Vurdering: Lokal TTS passer bedre med self-hosted-filosofien, men kvaliteten er vesentlig lavere enn ElevenLabs

Åpne spørsmål

  • Stemme: nøytral syntetisk stemme, eller voice clone av en vert? (etiske implikasjoner)
  • Latens: kan vi generere tekst + TTS + injisere i LiveKit under 3 sekunder?
  • Godkjenning: bør det spilles av direkte, eller vises som "Ghost Host vil si noe" med play-knapp?
  • Kill switch: hva om den sier noe feil live? Trenger en "avbryt"-knapp

================================================================ FILE: docs/proposals/graph_health_monitor.md

Forslag: Knowledge Graph Health Monitor

Innsats: Lav | Wow-faktor: Middels

Idé

En admin-side i SvelteKit som viser "Graf-helse": tetthet, isolerte noder, svake relasjoner, ubalanserte temaer. AI-en foreslår ukentlig nye edges:

"Du har 47 faktoider om Støre og 32 om Ap — skal vi koble dem med WORKS_FOR?" "Dette segmentet nevner vindkraft 9 ganger, men ingen #Tema-knute finnes."

Hvorfor

  • Kunnskapsgrafen vokser organisk, men kan bli rotete uten vedlikehold
  • Gir redaksjonen en "huskeliste" av ting de bør koble manuelt eller godkjenne
  • Synliggjør verdien av grafen — man ser den bli smartere over tid
  • Forhindrer "orphan nodes" som aldri dukker opp i oppslag

Bygger på

  • Kunnskapsgrafen (nodes, graph_edges — rekursive CTEs for å finne isolerte subgrafer)
  • pgvector (allerede planlagt i Kunnskaps-Bridge — brukes for å finne semantisk like noder som mangler eksplisitt kobling)
  • generate_embeddings-jobb (eksisterende jobbtype)
  • Jobbkø (graph_suggest_edges — ny jobbtype, scheduled ukentlig)
  • AI Gateway (sidelinja/resonering for naturlig språk-forslag)

Helsemetrikker

Metrikk SQL-skisse Handlingsforslag
Isolerte noder nodes LEFT JOIN graph_edges ... WHERE edge IS NULL "Koble til tema eller slett"
Temaer uten faktoider themes LEFT JOIN factoids ... HAVING count = 0 "Tomt tema — berik eller arkiver"
Aktører uten relasjoner Samme mønster "Ny aktør — trenger kontekst"
Semantisk like noder pgvector cosine distance < 0.15 "Mulig duplikat — slå sammen?"
Manglende edges AI-analyse av node-par med høy co-occurrence i segmenter "Koble Støre → Ap?"

Dataklassifisering

  • Edge-forslag: Flyktig (TTL 30 dager) — godkjente forslag blir ekte graph_edges
  • Helsemetrikker: Avledet (beregnes on-demand fra grafen)

Åpne spørsmål

  • Skal forslagene dukke opp som "innboks-kort" i Redaksjonen, eller kun på en dedikert admin-side?
  • Terskel for "semantisk lik": hvor lav cosine distance = mulig duplikat?
  • Bør monitoren kjøre per workspace eller globalt (cross-workspace via Bridge)?

================================================================ FILE: docs/proposals/guest_prep_simulator.md

Forslag: Guest Prep Simulator

Innsats: Middels | Wow-faktor: Høy

Idé

Før man sender async-gjest-lenke eller inviterer noen til studio, kan redaksjonen trykke "Simuler gjest". Systemet genererer en 35 min "prøve-debatt" der AI-en spiller gjesten basert på alt som finnes i kunnskapsgrafen om vedkommende (faktoider, tidligere episoder, sitater).

"La meg forberede deg — her er hva 'AI-Erna' svarer når du spør om vindkraft..."

Hvorfor

  • Reduserer "overraskelser" live — programlederne er bedre forberedt
  • Gir bedre spørsmål og motargumenter på forhånd
  • Morsomt å høre AI-versjonen av gjesten svare på dagens tema
  • Naturlig utvidelse av Debate Club-idéen, men som forberedelsesverktøy

Bygger på

  • Kunnskapsgraf + graph_edges (CONTRADICTS, MENTIONS)
  • AI Gateway (sidelinja/resonering — krever nyansert rollespill)
  • Debate Club-forslaget (lignende genereringslogikk)
  • Den Asynkrone Gjesten (konseptuell kobling — simuler før reell invitasjon)

Dataklassifisering

  • Generert script (Markdown): Avledet (kategori 3)
  • TTS-lydfil (valgfritt): Flyktig (TTL 7 dager)

Åpne spørsmål

  • Trenger vi TTS for å gjøre det "levende", eller holder tekst-basert simulering?
  • Etikk: tydelig "AI-simulert"-merking, spesielt hvis TTS brukes
  • Skal simuleringen baseres kun på faktoider vi har, eller kan AI-en fylle inn basert på offentlig kjent posisjon?
  • Kobling til Debate Club: er dette bare en spesialisert variant av samme feature?

================================================================ FILE: docs/proposals/kildevern_modus.md

Forslag: Kildevern-modus (100% lokal LLM)

Idé

Når Møterommet eller en channel brukes til sensitive, upubliserte redaksjonelle diskusjoner, bryter det med kildevernet å sende transkripsjoner til Claude/Gemini — selv via LiteLLM. En toggle for "kildevern-modus" ruter all AI-prosessering til en lokal modell. Data forlater aldri serveren.

Hvorfor er dette interessant?

  • Presseetikk og kildevern er ikke-forhandlbart for seriøse redaksjoner
  • Kan være et differensierende salgspunkt for plattformen
  • LiteLLM støtter allerede Ollama/vLLM som leverandør — arkitekturen er klar

Hva bygger den på?

  • AI Gateway — Ollama/vLLM som ny leverandør i config.yaml
  • Møterommet — kildevern-toggle på channel/rom-nivå
  • Jobbkø — ruting basert på kildevern-flagg

Gjennomføring

  1. Sett opp Ollama eller vLLM som egen Docker-container med en lett, lokal modell (f.eks. Llama-3-8B eller Gemma-2-9B)
  2. Registrer som sidelinja/lokal i LiteLLM config
  3. Channels/møter får en toggle: kildevern: true (lagres i channel-config eller workspaces.settings)
  4. Når flagget er satt, ruter AI Gateway til sidelinja/lokal i stedet for eksterne modeller
  5. UI viser tydelig "Kildevern aktiv — all AI-prosessering skjer lokalt" med visuell indikator

Ressurskrav

  • Lokal 8B-modell krever ~6 GB VRAM (GPU) eller ~8 GB RAM (CPU, saktere)
  • På nåværende server (16 GB RAM) er dette mulig men trangt — compute-separasjon (se docs/infra/jobbkø.md §4.4) gjør det mer komfortabelt
  • Kvaliteten på norsk tekst med 8B-modeller er merkbart lavere enn Claude/Gemini — akseptabelt for oppsummering, ikke for kompleks analyse

Åpne spørsmål

  • Hvor granulært skal kildevern-toggle være? Per channel, per melding, per workspace?
  • Trenger vi et visuelt "sikkerhetsnivå" (grønt/rødt skjold) i UI?
  • Bør kildevern-modus også blokkere ekstern embedding-generering (pgvector)?

Innsats: LavMiddels

Wow-faktor: Høy

================================================================ FILE: docs/proposals/komponerbare_sider.md

Komponerbare sider (Dashboard-komposisjon)

Idé

Brukere ser ferdige sider (Redaksjonen, Studioet, etc.), men admin kan komponere egne sider fra tilgjengelige byggeklosser — chat, kanban, statistikk, graf-visning, whiteboard, osv.

Hvorfor interessant?

Ulike redaksjoner jobber ulikt. Noen vil ha chat + kanban side-om-side, andre vil ha statistikk + research. I stedet for å hardkode alle kombinasjoner, gir vi admin verktøy til å sette opp sider tilpasset teamets arbeidsflyt. Brukerne ser resultatet som ferdige sider i navigasjonen.

Modell

Fase 1: Forhåndsdefinerte layouts (implementeres først)

  • Admin velger fra en katalog av blokker (chat, kanban, statistikk, etc.)
  • Plasserer dem i et grid-layout med forhåndsdefinerte maler (1-kolonne, 2-kolonne, 2+1, etc.)
  • Lagres som JSONB i workspaces.settings (nøkkel pages)
  • Brukere ser sidene i navigasjonen — ingen komposisjon, bare bruk
  • Mobil: blokkene stacker vertikalt (grid kollapser)
// Eksempel: admin-definert side
{
  "slug": "oversikt",
  "title": "Redaksjonsoversikt",
  "layout": "two-column",        // Forhåndsdefinert mal
  "blocks": [
    { "type": "chat", "channel": "general", "span": 1 },
    { "type": "kanban", "board": "episoder", "span": 1 },
    { "type": "stats", "view": "lyttere-7d", "span": 2 }
  ]
}

Fase 2: Konfigurerbare dashboards (senere, ved behov)

  • Brukere kan lage egne personlige dashboards
  • Drag-and-drop for å endre rekkefølge og størrelse
  • Lagres per bruker i workspace_members.dashboard_config JSONB (PG, ikke fil)

Fase 3: Full fri tiling (trolig unødvendig)

  • VS Code / Bloomberg-stil fritt plassering med splitters
  • Ekstremt høy kompleksitet, lav marginalverdi vs. Fase 2
  • Unngå med mindre det er et demonstrert behov

Blokker som resizable containere

Hver blokk på en side er et selvstendig, resizable panel. Brukeren kan:

Resize

Dra i kanten mellom blokker for å justere proporsjoner. Midlertidig (session) eller persistent (lagres i brukerens dashboard_config).

Standard layout:          Etter resize:
┌──────┬──────┐           ┌───┬─────────┐
│ Chat │Kanban│           │   │         │
│      │      │    →      │   │ Kanban  │
│      │      │           │   │         │
└──────┴──────┘           └───┴─────────┘

Maximize

Dobbelklikk på tittellinjen, eller en maximize-knapp — blokken utvider seg til hele tilgjengelig skjermflate. Alle andre blokker kollapses. Trykk Escape eller minimize for å gå tilbake.

Maksimert chat:
┌──────────────────────────┐
│ Chat — #Mediepolitikk  ✕ │
│                          │
│  [full editor,           │
│   full tråd-oversikt,    │
│   full funksjonalitet]   │
│                          │
│                          │
└──────────────────────────┘

Dette er spesielt verdifullt for:

  • Editoren — en chatmelding som vokser til en artikkel trenger plutselig hele skjermen. Maximize gir deg et reelt skriveverksted i stedet for en bitteliten boks.
  • Whiteboard — trenger plass for å være nyttig
  • Graf-visualisering — uleselig i en liten boks
  • Mobil — der skjermplassen er begrenset er maximize den naturlige interaksjonen. Blokkene stacker vertikalt, og du tapper en blokk for å utvide den til fullskjerm.

Mobil-opplevelse

På mobil er default-visningen en stacked liste med kollapsede blokker:

┌──────────────────┐
│ ▼ Chat (3 nye)   │
│ ▼ Kanban         │
│ ▼ Statistikk    │
└──────────────────┘

Tapp en blokk → den ekspanderer til fullskjerm. Swipe ned eller tilbake-knapp → tilbake til oversikten. Editoren i fullskjerm på mobil = et reelt skriveverksted, ikke en liten inputboks nederst.

Teknisk implementering

  • CSS Grid med grid-template-columns/grid-template-rows som justeres via drag
  • Resize via pointer events på grid-gapene (eller et lett bibliotek som svelte-splitpanes)
  • Maximize = CSS position: fixed + z-index overlay, med smooth transition
  • Blokk-størrelse lagres i dashboard_config JSONB: { "blockId": { "span": 1.5 } } eller lignende
  • Midlertidig resize (ikke lagret) = Svelte $state, forsvinner ved refresh

Arkitektur-krav

Hver feature-komponent MÅ bygges som en selvstendig Svelte-komponent som:

  • Tar imot workspaceId (og evt. config-props som channelId, boardId)
  • Håndterer sin egen datahenting og tilstand
  • Respekterer container-størrelse (responsiv innenfor sin blokk, container queries i CSS)
  • Eksponerer en blockMeta-descriptor (tittel, min-bredde, min-høyde, ikon) for katalogen
  • Har en maximized-prop som tilpasser layout (f.eks. editoren viser full toolbar i maximized)

Dette koster ingenting å gjøre fra start og gir full fleksibilitet senere.

Bygger på

  • Workspace-modell, SvelteKit layout
  • Alle feature-komponenter (chat, kanban, whiteboard, statistikk, etc.)
  • Editor (proposal) — maximize gir editoren plass til å være et reelt skriveverksted

Innsats

  • Fase 1: Lav (grid-layout + JSON-config, ingen drag-and-drop)
  • Fase 1.5: Lav (maximize per blokk — relativt enkelt med CSS overlay)
  • Fase 2: Middels (resize, svelte-splitpanes, bruker-lagring)
  • Fase 3: Stor (custom tiling engine — trolig unødvendig)

Wow-faktor

MiddelsHøy. Gir en "dette er MITT verktøy"-følelse. Maximize alene er en stor forbedring — spesielt på mobil der det transformerer opplevelsen fra "begrenset app" til "fullverdig arbeidsflate".

Åpne spørsmål

  • Skal sider være workspace-globale (alle ser samme oppsett) eller per-bruker? → Fase 1: workspace-globale via workspaces.settings JSONB. Fase 2: personlige overrides i workspace_members.dashboard_config JSONB. Alt i PG — ingen filer per bruker.
  • Midlertidig vs persistent resize: bør default være at resize forsvinner ved refresh (session), eller lagres automatisk? → Trolig: auto-lagre med debounce. Brukeren forventer at ting "huskes". En "tilbakestill layout"-knapp for å gå tilbake til admin-default.
  • Bør det finnes et "standard-oppsett" per workspace-type (podcast, nyhetsredaksjon)? → Ja, som templates admin kan velge som utgangspunkt.
  • Keyboard shortcut for maximize? F11 er konvensjon for fullskjerm, men kolliderer med nettleser. Kanskje Ctrl+Shift+F eller dobbelklikk.

Relasjon til andre proposals

  • Editor — maximize gir editoren rom til å gå fra chatinput til fullverdig skriveverksted
  • Personlig workspace — personlige dashboards (fase 2) henger tett sammen med personlig workspace
  • Tekst-primitiv — en melding som vokser trenger plass; maximize er mekanismen som gir den det

================================================================ FILE: docs/proposals/live_audience_qa.md

Forslag: Live Audience Q&A (Studio-integrasjon)

Innsats: Middels | Wow-faktor: Høy

Idé

Under live-innspilling åpner programlederne en "Spør oss"-QR-kode. Tilskuere (eller lyttere på nett) kan sende spørsmål anonymt via en mini-flate. Spørsmålene dukker opp i studio-chatten sortert etter popularitet (real-time voting). AI-en kan foreslå "Svar på dette med faktoid X" i sanntid.

"En lytter spør: 'Hvorfor sa dere det motsatte i episode 17?' — og AI-en har allerede funnet klippet."

Hvorfor

  • Gjør innspilling interaktiv uten å miste kontroll
  • Gir gullmateriale til episoden ("En lytter spurte akkurat...")
  • Kobler publikum direkte inn i studioet — bygger community
  • Kan gjenbruke valgomat-konseptets anonyme deltakelse

Bygger på

  • Valgomat (anonym UUID + SpacetimeDB for sanntids-stemming)
  • LiveKit + studio-chat (spørsmål vises i studio-UI)
  • Kunnskapsgraf (spørsmål kobles automatisk til #Tema via NER)
  • Live AI (kan foreslå relevante faktoider som svar)

Datamodell (skisse)

audience_sessions (
    id UUID,
    workspace_id UUID,
    livekit_room_id TEXT,
    created_at TIMESTAMPTZ,
    closed_at TIMESTAMPTZ
)

audience_questions (
    id UUID,
    session_id UUID,
    anonymous_id UUID,  -- ingen brukerregistrering
    body TEXT,
    votes INT DEFAULT 0,
    status TEXT DEFAULT 'pending',  -- pending / shown / dismissed
    created_at TIMESTAMPTZ
)

Dataklassifisering

  • Spørsmål brukt i episode: Kritisk (PG) — del av episodhistorikken
  • Spørsmål ikke brukt: Flyktig (TTL per sesjon, slettes ved lukking)
  • Anonym ID: Flyktig — ingen kobling til ekte bruker

Åpne spørsmål

  • Moderering: bør spørsmål gjennom en "godkjenn"-queue før de vises i studio?
  • Spam: rate limiting per anonymous_id? Captcha? Eller holder det med manuell moderering?
  • Kan dette utvides til live-polling ("Er dere enige med Erna?") med sanntids-resultater?
  • Kobling til valgomat: bør QA-flaten være en utvidelse av valgomat-UI, eller helt separat?
  • Personvern: lagre IP? Kun for rate limiting, aldri i PG?

================================================================ FILE: docs/proposals/meme_generator.md

Forslag: Møteroms-Meme Generator

Idé

Når whiteboard lukkes i møterommet, kjører en meme_gen-jobb som tar siste 30 sek transkripsjon + whiteboard-PNG og genererer 3 meme-forslag (Impact-font, norske tekster). Lagres som vedlegg i scratchpad-channelen.

Hvorfor

  • Redaksjonen elsker dette allerede i Slack — her blir det innebygd
  • Viser at plattformen har personlighet
  • Utnytter whiteboard + transkripsjon + AI som allerede finnes
  • Lav innsats, høy moro

Bygger på

  • Whiteboard (eksportert PNG)
  • Live transkripsjon (siste 30 sek)
  • AI Gateway (tekst-generering for meme-tekster)
  • Jobbkø (meme_gen-jobb)
  • Chat/channels (vedlegg i scratchpad)

Åpne spørsmål

  • Trenger vi bildegenerering (DALL-E/Stable Diffusion), eller holder det med tekst-overlay på whiteboard-bildet?
  • Impact-font rendering server-side: Rust image-lib, eller ImageMagick i Docker?
  • Opt-in per møte, eller alltid-på?

================================================================ FILE: docs/proposals/personlig_workspace.md

Forslag: Personlig workspace

Superseded: Dette forslaget er erstattet av retningen "noder er sentrum" (se docs/retninger/bruker_ikke_workspace.md). Det finnes ingen workspaces. Privat rom oppstår naturlig: noder uten edges til andre brukere er ditt private rom. Personlig kanban, kalender og notater er bare noder du eier uten delte edges. Dokumentet er bevart som historisk referanse.

Idé

Hver bruker får et personlig workspace som fungerer som en individuell produktivitetssuite. Alle verktøyene et delt workspace har — kanban, kalender, notater, graf-koblinger — men privat og selvorganisert. I tillegg: en personlig publiseringskanal ("blogg") der tekster kan deles med omverdenen.

Hvorfor er dette interessant?

Individuell produktivitet

Redaksjonsmedlemmer trenger et sted å jobbe uforstyrret:

  • Personlige oppgavelister (kanban)
  • Egen kalender (deadlines, påminnelser)
  • Kladder og research-notater
  • Graf-koblinger til temaer og aktører de følger

visibility = 'private' på meldingsbokser innenfor delte workspaces dekker noe av dette, men gir ikke en egen arbeidsflate. Et personlig workspace gir:

  • Eget kanban-brett for personlige oppgaver (ikke synlig for andre)
  • Egen kalender (kan overlappes med delt kalender i UI)
  • Egne notater uten støy fra fellesrommet
  • Egne graf-koblinger og research

Personlig publisering

Med tekst-primitiven (se tekst_primitiv.md) og publiseringsmodellen (se artikkel_publisering.md) kan personlig workspace også være utgangspunkt for en personlig blogg/feed:

  • Skriv en tekst i personlig workspace
  • Publiser den → tilgjengelig på en personlig URL (sidelinja.org/@vegard/...)
  • Teksten kan også plukkes opp av en felles publikasjon (se artikkel-publisering)

Hva bygger den på?

  • Workspace-modellen (RLS, workspace_members) — et personlig workspace er bare et vanlig workspace med én member
  • Meldingsboks — alt er allerede workspace-scopet
  • Tekst-primitiv (proposal) — gir notater en skikkelig editor
  • Artikkel-publisering (proposal) — gir publiseringskanalen

Skisse

Verktøy i personlig workspace

Verktøy Hva det er Bygger på
Oppgaver Personlig kanban-brett kanban_card_view
Kalender Personlig kalender calendar_event_view
Notater/kladder Meldinger med rich text editor Tekst-primitiv
Research Editor AI-knapp + graf-koblinger Kunnskapsgraf, AI gateway
Personlig feed Publiserte tekster med egen URL Artikkel-publisering

Alle disse er eksisterende features brukt i en personlig kontekst. Ingen ny funksjonalitet — bare et eget workspace å bruke dem i.

Opprettelse

Automatisk ved brukerregistrering. Workspacet er implisitt — det dukker opp i workspace-switcheren med et visuelt skille (ikon, farge, eller plassering).

Slug: personal-{authentik_id} (intern), visningsnavn: brukerens display_name.

Workspace-switcher

┌─────────────────────┐
│ 👤 Mitt workspace    │  ← alltid øverst, visuelt adskilt
├─────────────────────┤
│ 📻 Sidelinja         │
│ 🏛️ Foreningen        │
│ ...                  │
└─────────────────────┘

Flytt mellom workspaces

Tre strategier, rangert etter pragmatisme:

  1. Del, ikke flytt (enklest) — endre visibility fra 'private' til 'workspace'. Krever at meldingen allerede bor i mål-workspacet. Fungerer for "jobbe privat i fellesrommet", men ikke for å flytte fra personlig workspace til et delt.

  2. Kopier, ikke flytt (anbefalt) — opprett ny node i mål-workspace, behold original i personlig. Lenke mellom dem med COPIED_FROM-edge. Enkelt, trygt, ingen referanseproblemer.

  3. Flytt atomisk — endre workspace_id på node + alle avhengigheter i én transaksjon. Komplekst: graph_edges, reply_to-kjeder, kanban_card_view-referanser til kolonner i kilde-workspace. Ikke verdt kompleksiteten initialt.

Anbefaling: Start med (2). "Kopier til fellesrom" er en tydelig handling. Originalen forblir i personlig workspace som referanse.

Personlig publisering (avhenger av artikkel-publisering)

Hvert personlig workspace har en implisitt publikasjon (feed). Når en tekst publiseres fra personlig workspace:

  • Den får en article_view med slug og status
  • Den blir tilgjengelig på sidelinja.org/@brukernavn/slug
  • Den dukker opp i brukerens personlige Atom-feed
  • En redaktør i en felles publikasjon kan kuratere den derfra (se artikkel_publisering.md)

Åpne spørsmål

Grense mot delte workspaces

  • Kan et personlig workspace ha flere medlemmer (f.eks. invitere en kollega til å se kanban-brettet)? Eller er det strengt personlig?
  • Pragmatisk: start strengt personlig (1 member). Utvid later hvis behov oppstår.

Kvoter og vekst

  • Eget lagringsbudsjett per personlig workspace?
  • TTL-policy: samme som delte workspaces, eller mer liberal (personlig innhold slettes ikke automatisk)?
  • Trolig: ingen TTL på personlig workspace som default. Brukeren styrer selv.

Dashboard / startside

  • Bør personlig workspace ha et dashboard? F.eks.:
    • Siste notater
    • Kommende kalenderhendelser
    • Kanban-kort med deadline
    • Siste aktivitet i delte workspaces brukeren er med i
  • Eller er det overkill — bare vis verktøyene?

Alternativ: "Visibility er nok"

Det kan fortsatt hende at visibility = 'private' i delte workspaces dekker 80% av behovet. Et personlig workspace er da mest relevant for:

  • Innhold som ikke hører til noe delt workspace
  • Personlig publisering
  • Et "hjem" i appen

Verdt å evaluere etter at visibility og tekst-primitiven er på plass.

Innsats: Lav (opprettelse) / Middels (med publisering og dashboard)

Workspace-opprettelse ved registrering er trivielt. Publiseringslaget avhenger av tekst-primitiv og artikkel-publisering. Dashboard er eget arbeid.

Wow-faktor: Middels-Høy

Alene er det "et privat workspace". Med publisering blir det en personlig plattform — Substack-aktig, men integrert i redaksjonsverktøyet.

Relasjon til andre proposals

  • Tekst-primitiv — gir notater og kladder en skikkelig editor
  • Artikkel-publisering — gir publiseringsmodellen (publikasjoner, kuratorer, feeds)
  • Personlig workspace er konteksten der tekst-primitiven og publisering møtes for individet

================================================================ FILE: docs/proposals/pinboard_mode.md

Pinboard Mode — Fugleperspektiv over episode-arc

Idé

Hurtigtast (f.eks. Ctrl+0) zoomer ut storyboardet til fugleperspektiv. Kort krymper, titler blir store. Du ser hele episodens arc som en visuell flyt: intro → morsom bit → dypt spørsmål → outro. Dra kort som Lego-klosser for å endre rekkefølge.

Hvorfor interessant?

Under innspilling er man "for nær" — man ser enkelt-kort, ikke helheten. Pinboard mode gir 3-sekunders overblikk: "vi har tre morsomme segmenter på rad, vi trenger noe tungt i midten."

Fungerer slik

  1. Toggle via hurtigtast eller knapp
  2. CSS transform: scale(0.4) + økt font-size på titler
  3. Kort viser kun: tittel, status-farge, varighet (hvis tatt opp)
  4. Drag-and-drop endrer rekkefølge i episode-sekvensen
  5. Klikk på kort = zoom tilbake til normalvisning på det kortet

Bygger på

  • Storyboard (episode-sekvens)
  • Kanban (drag-and-drop)

Innsats

Lav — ren CSS/UI-jobb.

Wow-faktor

Høy — visuelt imponerende og genuint nyttig under innspilling.

================================================================ FILE: docs/proposals/podcast_time_machine.md

Forslag: Podcast Time Machine

Idé

Mens programlederne snakker om et tema, vises en "Play past clip"-knapp i studio-UI-et. Trykker du den, streamer systemet et 20-sekunders segment fra en gammel episode der samme aktør/tema ble nevnt.

Morsom variant: "Time Machine Roast" — viser bare de mest selvmotsigende eller pinlige sitatene fra arkivet.

Hvorfor

  • Naturlig utvidelse av segment-søk som allerede finnes
  • Gjør podcast-arkivet til en aktiv ressurs under innspilling
  • Kan gi gullmomenter: "Du sa jo det stikk motsatte i episode 17!"
  • Caddy serverer allerede media med Accept-Ranges: bytes — byte-range streaming fungerer

Bygger på

  • Segmenter med tidsstempler i PG (allerede indeksert med full-text search)
  • DISCUSSED_IN og MENTIONS edges i kunnskapsgrafen
  • Live AI-assistent (NER matcher aktører/temaer → oppslag)
  • Caddy media-servering (byte-range for å streame bare segmentet)

Åpne spørsmål

  • Trenger vi en egen "clip cache" eller kan vi streame direkte fra MP3 med start/end byte offset?
  • Bør AI-en velge det mest relevante klippet, eller det mest underholdende?
  • Kan dette kombineres med Serendipity Roulette til et "throwback"-modus?

================================================================ FILE: docs/proposals/podcasting_2_0.md

Forslag: Podcasting 2.0 — strukturert RSS

Idé

Sidelinja har allerede strukturert data for transkripsjoner (segmenter), kapittelinndeling og personer (aktører i grafen). Mate dette direkte inn i RSS-feeden via Podcasting 2.0-standarden — zero ekstra arbeid for redaksjonen, maks wow i lytterappen.

Hvorfor er dette interessant?

  • Apper som Apple Podcasts og Pocket Casts viser automatisk live-synkronisert teksting
  • Lytteren kan klikke på gjestens navn for profilbilde (fra entities.avatar_url)
  • Kapitlene genereres allerede fra segmenter — bare å eksponere dem i riktig format
  • Nesten null implementeringskostnad — dataen finnes, bare RSS-generatoren mangler tags

Hva bygger den på?

  • Podcastfabrikken — episoder, segmenter, transkripsjoner
  • Kunnskapsgraf — aktører med avatar_url, relasjoner til segmenter
  • RSS-feed — SvelteKit-generert (se docs/arkitektur.md §6)

Podcasting 2.0 tags

Tag Sidelinja-kilde Resultat i lytterapp
<podcast:transcript> SRT fra Git (eller VTT-konvertert) Live tekstssynkronisert teksting
<podcast:person> entities med type = 'person' + avatar_url Gjeste-/vertsprofiler med bilde
<podcast:chapters> Segmenter (tidsstemplet) Klikkbare kapitler
<podcast:soundbite> Aha-markører (hvis implementert) Utvalgte høydepunkter

Gjennomføring

  1. Utvid SvelteKit RSS-generatoren med Podcasting 2.0 namespace: xmlns:podcast="https://podcastindex.org/namespace/1.0"
  2. Per episode: generer <podcast:transcript> med URL til SRT/VTT-fil
  3. Per episode: generer <podcast:person> for aktører koblet til episoden via DISCUSSED_IN/MENTIONS-edges
  4. Per episode: generer <podcast:chapters> fra segmenter

Åpne spørsmål

  • VTT vs SRT for transkripsjoner? VTT er standarden for web, men SRT er vår master. Konvertering er triviell.
  • Hvor mange apper støtter dette faktisk i dag? Nok til at det er verdt det.

Innsats: Lav

Wow-faktor: Høy

================================================================ FILE: docs/proposals/serendipity_roulette.md

Forslag: Serendipity Roulette

Idé

Med jevne mellomrom (konfigurerbart, f.eks. hvert 8. minutt) under innspilling i Studioet, trekker systemet en tilfeldig DISCUSSED_IN-edge fra kunnskapsgrafen — 2-3 ledd unna dagens tema. AI-en presenterer en morsom eller relevant faktoide som en "overraskelse" i studio-UI-et.

"Visste dere at Jonas Gahr Støre i 2011 søkte jobb i AP… samtidig som han var i en episode der dere kalte ham 'den evige kronprinsen'?"

Hvorfor

  • Gir ekte "live co-host"-følelse — systemet bidrar aktivt til samtalen
  • Utnytter kunnskapsgrafen på en måte som belønner at den er godt fylt
  • Kan gi genuint gode podcast-øyeblikk som ellers ikke ville oppstått
  • Kan skrus av med én toggle

Bygger på

  • Kunnskapsgrafen (graph_edges, recursive CTE for 2-3 ledd)
  • Live AI-assistent (SpacetimeDB push til studio-UI)
  • Eksisterende segmenter + faktoider

Åpne spørsmål

  • Trenger vi tekst-til-tale (TTS) for at AI-en "leser høyt", eller holder en tekst-popup?
  • Hvordan unngå gjentakelser? Trenger vi en "already shown"-tabell per sesjon?
  • Bør den være smart nok til å vente på en naturlig pause i samtalen?

================================================================ FILE: docs/proposals/social_posting.md

Forslag: Sosial publisering fra chat

Idé

Post til X (og senere Bluesky/Mastodon) direkte fra Redaksjonens chat. Noen sier noe morsomt eller skarpt — én kommando, og det er ute i verden. Flere teammedlemmer deler én konto uten å dele passord.

Hvorfor er det interessant?

  • Senker terskelen. En podcast-konto på X dør ofte fordi kun én person har tilgang og gidder. Med chat-integrasjon kan hvem som helst foreslå en post, og flyten er én handling — ikke "åpne X, logg inn på riktig konto, skriv, post".
  • Spontanitet. De beste sosiale medie-postene er de som kommer i øyeblikket. Å fange dem rett fra chatten der samtalen skjer bevarer energien.
  • Kontroll uten friksjon. Konfigurerbar godkjenningsflyt per workspace — fra "alle poster fritt" til "admin godkjenner alt".
  • Bygger på det som finnes. Chat, jobbkø, workspace-settings — ingen ny infrastruktur.

Hva bygger den på?

  • Chat/x-kommando som trigger publisering
  • Jobbkøsocial_post-jobbtype med retry ved rate limit
  • Workspace settings — API-nøkler og godkjenningsflyt per workspace
  • Admin-panel — Workspace-innstillinger for plattformer og tilgangsstyring

Flyt

Uten godkjenning (workspace-innstilling: approval: none)

Vegard i chat: "Fantastisk intervju med @støre i dag, han innrømmet at han liker brunost på vaffel"
Marte svarer:  /x
              ┌──────────────────────────────────────┐
              │ Post til X:                          │
              │                                      │
              │ Fantastisk intervju med @støre i     │
              │ dag, han innrømmet at han liker      │
              │ brunost på vaffel 🧇                 │
              │                                      │
              │ 156/280 tegn                         │
              │                                      │
              │ [Rediger]  [Avbryt]  [Post nå]       │
              └──────────────────────────────────────┘
→ Jobb opprettes → Rust-worker poster til X
→ Systemmelding i chat: "Marte postet til X: https://x.com/sidelinja/status/..."

Med godkjenning (workspace-innstilling: approval: required)

Marte:  /x
→ Jobb opprettes med status 'pending_approval'
→ Systemmelding: "Marte foreslår X-post: «...» — reager med ✅ for å godkjenne"
→ Admin reagerer med ✅
→ Jobb endres til 'pending', worker poster
→ Systemmelding oppdateres: "Postet av Marte, godkjent av Lars: https://x.com/..."

Med godkjenning fra hvem som helst med riktig rolle (workspace-innstilling: approval: role)

Samme flyt, men approval_role i settings styrer hvem som kan godkjenne (default: admin+owner).

Workspace-innstillinger

Lagres i workspaces.settings under nøkkelen social:

{
  "social": {
    "platforms": {
      "x": {
        "enabled": true,
        "api_key": "...",
        "api_secret": "...",
        "access_token": "...",
        "access_token_secret": "..."
      }
    },
    "approval": "none",
    "approval_role": "admin",
    "default_hashtags": ["#sidelinja"],
    "max_daily_posts": 20
  }
}

approval-verdier:

Verdi Oppførsel
none Alle workspace-medlemmer kan poste direkte
required Alle poster krever godkjenning
role Poster fra brukere med lavere rolle enn approval_role krever godkjenning

Datamodell

Ingen ny tabell — bruker jobbkøen med job_type = 'social_post':

{
  "job_type": "social_post",
  "payload": {
    "platform": "x",
    "text": "Fantastisk intervju med @støre...",
    "source_message_id": "uuid",
    "suggested_by": "authentik_id",
    "approved_by": null,
    "thread_ids": []
  }
}

For godkjenningsflyt brukes en ekstra jobbstatus. Jobber med approval: required opprettes med status = 'pending_approval' (ny enum-verdi) og endres til pending ved godkjenning.

Admin-panel (workspace-innstillinger)

Denne featuren forutsetter et minimalt admin-panel i SvelteKit (/workspace/settings). Første iterasjon trenger kun:

  • Sosiale kontoer: Koble til X (OAuth-flyt), se tilkoblet konto, fjern
  • Godkjenningsflyt: Velg mellom none / required / role
  • Historikk: Liste over poster sendt fra dette workspacet (fra jobbkø-data)

Admin-panelet vil vokse med andre workspace-innstillinger over tid (Whisper-config, AI-prompts, channel-defaults, etc.), men sosial publisering er et godt første bruksområde.

Plattform-abstraksjon

Chat-kommandoen og jobbkøen vet ikke om X spesifikt. Arkitekturen er:

Chat (/x, /bsky, /social)
    │
    ▼
Jobbkø: social_post { platform: "x", text: "..." }
    │
    ▼
Rust worker: matcher platform → riktig API-klient
    ├── X (OAuth 1.0a, v2 API)
    ├── Bluesky (AT Protocol) [fremtidig]
    └── Mastodon (OAuth 2.0, REST) [fremtidig]

Å legge til en ny plattform er å implementere én ny handler i Rust-workeren + legge til credentials i workspace-settings. Ingen endring i chat-koden.

X API: Tekniske detaljer

  • API: X API v2, POST /2/tweets
  • Auth: OAuth 1.0a (User Context) — krever app + bruker-tokens
  • Rate limit: 200 poster/15 min per bruker (mer enn nok)
  • Tråd-støtte: reply.in_reply_to_tweet_id for tråder
  • Pris: Free tier tillater 1500 poster/mnd. Basic ($100/mnd) gir 3000 poster + lesing. Free er nok for start.

Innsats

LavMiddels — X API-integrasjonen er enkel. Chat-kommandoen er en ny handler. Det meste av arbeidet er UI for preview/redigering-popupen og admin-panelet for innstillinger.

Wow-faktor

Høy — Gjør podcast-kontoen levende uten at noen må "huske å poste". Demokratiserer kontoen uten å dele passord.

Åpne spørsmål

  • Skal /x kunne brukes på en hel tråd (flere meldinger → X-tråd)?
  • Bilder/media i poster — fra chat-vedlegg eller kun tekst i v1?
  • Skal poster inkludere en automatisk lenke tilbake til episoden/temaet fra grafen?
  • Bør det finnes en /draft-kommando som legger posten i en kø uten å sende (planlagt posting)?
  • Varsling i chat når en foreslått post venter på godkjenning for lenge?

================================================================ FILE: docs/proposals/storyboard.md

Storyboard — Fritt canvas for innspillingsplanlegging og live-produksjon

1. Konsept

Storyboardet er et fritt canvas der meldingsboks-kort plasseres, flyttes og grupperes visuelt. Det brukes før, under og etter innspilling — fra idémyldring til ferdig episodestruktur. Ikke et erstatningsverktøy for kanban (som er for langsiktig planlegging), men et taktilt arbeidsflate for å se en episode ta form.

Storyboardet er en consumer av Canvas-primitivet (docs/features/canvas_primitiv.md) og deltar i universell overføring (docs/features/universell_overfoering.md).

2. Kjernemodell

2.1 Kort = Meldingsboks med storyboard-plassering

Alle kort på storyboardet er vanlige messages-noder. Plasseringen på canvaset styres av message_placements-tabellen:

message_placements:
  message_id  → messages.id
  context_type = 'storyboard'
  context_id  → episode_id (eller standalone storyboard-id)
  entered_at  = når kortet ble lagt på boardet
  position    = { "x": 340, "y": 120, "status": "klar" }

Kortet kan samtidig leve i en chat, på et kanban-brett, eller i en kalender. Endringer i chatten (nye svar, redigeringer) propagerer til storyboard-visningen fordi det er samme melding.

2.2 Episode-objekt

Et storyboard er knyttet til en episode (node av type episode) eller eksisterer som frittstående canvas (for idémyldring uten episode-tilknytning).

Episode-noden eier:

  • Sekvensliste: En ordnet liste over kort som er "Tatt opp", med tidsstempel for når de ble markert. Lagres som episode_sequence i SpacetimeDB.
  • Målvarighet: Konfigurerbar per workspace (f.eks. 45 min) — brukes av Flow Meter.

2.3 Statuser

Kort har status (lagret i position-JSONB på plasseringen):

Status Visuelt Betydning
Klar Solid, hvit/lys border Planlagt, venter på tur
Tatt opp Grønn border, tidsstempel-badge Snakket om under innspilling
Droppet Dimmet (opacity 0.4), rød stripe Ikke brukt denne gang
Arkivert Skjult (filter) Ferdig behandlet

Status-endring skjer via:

  • Drag til en status-sone (valgfri — konfigurer per workspace)
  • Hurtigtast: R = Tatt opp, D = Droppet (når kort er valgt)
  • Kontekstmeny
  • Flytende toolbar ved seleksjon

2.4 Flere storyboards

Et workspace kan ha mange storyboards — ett per episode, pluss frittstående for idémyldring. Hver er en StoryboardBlock med props.episodeId (eller props.boardId for frittstående).

Tre episoder under planlegging = tre storyboard-blokker, enten:

  • På samme side (splittet grid-layout)
  • På ulike sider i workspace-navigasjonen

3. Fritt canvas

Storyboardet bruker Canvas-primitivet for all canvas-interaksjon:

  • Pan/zoom: Se canvas_primitiv.md §2
  • Objekt-plassering: Kort har (x, y) i world-space, ingen kolonner eller rader
  • Snap-to-grid: Av som default, toggle med G
  • Ingen akse-begrensning: Brukeren plasserer kort fritt. Ingen antatt retning eller tidslinje

3.1 Kort-rendering

Hvert kort rendres som en <StoryboardCard> inne i canvas-primitivets objekt-slot:

┌─────────────────────────┐
│ 🔵 Tittel               │  ← Status-farge som border
│                         │
│ Kort sammendrag av body │  ← Avkortet tekst
│                         │
│ 💬 3  ⏱ 04:32          │  ← Svar-count + varighet (hvis tatt opp)
└─────────────────────────┘

Kort-størrelse er fast bredde (variabel ved zoom), høyde tilpasser seg innholdet opp til en max.

3.2 Kort-interaksjon

  • Klikk: Velg kort, vis flytende toolbar
  • Dobbeltklikk: Åpne meldingsboksen i utvidet modus (full diskusjonstråd)
  • Drag: Flytt kort på canvaset
  • Høyreklikk: Kontekstmeny (status, send til, fjern, slett)

4. Overføring mellom blokker

Storyboardet deltar fullt i universell overføring (universell_overfoering.md):

4.1 Som sender

Dra et kort ut av storyboard-blokken → ghost følger musepekeren → slipp på annen blokk:

  • → Chat: Melding ankommer som ny chatmelding med entered_at = now()
  • → Kanban: Melding blir kort i første kolonne
  • → Kalender: Melding blir hendelse på dagens dato
  • → Annet storyboard: Melding får ny canvas-posisjon i mål-boardet

4.2 Som mottaker

Storyboardet kan motta fra alle blokk-typer:

  • Default-plassering: Senter av viewport
  • Drag-plassering: Der brukeren slipper objektet på canvaset
  • Status: Nye kort ankommer som "Klar"

4.3 Inter-storyboard overføring

For å flytte kort mellom episoder (f.eks. "dette passer bedre i episode 48"):

  • Dra kortet til en annen storyboard-blokk
  • Eller bruk "Send til..." → velg annet storyboard
  • Plasseringen i kilde-boardet fjernes, ny plassering opprettes i mål-boardet

5. Kort som oppstår under innspilling

Under live innspilling dukker nye idéer opp. Flere veier inn:

Metode Flyt
Hurtigtast (N) Popup for tittel + body → kort plasseres nær senter
Fra chat Skriv melding i studio-chat → "Send til Storyboard"
AI-forslag Live AI foreslår kort basert på transkripsjon (se §8)

Alle veier oppretter en meldingsboks + plassering med status "Klar".

6. Kobling til LiveKit / Studioet

6.1 Tidsstempel ved "Tatt opp"

Når et kort settes til "Tatt opp" under en aktiv innspilling:

  1. Klient spør LiveKit om nåværende innspillings-tidspunkt (offset fra oppstart)
  2. Tidspunktet lagres i plasserings-metadata: position.recorded_at_offset = 1823 (sekunder)
  3. Episode-sekvensen oppdateres med kortet i riktig posisjon

Etter innspilling, når Whisper har prosessert lyden, kan recorded_at_offset matches mot Whisper-segmenter for å koble kort til eksakte transkripsjonsavsnitt.

6.2 Episode-sekvens

#[table(name = episode_sequence_entry, public)]
pub struct EpisodeSequenceEntry {
    #[primary_key]
    pub id: String,
    pub episode_id: String,
    pub message_id: String,
    pub sequence_position: f32,      // REAL for midpoint-innsetting
    pub recorded_at_offset: Option<i64>,  // sekunder fra innspillingsstart
    pub workspace_id: String,
}

Sekvensen er den ordnede listen over "Tatt opp"-kort og blir grunnlaget for episode-strukturen i Podcastfabrikken.

7. Etter innspilling

7.1 Episode-oppsummering

Automatisk generert visning etter innspilling:

Episode 47 — Oppsummering
━━━━━━━━━━━━━━━━━━━━━━━━
1. [00:00] Intro — Kommunevalg 2027          💬 2 svar
2. [04:32] Listekandidatene i Oslo            💬 5 svar
3. [12:15] Valgomaten — første resultater     💬 1 svar
4. [18:40] Debatten om bompenger              💬 3 svar
━━━━━━━━━━━━━━━━━━━━━━━━
Totalt: 23:10 av 45:00 mål (51%)
Droppet: 3 kort (bevart for neste episode)

7.2 Arkivering

Ett-klikks arkivering:

  1. Alle "Tatt opp"-kort settes til "Arkivert"
  2. "Droppet"-kort beholder status (synlige for neste episode som Ghost Cards)
  3. "Klar"-kort beholder status (ubrukte idéer)
  4. Episode-sekvensen fryses (immutable etter arkivering)

8. AI-integrasjon (fremtidig)

Under innspilling kan Live AI foreslå nye kort:

  1. Whisper transkriberer i sanntid
  2. AI analyserer transkripsjon: "det ble nevnt et nytt tema som ikke er på boardet"
  3. AI oppretter et foreslått kort (status: "Foreslått", visuelt distinkt — stiplet border)
  4. Bruker aksepterer → status endres til "Klar"

Dette bygger på Live AI (docs/features/live_ai.md) og er ikke del av MVP.

9. SpacetimeDB-modell

9.1 Tabeller

// Kort-posisjon og status (via universell overføring)
// Bruker message_placement-tabellen — se universell_overfoering.md

// Episode-sekvens (ordnet liste over "Tatt opp"-kort)
#[table(name = episode_sequence_entry, public)]
pub struct EpisodeSequenceEntry {
    #[primary_key]
    pub id: String,
    pub episode_id: String,
    pub message_id: String,
    pub sequence_position: f32,
    pub recorded_at_offset: Option<i64>,
    pub workspace_id: String,
}

9.2 Reducers

#[reducer]
pub fn set_card_status(ctx: &ReducerContext, placement_id: String, status: String) { ... }

#[reducer]
pub fn record_card(ctx: &ReducerContext, episode_id: String, message_id: String, offset: Option<i64>) {
    // Sett status til "Tatt opp" + legg til i episode-sekvens
}

#[reducer]
pub fn reorder_sequence(ctx: &ReducerContext, episode_id: String, entries: Vec<(String, f32)>) { ... }

#[reducer]
pub fn archive_episode(ctx: &ReducerContext, episode_id: String) { ... }

10. Responsivt design

Skjerm Tilpasning
Desktop Full canvas med zoom/pan, hurtigtaster, drag-and-drop
Tablet Touch-gester, flytende toolbar i bunn, "Send til"-meny
Mobil Listevisning av kort (gruppert etter status), "Send til"-meny, ingen canvas

Mobil får en alternativ listevisning fordi fritt canvas på liten skjerm er upraktisk. Listen grupperer kort etter status og lar brukeren endre status via swipe.

11. Bygger på

Feature / Infra Rolle
Canvas-primitiv Fritt canvas med zoom/pan/drag
Meldingsboks Kort = meldinger med view-config
Universell overføring Portal-mekanikk mellom blokker
Kunnskapsgraf Kort er noder, relasjoner mellom kort
SpacetimeDB Sanntidssynk av posisjon og status
Studioet / LiveKit Tidsstempel ved "Tatt opp" under innspilling
Podcastfabrikken Episode-sekvens → redigeringsgrunnlag

12. Innsats

MiddelsStor — canvas-primitivet er det tyngste løftet, men gjenbrukes av whiteboard. Selve storyboard-logikken er middels (status, sekvens, arkivering).

13. Wow-faktor

Høy — dette er "limen" mellom redaksjonelt arbeid og faktisk innspilling. Visuelt imponerende og genuint nyttig.

14. Implementeringsfaser

Fase 1: Fundament

  1. Canvas-primitiv (<Canvas>) med pan, zoom, drag, viewport culling
  2. BlockShell fullskjerm-toggle
  3. message_placements-tabell (PG + SpacetimeDB)
  4. StoryboardBlock registrert i block registry

Fase 2: Kjerne-storyboard

  1. <StoryboardCard> med status-visning og hurtigtaster
  2. Episode-sekvens med SpacetimeDB-synk
  3. Universell overføring: "Send til..." kontekstmeny
  4. Drag-and-drop mellom blokker

Fase 3: Innspillingsintegrasjon

  1. LiveKit-kobling for tidsstempler ved "Tatt opp"
  2. Episode-oppsummering og arkivering
  3. Kort opprettet under innspilling (hurtigtast + fra chat)

Fase 4: Polish og utvidelser

  1. Ghost Cards (forrige episodes kort)
  2. Pinboard Mode (zoom-ut til fugleperspektiv)
  3. Flow Meter (visuell progresjon)
  4. Mobil listevisning
  5. AI-foreslåtte kort under innspilling

15. Åpne spørsmål (parkert)

  • Skal frittstående storyboards (uten episode) ha en egen node-type, eller er de bare episoder uten publiseringsdato?
  • Bør canvaset ha et bakgrunnsmønster (dots/grid) for orientering, eller blank?
  • Hvor mange kort tåler canvaset før ytelsen lider? Sannsynlig grense: 200500 DOM-noder med viewport culling.

16. Instruks for Claude Code

  • Storyboard er en blokk-type (StoryboardBlock.svelte) i block registry
  • Kort-posisjon eies av SpacetimeDB via message_placements — aldri direkte PG fra frontend
  • Episode-sekvens er en egen SpacetimeDB-tabell, ikke metadata på episode-noden
  • Bruk Canvas-primitivet for all canvas-logikk — ikke reimplementer pan/zoom
  • Mobil-fallback (listevisning) er en egen komponent, ikke en "responsiv" versjon av canvaset
  • Status-endring skal alltid gå via SpacetimeDB reducer, aldri direkte state-mutasjon

================================================================ FILE: docs/proposals/tekst_primitiv.md

Forslag: Tekst-primitiv

Idé

Det finnes ingen forskjell mellom en chatmelding og en artikkel — bare ulike stadier av samme ting. Enhver tekst starter som det enkleste ("hei") og kan vokse til hva som helst: få en tittel, bli rik-formatert, dras inn i en kalender, publiseres på web. Alt er samme node, samme primitiv. Brukeren bestemmer aldri "type" på forhånd — de bare skriver, og utvider når det føles naturlig.

Kjerneprinsipp: Enhver melding kan vokse

"hei"                          → ren tekst
"hei, her er mine tanker..."   → brukeren utvider → toolbar dukker opp
   + tittel                    → den har nå et navn
   + overskrifter, lister      → strukturert innhold
   + bilder, LaTeX, embeds     → rikt innhold
   + kanban_card_view          → oppgave
   + calendar_event_view       → kalenderhendelse
   + article_view              → publiserbar artikkel

Ingen "oppgradering", ingen "konvertering", ingen modusskifte i data. Noden er den samme hele veien. View-configs legges til og fjernes fritt — de er additive, aldri destruktive.

Hva dette betyr i praksis

  • En chatmelding i #Mediepolitikk kan få en tittel → den er nå et notat
  • Det notatet kan dras til kanban → det er nå også en oppgave
  • Samme notat kan få article_view → det er nå publiserbart
  • Alt uten å kopiere, flytte, eller miste kontekst (reply_to-kjeden, graf-edges, reaksjoner)

Dette er meldingsboks-filosofien tatt til sin logiske konklusjon: ikke bare "én primitiv, flere views", men "én primitiv som vokser organisk med brukerens intensjon".

Hvorfor er dette interessant?

Rekkefølgeproblemet

Brukeren vet sjelden på forhånd hva en tekst skal bli. Man skriver, tenker, og innser: "dette bør bli noe mer". Ved å la enhver melding vokse forsvinner problemet — veien fra tanke til publisering er bare å legge til, aldri å starte på nytt.

Konsistens

  • Én primitiv i hele systemet (meldingsboksen)
  • #-mentions fungerer overalt — chat, notater, artikler — samme mekanisme
  • Graf-koblinger er universelle — en mention i chat og en mention i en publisert artikkel er identisk
  • Versjonshistorikk (message_revisions) følger med uansett hvor teksten ender opp

Hva bygger den på?

  • Meldingsboks — enhver tekst er en melding (node i grafen)
  • Kunnskapsgraf#-mentions oppretter graf-edges uansett kontekst
  • Message revisions — revisjonshistorikk allerede på plass
  • View-configs — kanban_card_view, calendar_event_view, article_view er bare pekere

Skisse

Visibility-trappen

En melding kan bevege seg gjennom synlighetsnivåer uten å endre identitet:

'private'    → kun forfatter (kladd, personlig notat)
'workspace'  → alle i workspacet (delt internt)
'link'       → hvem som helst med URL-en (Google Docs-modell, ingen indeksering)
'public'     → publisert (indeksert, i feeds, full SEO)

Hvert steg er reversibelt. En publisert artikkel kan trekkes tilbake til 'workspace'. En privat kladd kan deles med én lenke uten å bli offentlig.

Delbare URL-er

Enhver melding med visibility: 'link' eller 'public' får en URL:

sidelinja.org/delt/<uuid>                → enkel deling (lesbar med lenke, ingen SEO)
sidelinja.org/@vegard/<slug>             → publisert artikkel (SEO, feed, OG-tags)
sidelinja.org/pub/<publikasjon>/<slug>   → kuratert artikkel (se artikkel-publisering)

article_view — publiseringslaget

Når en tekst skal publiseres, legges en view-config til:

CREATE TABLE article_view (
    message_id   UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
    slug         TEXT NOT NULL,
    status       TEXT NOT NULL DEFAULT 'draft',  -- 'draft', 'review', 'published', 'archived'
    excerpt      TEXT,
    body_html    TEXT,                           -- Pre-rendret HTML (KaTeX + editor → HTML)
    published_at TIMESTAMPTZ,
    CONSTRAINT unique_slug_per_workspace UNIQUE (message_id)
);

Offentlig vs intern kontekst

En publisert artikkel har to ansikter:

Inne i workspacet: Artikkelen er en melding i en tråd. Den har reply_to til meldingen som startet diskusjonen, interne svar, graf-koblinger. Full kontekst.

Ute på web: Artikkelen er en frittstående tekst. Den interne konteksten er usynlig. Offentlige kommentarer lever i en separat kanal (visibility: 'public') som workspace-medlemmer kan se, men som ikke blander seg med den interne diskusjonen.

article_node (melding i workspace)
├── reply_to → intern forelder (usynlig utenfra)
├── channel: #Mediepolitikk (intern diskusjon)
└── public_comment_channel (offentlige kommentarer, separat)

Åpne spørsmål

Versjoner og kladder

  • Er det nok med message_revisions (lineær historikk), eller trengs navngitte versjoner / snapshots?
  • Bør ulike versjoner kunne ha ulik visibility? ("kladd 1 er privat, kladd 2 er delt med lenke, publisert versjon er offentlig")
  • Eller er det enklere: én tekst, én visibility, revisjonshistorikk under panseret?

Grensen melding ↔ artikkel

  • Teknisk: ingen grense. En melding med article_view er en artikkel.
  • UX-messig: når tilbyr systemet "vil du publisere dette?" Manuelt via meny? Automatisk forslag når teksten når en viss lengde/kompleksitet?

Offentlige kommentarer

  • Hvem kan kommentere? Anonymt, autentisert, inviterte?
  • Enkleste start: ingen offentlige kommentarer. Artikler er read-only for publikum. Diskusjon skjer internt eller via eksterne kanaler.

Innsats: Lav (prinsipp) / Middels (article_view + visibility)

Prinsippet krever ingen ny kode — meldingsboksen støtter det allerede. article_view-tabell og visibility-utvidelse er overkommelig. Den tunge delen er editoren (se editor.md).

Wow-faktor: MiddelsHøy

Filosofien — at enhver tekst kan bli hva som helst — er enableren for personlig workspace, artikkel-publisering, samarbeid og kurasjon.

Relasjon til andre proposals

  • Editor — det tekniske skriveverktøyet som gjør alt dette mulig i UI
  • Personlig workspace — konteksten der brukeren kladder og publiserer
  • Artikkel-publisering — publiseringsmodellen (publikasjoner, kuratorer, feeds) bygger på tekst-primitiven
  • Meldingsboks (feature) — tekst-primitiven er den naturlige utviklingen: "én primitiv som vokser med brukerens intensjon"

================================================================ FILE: docs/proposals/valgomat_roast.md

Forslag: Valgomat Roast Mode

Idé

Når en bruker matcher dårlig mot en politiker i valgomaten, får de en ekstra fane: "Sidelinja Roast". Systemet trekker morsomme sitater fra kunnskapsgrafen der politikeren har motsagt seg selv, eller der brukeren er overraskende enig med noen de ikke forventet.

"Du er 98% uenig med Støre… men her er 3 ganger han har sagt akkurat det samme som deg."

Hvorfor

  • Gjør valgomaten delbar og viral — folk poster roasts på sosiale medier
  • Utnytter CONTRADICTS-edges og faktoider som allerede finnes i grafen
  • Pedagogisk: viser at politikk er mer nyansert enn venstre/høyre

Bygger på

  • Valgomaten (match-algoritme, brukerprofil)
  • Kunnskapsgrafen (faktoider, CONTRADICTS-edges, AI-kandidatprofiler)
  • AI Gateway (generering av "roast"-tekst)

Åpne spørsmål

  • Tone: morsom og spiss, eller saklig og overraskende? Begge?
  • Bør roasts modereres av redaksjonen før de vises?
  • Juss: kan vi "roaste" ekte politikere basert på AI-generert innhold?

================================================================ FILE: docs/proposals/waveforms.md

Forslag: Visuelle Waveforms som UI-primitiv

Idé

Podcast handler om lyd, men i Sidelinja-editoren er lyd foreløpig kun tekst eller en usynlig boks. Generer komprimerte audio-peaks fra lydfiler og rendrer dem som visuelle bølgeformer (SoundCloud-stil) overalt der lyd refereres — i editoren, i segment-embeds, i Podcastfabrikken.

Hvorfor er dette interessant?

  • Gjør manuell redigering og verifisering av transkripsjoner mye mer intuitivt
  • Tidsstempler og Aha-markører kan lyse opp oppå bølgene
  • Visuell representasjon av lyd er forventet i 2026 — ren tekst føles utdatert
  • {{segment:uuid}} i editoren kan rendres som en interaktiv bølgeform i stedet for en flat lydspiller

Hva bygger den på?

  • Podcastfabrikken — lydfilene og segmentene
  • Jobbkø — ny jobbtype generate_waveform
  • Editor{{segment:uuid}}-embeds rendrer waveform i stedet for enkel player
  • Studioet — Aha-markører vises oppå bølgeformen

Gjennomføring

1. Generere peaks

Utvid whisper_transcribe-jobben (eller opprett separat generate_waveform-jobb) til å generere en komprimert array med audio-peaks:

Verktøy: audiowaveform (BBC, C++, rask) eller ffmpeg
Input: MP3/WAV
Output: JSON-array med peaks (f.eks. 800 datapunkter per minutt)
Lagring: media_files.metadata (JSONB) eller separat fil

2. Svelte-komponent: <Waveform>

  • Rendrer peaks som SVG eller Canvas
  • Klikkbar: hopp til tidspunkt i lyden
  • Overlay: Aha-markører, segment-grenser, talerskifter
  • Responsiv: tilpasser seg containerbredde
  • Brukes i: editor-embeds, Podcastfabrikken, segment-visning

3. Editor-integrasjon

{{segment:uuid}} i rendered-modus viser:

┌──────────────────────────────────────────┐
│ ▶ Episode 42, 14:2321:07               │
│ ░▓▓░▓▓▓░░▓▓░▓░░▓▓▓▓░░▓░░▓▓░▓▓▓░░▓▓░▓  │
│     ^Aha          ^Støre nevnt           │
└──────────────────────────────────────────┘

Åpne spørsmål

  • Peaks per minutt: 800 er nok for de fleste visninger. Trenger vi flere nivåer (zoom)?
  • Farger: Mono-farge eller fargekode per taler (krever diarisering)?
  • Lagring: Inline i JSONB eller som separat .json-fil i media/?

Innsats: LavMiddels

Wow-faktor: Høy

================================================================ FILE: docs/proposals/web_clipper.md

Forslag: Web Clipper / "Send til Sidelinja"

Idé

Redaksjonell research skjer oftest utenfor Sidelinja — når man leser VG, Aftenposten, eller PDF-er. En minimal "Send til Sidelinja"-mekanisme lar brukeren sende en URL til sin personlige innboks, der AI-en oppsummerer, trekker ut aktører og lager et ferdig research-klipp.

Hvorfor er dette interessant?

  • Fjerner friksjonen mellom "leste noe interessant" og "la det inn i systemet"
  • Utnytter eksisterende infrastruktur: jobbkø, AI Gateway, kunnskapsgraf
  • Research-klipp lander som meldingsbokser — kan bli kanban-kort, faktoider, artikkel-grunnlag

Hva bygger den på?

  • Jobbkø — ny jobbtype url_ingest
  • AI Gateway — oppsummering og entity-uttrekk
  • Meldingsboks — resultatet er en vanlig melding med #-mentions og graf-edges
  • Kunnskapsgraf — kobler research til eksisterende entiteter
  • Personlig workspace — innboks for ubehandlet research

Gjennomføring

Innsendingsmetoder (velg én eller flere)

  1. Share Target (PWA): SvelteKit PWA registrerer seg som share target på mobil. Brukeren trykker "Del" i nettleseren → Sidelinja mottar URL-en. Krever kun en share_target-entry i manifest.json + et API-endepunkt.
  2. Chrome-utvidelse: Minimal popup med "Send til Sidelinja" + workspace-velger. POST til /api/clip.
  3. Bookmarklet: JavaScript-bookmarklet som sender window.location.href til API-et. Zero install.

Pipeline

URL mottatt via /api/clip
  → Opprett melding i personlig innboks (umiddelbart synlig for bruker)
  → Legg jobb i køen: url_ingest (prioritet 5)
  → Rust-worker:
    1. Hent HTML (eller bruk readability-parser for ren tekst)
    2. Send til AI Gateway (sidelinja/rutine): "Oppsummer, identifiser aktører"
    3. Oppdater meldingen med oppsummering, kilde-URL, foreslåtte #-mentions
    4. Brukeren godkjenner mentions → graf-edges opprettes

Åpne spørsmål

  • Paywall-innhold? Brukeren ser det i nettleseren, men workeren kan ikke hente det. Løsning: Send full tekst fra klienten i stedet for bare URL?
  • Batching? "Legg til 5 artikler i kø" eller én-og-én?
  • Automatisk duplikat-deteksjon? Sjekk om URL-en allerede er klippet.

Innsats: LavMiddels

Wow-faktor: Høy