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>
405 KiB
================================================================ 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, teknologivalgdocs/retninger/— Arkitektoniske teser og vedtatte retninger:README.md— Oversikt og statusstatus_quo.md— Hva v1 var: ambisiøse primitiver, tradisjonell overflaterom_ikke_forum.md— Opplevelse-først, to-lags-modell, administrativ opplevelseuniversell_input.md— Tre primitiver (input, mottak, kommunikasjon), noder+edgesmaskinrommet.md— Rust-orkestrator: fang, prosesser, leverbruker_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, eierskapedges.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 scratchlokal.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 workerjobbkø.md— PostgreSQL-basert køsystem for bakgrunnsjobbersynkronisering.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 SSOgit.sidelinja.org— Forgejo (SSH port 222)vegard.info— Separat nettstedsynops.no— Plattformdomene (reservert, ikke i bruk ennå)
Git
- Repos i Forgejo:
vegard/synops— plattformkode og arkitektur:ssh://git@git.sidelinja.org:222/vegard/synops.gitsidelinja/sidelinja— podcastinnhold:ssh://git@git.sidelinja.org:222/sidelinja/sidelinja.git
- Git-identitet: vegard / vnotnes@pm.me
- Forgejo-bruker: vegard (admin)
- CLI: Bruk
tea, ikkegh(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
- Alt er noder og edges. Ingen separate tabeller for chat, kanban, kalender, notater. Visninger er spørringer mot grafen.
- Tre primitiver: Input (fanger), Mottak (presenterer), Kommunikasjon (samler folk). Alt annet er visninger og edges.
- Maskinrommet orkestrerer alt. Fang, prosesser, lever. Edge-drevet ressursallokering. Tjenester under er utbyttbare.
- Noder er sentrum. Brukere, team, innhold — alt er noder. Du ser dine edges. Tilgang via materialisert tilgangsmatrise.
- Privat er default. Input uten mottaker-edge er privat. Security by design, ikke konfigurasjon.
- 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
- 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. - Relasjoner er edges. Vegard → Sidelinja (
owner). Trond → Sidelinja (member). Møtereferat → møte (belongs_to). - Ingen containere. Hva du ser er summen av dine edges.
- Samlings-noder gir struktur — de er vanlige noder som fungerer som gravitasjonspunkt.
- Privat er default — en node uten edges til andre er kun din.
- 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 hidden → stopp, 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:
- Egne noder (
created_by) — instant - Eksplisitt tilgang via matrisen — indeksert lookup
- 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øk —
tsvectorpånodes.contentognodes.title - Semantisk søk — pgvector for embedding-basert likhet
- Graftraversering — rekursive CTEs, Apache AGE ved behov
- Statistikk — aggregeringer, tidsserier
- Tilgangsmatrise —
node_accessberegnes 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:
- Nå: PG med nodes/edges-tabeller og CTEs
- Når CTEs blir smertefulle: Legg til AGE
- 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
- Noder er sentrum — tilgangsmatrise beregnet fra edge-grafen, speiles til SpacetimeDB
- Universell input og mottak — noder og edges er datamodellen for alle tre primitiver
- Maskinrommet — CAS-pruning, edge-drevet ressursorkestrering, validering før skriving
================================================================ 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 nå.
- 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 visningmetadata.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 på 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?
- Kontekst gir det meste. Du er i en samtale →
belongs_to- edge til samtalen. Du er alene → privat. Dekker 80%. - Eksplisitt handling. Du drar en node til kanban-brettet. Du tagger noe. Du setter en dato.
- 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 nodenmember-edge — kan gi input og mottareader-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:
NULLved 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
ownerelleradmini 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
- Redaksjonen oppretter en "Gjestesesjon" knyttet til et Tema.
- Legger inn spørsmål (tekst) som gjesten skal svare på.
- Systemet genererer en unik, tidsbegrenset URL.
- URL-en sendes til gjesten via e-post, SMS eller chat.
- Gjestens svar (lydmeldinger) dukker opp i Tema-chatten som
voice_memo-meldinger, automatisk transkribert. - Redaksjonen triagerer svarene — kan tagge, klippe inn i episode, eller bruke som research.
2.2 Gjestens side
- Gjesten åpner lenken i mobilnettleseren. Ingen app, ingen konto, ingen registrering.
- Ser en enkel, ren flate: podcast-logo, spørsmålene fra redaksjonen, og en opptaksknapp per spørsmål.
- Trykker record, snakker, trykker stopp. Kan lytte tilbake og ta om igjen.
- Ved innsending lastes lydfilene opp og gjesten ser en bekreftelse.
- 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 = NULLogmetadata.guest_name+metadata.guest_token_idfor 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 (visguest_namei 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:
- Chat-meldinger med
#-tags oppretter automatiskMENTIONS-relasjoner i grafen. - AI-behandling i editoren trekker ut aktører og faktoider fra innlimt tekst (se
docs/proposals/editor.md). - Podcastfabrikken kobler episode-segmenter til temaer og aktører.
- 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)
- Velg autoritativ node (Node A) og duplikat(er) (Node B, C, ...)
- Flytt alle
graph_edgessom peker på Node B → Node A (UPDATE graph_edges SET source_id/target_id = A WHERE ... = B) - Flytt alle
messages-mentions som refererer til Node B → Node A - Legg til
namefra Node B som alias ientities.aliasespå Node A - 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
- En bruker oppretter et møterom i SvelteKit. Alle deltakere kobler seg til via LiveKit (WebRTC).
- 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
- Whisper transkriberer møtet via live transkripsjonspipelinen (se
docs/features/live_transkripsjon.md). - 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
#Temaerog@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.
- 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). - 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ørstwhisper_transcribe, deretteropenrouter_analyze(som trigges automatisk ved fullført transkripsjon). - Transkripsjon (faster-whisper): Rust-worker kaller faster-whisper-server (OpenAI-kompatibelt API,
POST /v1/audio/transcriptions) medresponse_format=srtog mottar SRT direkte. Modell:Systran/faster-whisper-mediummedinitial_prompt(navneliste). - 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.
- 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
- AI-Analyse (OpenRouter): Transkripsjonen sendes til OpenRouter (Claude-modell) for uttrekk av forslag til tittel, sammendrag, show notes og kapittler.
- 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).
- Publisering (PostgreSQL): Ved "Godkjenn" lagres metadataene permanent i databasen.
- 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:
- Under innspilling i LiveKit produserer Whisper chunks i sanntid
- 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)
- Lydfilen lagres i CAS med edge til episode-noden
- 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 ienclosure-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/transcriptionsmedresponse_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_promptsom standard.large-v3+ VAD + prompt for best mulig kvalitet der det er verdt ventetiden. - Viktig:
large-v3KREVERvad_filter=true— uten hallusinerer modellen repeterende tekst. - Språk: Sett
language=noeksplisitt 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
errori 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
- Programlederne åpner studioet i SvelteKit (PWA) og kobler seg til et LiveKit-rom.
- Høykvalitetslyd streames mellom deltakerne via WebRTC.
- I bakgrunnen transkriberer Whisper lydstrømmen i chunks (~5 sek) via live transkripsjonspipelinen (se
docs/features/live_transkripsjon.md). - 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). - Programlederne kan trykke Aha-markør for å markere viktige øyeblikk. Tidsstempelet lagres i SpacetimeDB, koblet til episoden.
- 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
- Bygg SpacetimeDB-lytter i frontend + dummy faktoid-push for å verifisere UI.
- Koble Whisper til et offline lydopptak, kjør NER/oppslag mot PostgreSQL.
- 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
localStorageog brukes mot SpacetimeDB. - UX: Et rent kortstokk-grensesnitt (Tinder-stil swipe). Påstand på forsiden, brukeren velger Enig/Uenig.
- Tre-trinns kalibrering (OKCupid-inspirert):
- Hva mener du?
- Hva ønsker du at kandidaten skal mene?
- 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 feltetconfidence. SpacetimeDB bruker denneconfidence-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 viasidelinja/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_statementsfor 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
- Datamodell: Utvid enum
node_typei PostgreSQL medvalgomat_questionogvalgomat_axis. Brukgraph_edgesmedrelation_type = 'AFFECTS_AXIS'og oppdaterconfidence-feltet for å håndtere crowdsourcet vekting av spørsmål opp mot ulike akser. - SpacetimeDB Reducers: Implementer innkommende events som
SubmitSwipe,SuggestAxis, og match-algoritmen i Rust inne i SpacetimeDB. Pass på at reducere støtter anonymsession_id. - 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 Forbiddenfor uautoriserte og trigger Authentik-flyten. - Explorer API: Bygg aggregerte Materialized Views i PostgreSQL for Sidelinja Explorer for å unngå tung on-the-fly kalkulering av Heatmaps over millioner av rader.
- Jobbtyper: Registrer
valgomat_generate_profileogvalgomat_moderationsom jobbtyper i jobbkøen (sejobbkø.md). Bruksidelinja/rutinesom modellalias. - Versjonering: Spørsmål er append-only. Nye versjoner er nye rader med referanse til forgjengeren — aldri
UPDATEpå 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 | 12–24px (slider) | Gjelder all tekst i appen |
| Linjehøyde | 1.6 | 1.4–2.0 (slider) | Avstand mellom linjer |
| Innholdsbredde | 70ch | 50ch–full (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 | På | Av / På | Nettleserens innebygde stavekontroll |
| Auto-save intervall | 500ms | 500ms–5s | 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 | På | 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.settingsJSONB — 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 50–200 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 (768–1024px) | 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/$derivedfor 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
$stateog$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 automatiskMENTIONS-edges igraph_edgesmellom 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_attachments → media_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
- Bruker trykker og holder (eller toggler) mikrofon-knappen i chat-feltet.
- Nettleseren fanger lyd via
MediaRecorderAPI (WebM/Opus). - Lydklippet sendes til Whisper (
POST /v1/audio/transcriptions,response_format=text,language=no) via SvelteKit server-side. - Transkripsjonen settes inn i meldingsfeltet — brukeren kan redigere før sending.
- 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. Brukerchat.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 framessage_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 medbrowser-guard. - Shared types (
types.ts):ChatConnectioninterface medsend,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.configog slå av/på UI-elementer.channels.configinneholder ogsåwarmup_mode/warmup_valuefor 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:00for 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, aldriT00:00:00(tidssone-felle) - Tilgang styres via
node_access-matrisen - Sjekk
docs/erfaringer/adapter_moenster.mdfor 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.mdfor 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:
- Rust-worker plukker opp jobben fra jobbkøen.
- Bygger en tekst-representasjon av noden (navn, body, tilknyttede faktoider).
- Sender til AI Gateway (
sidelinja/rutine) for embedding-generering. - Lagrer vektoren i pgvector-kolonnen.
- Re-genereres ved vesentlige endringer av noden.
3.3 Cross-context søk
Når en bruker utforsker en node (f.eks. Tema "Skolepolitikk"):
- SvelteKit server-side henter brukerens tilgjengelige samlings-noder fra
node_access-matrisen. - 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; - 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-noderbridge_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_embeddingsbrukersidelinja/rutinesom 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:
- Chat & Notater: En bruker skriver: "Apropos #Hans_Petter_Sjøli, hva var greia med #Arbeiderpartiet?"
- Parsing (Svelte/Rust): Systemet fanger opp de to
#-taggene (som allerede har UUIDs iAktør-tabellen). - 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]
- [Melding UUID] ->
- 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. - 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 mednode_access. nodes-tabellen er obligatorisk. Opprett alltid en rad inodesfø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 vianode_access.UNIQUE(source_id, target_id, relation_type)hindrer duplikater — brukON CONFLICTved upsert.- Graf-spørringer: Bruk
WITH RECURSIVEi 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
- Live transkripsjon (se
docs/features/live_transkripsjon.md) leverer tekst-chunks. - Rust-tjenesten analyserer for egennavn (Named Entity Recognition).
- Lynraskt oppslag i PostgreSQL:
SELECT * FROM factoids JOIN actors... WHERE actor.name = $1. - Treff dytter
LiveFactoidEventinn i SpacetimeDB. - 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
- Under møtet: Whisper transkriberer i chunks. Aha-markører fra deltakerne lagres med tidsstempler.
- Ved møteslutt: En
meeting_summarize-jobb opprettes i jobbkøen (sedocs/infra/jobbkø.md). - 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
#Temaerog@Aktører
- Referatet lagres som melding i relevant Tema-chat. Foreslåtte Kanban-kort vises for godkjenning.
graph_edgesopprettes 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:
- Stopper NER-analyse av nye chunks
- Skjuler faktoid-boksen
- 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
- Lydkilde: LiveKit server-side hooks (live) eller filopplasting (batch).
- Whisper-server:
fedirz/faster-whisper-server(Docker, OpenAI-kompatibelt API). Endepunkt:POST /v1/audio/transcriptions. - Chunking (live): Rust-tjeneste mater lyd i ~5-sekunders chunks.
small-modellen prosesserer ~5x raskere enn sanntid, noe som gir <1s forsinkelse per chunk. - Output: SRT (batch) eller ren tekst (live).
4. Whisper-konfigurasjon
- Språk: Sett
language=noeksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk. large-v3KREVERvad_filter=true— uten hallusinerer modellen repeterende tekst.- Benchmark og modellvalg: Se
docs/concepts/podcastfabrikken.mdseksjon 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_logfor 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 (sedocs/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 | 1–30 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
- Bruker åpner lydmelding-modus (via FAB-knapp, hurtigtast eller fra en channel).
- Trykker record. Nettleseren fanger lyd via
MediaRecorderAPI (WebM/Opus). - Kan valgfritt velge kontekst: et Tema, en Aktør, eller "Usortert".
- Ved stopp lastes lydfilen opp til SvelteKit (streaming for store filer).
- Filen lagres som
media_filei mediamappen (/srv/synops/media/voice/). - En
whisper_transcribe-jobb opprettes i jobbkøen for å gjøre opptaket søkbart. - 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
- Bruker åpner dikteringsmodus (mobil-first, men fungerer på desktop).
- Snakker fritt — kan være flere minutter. Visuell indikator viser at opptak pågår.
- Ved stopp opprettes to jobber i jobbkøen (sekvensielt):
whisper_transcribe— rå transkripsjon (medium+initial_promptfor kvalitet)dictation_cleanup— AI rydder i teksten
- Brukeren ser resultatet: rå transkripsjon og AI-ryddet versjon side om side.
- Kan redigere den ryddede versjonen før lagring.
- 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:
MediaRecorderAPI 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_memoer en nymessage_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:
ider FK tilnodes(id)— alle meldinger er noder i kunnskapsgrafen- Ingen
workspace_id— tilgang styres vianode_access-matrisen channel_ider nullable — notater og standalone-bokser trenger ikke en channeltitleer førsteklasses felt — brukes av kanban-kort, notater, kalenderhendelserpinnedfor manuell fritak fra TTL-slettingvisibilitystyrer synlighet —'shared'(alle med tilgang vianode_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_tooppover - 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/temaerMENTIONS— 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):
- Boks (trådstart)
- Svar på boks
- 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 svar —
metadata.absorbed = true. Svaret kollapses visuelt, ikke slettet. - Kollapser utdaterte svar —
metadata.collapsed = true. Alltid tilgjengelig med klikk. - Fest viktige svar —
metadata.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 (
COUNTpåreply_to) - Stemmer (
SUMframessage_votes) - Antall roller (finnes i
kanban_card_view,calendar_event_view?) - Graf-koblinger (
COUNTfragraph_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
- Skjult fra visning — meldingen forsvinner fra default UI etter TTL (arvet fra channel eller samlings-node).
messages.metadata.hidden_atsettes. - 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: enABOUT-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 avkanban_card_viewcalendar_events→ erstattes avcalendar_event_viewfactoids+factoid_votes→ erstattes avmessages+message_reactionsmessage_votes→ erstattes avmessage_reactionsnotes→ erstattes avmessagesmed tittel
11.2 Tabeller som forblir uendret
kanban_boards,kanban_columns— strukturellecalendars— strukturellchannels— grupperingmessage_revisions— revisjonshistorikkmessage_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:
- Endre
messages-tabellen: Legg tiltitle,pinned.id → nodes(id)beholdes (alle meldinger er allerede noder). - Migrer kanban-kort: For hver
kanban_cards-rad: opprettmessages-rad (medtitle,bodyfra description, gjenbruk eksisterende node-id) +kanban_card_view-rad. - Migrer kalenderhendelser: For hver
calendar_events-rad: opprettmessages-rad +calendar_event_view-rad. - Migrer faktoider: For hver
factoids-rad: opprettmessages-rad (medbody,message_type = 'factoid'). Flyttfactoid_votestilmessage_votes. OpprettABOUT-edges igraph_edges. - Migrer notater: For hver
notes-rad: opprettmessages-rad (medtitle,bodyfra content). - 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 imessagesmed 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_edgesmedABOUT-relasjon til aktør/tema. Én transaksjon. - Notat: INSERT i
nodes+messages(med tittel + body).channel_idpeker 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 framessages→ cascade tilnodesvia 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_atbrukes til UI-feedback, ikke til conflict resolution (ennå)- Sjekk
docs/erfaringer/adapter_moenster.mdfor 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
- Rådata (Caddy): Caddy konfigureres til å skrive access-logs for stien
/media/podcast/*.mp3til en formatert JSON-fil (f.eks./srv/synops/logs/caddy/podcast_access.log). - Logrotate: Standard Linux logrotate arkiverer loggene nattlig.
- Rust Batch Processor (Jobbkø): Statistikkparseren kjøres som en
stats_parse-jobb i den felles jobbkøen (sedocs/infra/jobbkø.md), medscheduled_forsatt 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-Agentmot 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.
- Steg A (Filtrering): Leser JSON-loggen. Fjerner treff fra kjente bots ved å krysjekke
- Lagring (PostgreSQL): Rust-programmet skriver det aggregerte resultatet inn i PostgreSQL (
episode_statstabell med felter fortenant_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_jsonfor 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_statsmerkes medtenant_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)
- Bruker velger en jobbtype (research_clip, whisper_postprocess, metadata_extract, dictation_cleanup, etc.).
- Systemet laster inn gjeldende system-prompt fra samlings-nodens metadata.
- Bruker kan redigere prompten i et tekstfelt.
- Velger testdata: enten paste inn tekst manuelt, eller velg fra faktiske transkripsjoner/artikler brukeren har tilgang til.
- Velger modeller å teste mot (f.eks.
sidelinja/rutine+sidelinja/resonering). - Kjører testen — ser resultatene side-om-side.
- Kan iterere: endre prompten, kjør igjen, sammenlign.
3.2 Batch-evaluering (Promptfoo-integrasjon)
- Bruker velger et lagret testsett (fra
tests/prompts/i Git). - Kjører testsettene mot valgte modeller via AI Gateway.
- Ser en matrise: testcase × modell × resultat, med pass/fail-markering.
- Kan sammenligne mot tidligere kjøringer for å oppdage regresjoner.
3.3 Deploy
Når en prompt er verifisert:
- Bruker klikker "Deploy".
- Prompten skrives til samlings-nodens metadata (
metadata.llm_prompts[jobbtype]). - Alle fremtidige jobber av den typen bruker den nye prompten.
- 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_runsogprompt_historyer 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:
- Fase 1:
message_placementsbrukes for nye kontekster (storyboard, notes) og for overføringsmekanikken - Fase 2: Eksisterende view-configs migreres gradvis til
message_placements(kanban-posisjon, kalender-dato) - Fase 3:
kanban_card_viewogcalendar_event_viewkan fjernes når all logikk bruker placements
2.4 entered_at vs created_at
messages.created_at= når meldingen ble skrevetmessage_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:
- Objektet blir en "ghost" (semi-transparent drag-representasjon)
- Andre blokker som kan motta objektet highlighter sin mottakssone
- 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/onDrophandlers for visuell feedback- Prop for
receiver: BlockReceiverfra 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_ater alltidnow()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ærePART_OFet annet Tema, en Aktør kan værePART_OFen 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) viamessage_attachmentseller 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:
- Les aktive aliaser og deres providers (sortert etter priority)
- Skriv
config.yamltil volum delt med LiteLLM-containeren - Restart LiteLLM (
docker restart ai-gateway) eller sendSIGHUP
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 evalmot 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:
- Rust-worker sender AI-kall via gateway, får tilbake
usagei responsen - Worker skriver rad til
ai_usage_logmed collection_node_id, tokens og modellinfo - 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
pausedmed 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 evalfø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.jsellerdrizzle-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 LOCKEDgir 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-concurrentbegrenser 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 2i HTTP-kall til faster-whisper) for å beskytte lydkvaliteten. Sjekkes via LiveKit room-status før Whisper-kall. - Skalering senere: To nivåer:
- 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. - 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.
- Worker-splitting: Workeren splittes til to binærer fra samme crate (
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_idfra 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
tokiomed 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 medscheduled_forfor 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:
- Validering — tilgangssjekk, unique-constraints, forretningsregler
- SpacetimeDB først — frontend oppdateres umiddelbart (~10μs)
- 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:
- Alle noder
- Alle edges
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 ogSET app.current_workspace_id. Workspace-modellen er erstattet av en node-basert tilgangsmatrise (sedocs/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.orgvegard.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)
- Docker-nettverk: Opprett internt nettverk
sidelinja-net - PostgreSQL: Start, opprett databaser for Authentik og Forgejo, verifiser (
pg_isready) - Caddy: Start med Caddyfile for alle domener, verifiser at HTTPS fungerer
- Authentik: Start, gjennomfør initial setup via
https://auth.sidelinja.org - Forgejo: Start med Authentik som OAuth2-provider, opprett organisasjon og repo
Lag B: Sanntid (krever nettverk)
- SpacetimeDB: Start, verifiser tilkobling
- LiveKit: Start, verifiser at WebRTC fungerer
Lag C: Applikasjon (krever alt over)
- SvelteKit: Bygg og start container, verifiser at frontenden laster
- 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/:
- Opprett admin-konto
- Opprett OAuth2/OpenID Connect-provider for Forgejo
- Opprett OAuth2/OpenID Connect-provider for SvelteKit (senere)
- 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 reposidelinja
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.orgviser Authentik loginhttps://git.sidelinja.orgviser Forgejo, innlogging via Authentik fungerer- PostgreSQL:
docker compose exec postgres pg_isreadyreturnerer OK - SSH-push fra lokal WSL2 til Forgejo fungerer
Lag B-C
https://sidelinja.orglaster SvelteKit-appen (deployet 2025-03-15)https://sidelinja.org/api/healthreturnerer 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.infosvarer- SpacetimeDB: WebSocket-tilkobling fra nettleser fungerer
- LiveKit: Test-rom med video/lyd fungerer
- Media:
curl -I https://sidelinja.org/media/podcast/test.mp3returnererAccept-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 1–2 sider per fil. Fellen først, forklaring etter.
- Bare ting som ikke er åpenbare. Ikke dokumenter at
npm installinstallerer 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 activeConnectioni 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 (
deletedIdsSet, 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_outboxhvert 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:
- Legg til feltet i SpacetimeDB Rust-modul (
spacetimedb/src/lib.rs) - Utvid warmup til å laste feltet fra PG
- Utvid sync til å persistere feltet til PG
- Worker skriver til SpacetimeDB via reducer
- 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:
- Start med PG-adapter. Få hele flyten til å fungere med REST/polling først.
- Lag SpacetimeDB-adapter med warmup. Worker laster data fra PG ved oppstart.
- Bruk samme factory-mønster. Felles interface, env-variabel for valg.
- Legg til warmup-config i
channels.config(eller tilsvarende config-felt). - Test begge adaptere uavhengig før du integrerer i UI-komponenten.
- 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 — brukwithDatabaseName()onConnect-callback mottarDbConnection, ikkeEventContextonConnectError-callback har signatur(ctx, errMessage)dererrMessageer 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.rs — parse_timestamp(), web/src/lib/chat/spacetime.svelte.ts — spacetimeRowToMessage().
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
- Worker starter →
warmup::run()leser kanaler med config fra PG - Per kanal: sjekker
channels.config.warmup_mode(all/messages/days/none) - Kaller
clear_channelreducer (unngår duplikater ved restart) - 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)
- Kaller
load_messagesreducer med JSON-array - Laster også reaksjoner via
load_reactionsreducer
Per-kanal konfigurasjon
- Lagres i
channels.configJSONB: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 (sedocs/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:
- Leser meldingens body fra PG (OK — PG er persistent lager)
- Kaller
set_ai_processingreducer → frontend ser pulsering umiddelbart - Kaller AI Gateway med prompt
- Kaller
ai_update_messagereducer → SpacetimeDB oppdaterer body/metadata/edited_at atomisk, lagrer revisjon, legger outbox-entry - Sync-worker persisterer til PG via
ai_updateaction - Ved feil:
clear_ai_processingreducer 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.ts — browser-guard i factory, web/src/lib/blocks/ChatBlock.svelte — onMount 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.ts — refresh() 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 | Lav–Middels | Middels–Høy | Meldingsboks, view-configs |
| Editor | Middels–Stor | Høy | Tekst-primitiv, Tiptap/ProseMirror, KaTeX |
| Artikkel-publisering | Middels–Stor | Høy | Tekst-primitiv, kunnskapsgraf, Caddy, jobbkø |
| Sosial publisering | Lav–Middels | Høy | Chat, jobbkø, workspace settings |
| Komponerbare sider | Lav (Fase 1) | Middels–Hø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 | Lav–Middels | Høy | Meldingsboks, kunnskapsgraf, prominens-score |
| Personlig workspace | Lav–Middels | Middels–Høy | Workspace-modell, meldingsboks, tekst-primitiv |
| Kildevern-modus | Lav–Middels | Høy | AI Gateway, Ollama/vLLM, Møterommet |
| Podcasting 2.0 | Lav | Høy | Podcastfabrikken, kunnskapsgraf, RSS |
| Web Clipper | Lav–Middels | Høy | Jobbkø, AI Gateway, meldingsboks, kunnskapsgraf |
| Visuelle Waveforms | Lav–Middels | Høy | Podcastfabrikken, jobbkø, editor |
| Innspilling & Storyboard | |||
| Storyboard | Middels–Stor | Høy | Canvas-primitiv, meldingsboks, universell overføring, Studioet, Podcastfabrikken |
| Card Chaining | Lav | Middels | Kunnskapsgraf, Storyboard, AI Gateway |
| Ghost Cards | Lav–Middels | 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.6–1.8), komfortabel lesbredde (60–75 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 rendering —
body_htmliarticle_viewinneholder 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, 60–75ch 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 egenarticle_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:
-
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. -
Offentlig kommentarkanal — en separat channel med
visibility: 'public', knyttet til artikkelen viaarticle_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_revisionsgir 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
- Redaksjonen oppretter en "Live Q&A-sesjon" (spesiell guest_token med
type: 'audience') - QR-kode genereres med kort-URL →
/live/[token] - Publikum åpner, skriver inn kallenavn, tar opp voice memo (maks 30 sek)
- Voice memo lastes opp, Whisper transkriberer, AI matcher mot graf
- 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 15–45 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 5–10 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
#Temaog@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 etterwhisper_postprocess) - AI Gateway (
sidelinja/resoneringfor 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 nå.
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
- Kunnskapsgraf —
MENTIONS-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")
1–3 dager siden → rangert etter prominens (side 2 / bla-ned)
3–7 dager siden → rangert etter prominens (eldre nyheter)
7–30 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: Lav–Middels
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
- Dra kort A ved siden av kort B i storyboard
- System oppretter
graph_edgemedrelation_type: 'sequence'ogorigin: 'proximity' - Valgfritt: AI foreslår overgangssetning basert på begge kortenes innhold
- 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
- Klient tracker hover-tid per kort (lokal state)
- Server aggregerer: antall edits, tråd-lengde, reaksjoner
- Kombinert score → CSS-variabel (
--heat: 0.0–1.0) → glow-effekt - 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
- Klient sender
{ user_id, x, y, page }til SpacetimeDB ved musebevegelse (throttlet til ~10 Hz) - Andre klienter renderer fargede SVG-sirkler med brukernavn
- Prikken fader ut etter 5 sekunder uten bevegelse
- 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:
- Ignorere (ingen handling)
- Markere for oppfølging (Aha-markør)
- 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
- Whisper-chunk → NER-uttrekk (aktører, temaer, påstander)
- Søk i kunnskapsgrafen: finnes det segmenter med samme aktør/tema men motstridende innhold?
- pgvector cosine similarity for semantisk matching + LLM-vurdering via
sidelinja/resonering - 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/resoneringfor 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
debatei grafen, eller bare en melding med spesiellmessage_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/coreheadless + 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:23–21: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:
eller standard markdown bildesyntaks - Responsiv
srcsetgenereres 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 imedia_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 (2–3 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:
- Nåværende innhold lagres i
message_revisions(originalen er alltid tilgjengelig) - AI-resultatet erstatter
messages.body messages.metadataoppdateres med{ ai_processed: true, ai_action: 'fix_text', ai_prompt_id: '...' }- 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/processmed{ 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: Middels–Stor
Tiptap-integrasjon er rett frem. Autodeteksjon, progressiv toolbar, mentions, LaTeX, podcast-embeds, auto-save, versjonering, mobilopplevelse — summen er betydelig. Bør bygges inkrementelt:
- Fase 1: Tiptap med plaintext + mentions + markdown formatting. Kompakt og utvidet modus. Auto-save.
- Fase 2: LaTeX (KaTeX), kodeblokker, bilder/vedlegg. Publiserings-modus.
- 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
- Predefinerte tags med ikoner og farger (konfigurerbart per workspace)
- Klikk-basert tagging — ett klikk per tag, toggle on/off
- Visuelt: farget border + ikon-badge på kortet
- Filter: "vis kun 🔥-kort" i storyboard og kanban
- 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
- Konfigurerbar målvarighet per workspace (f.eks. 45 min)
- Summerer varighet for alle "Tatt opp"-kort
- Fargeovergang: rød (0–30%) → gul (30–70%) → grønn (70–100%)
- 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
- Ved arkivering: kort som var "Tatt opp" får
ghost_episode_idi metadata - Ved neste episode: ghost cards vises med
opacity: 0.3og episode-nummer - Klikk på ghost → se original diskusjonstråd og tidsstempel
- Dra ghost → promoter til nytt aktivt kort for oppfølging (beholder graf-edge til originalen)
- 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
Lav–Middels — 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/resoneringfor 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 3–5 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
- Sett opp Ollama eller vLLM som egen Docker-container med en lett, lokal modell (f.eks. Llama-3-8B eller Gemma-2-9B)
- Registrer som
sidelinja/lokali LiteLLM config - Channels/møter får en toggle:
kildevern: true(lagres i channel-config ellerworkspaces.settings) - Når flagget er satt, ruter AI Gateway til
sidelinja/lokali stedet for eksterne modeller - 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: Lav–Middels
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økkelpages) - 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_configJSONB (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-rowssom 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_configJSONB:{ "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 somchannelId,boardId) - Håndterer sin egen datahenting og tilstand
- Respekterer container-størrelse (responsiv innenfor sin blokk,
container queriesi 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
Middels–Hø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.settingsJSONB. Fase 2: personlige overrides iworkspace_members.dashboard_configJSONB. 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
#Temavia 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:
-
Del, ikke flytt (enklest) — endre
visibilityfra'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. -
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. -
Flytt atomisk — endre
workspace_idpå 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_viewmed 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
- Toggle via hurtigtast eller knapp
- CSS transform:
scale(0.4)+ økt font-size på titler - Kort viser kun: tittel, status-farge, varighet (hvis tatt opp)
- Drag-and-drop endrer rekkefølge i episode-sekvensen
- 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_INogMENTIONSedges 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
- Utvid SvelteKit RSS-generatoren med Podcasting 2.0 namespace:
xmlns:podcast="https://podcastindex.org/namespace/1.0" - Per episode: generer
<podcast:transcript>med URL til SRT/VTT-fil - Per episode: generer
<podcast:person>for aktører koblet til episoden viaDISCUSSED_IN/MENTIONS-edges - 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_idfor tråder - Pris: Free tier tillater 1500 poster/mnd. Basic ($100/mnd) gir 3000 poster + lesing. Free er nok for start.
Innsats
Lav–Middels — 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
/xkunne 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_sequencei 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:
- Klient spør LiveKit om nåværende innspillings-tidspunkt (offset fra oppstart)
- Tidspunktet lagres i plasserings-metadata:
position.recorded_at_offset = 1823(sekunder) - 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:
- Alle "Tatt opp"-kort settes til "Arkivert"
- "Droppet"-kort beholder status (synlige for neste episode som Ghost Cards)
- "Klar"-kort beholder status (ubrukte idéer)
- Episode-sekvensen fryses (immutable etter arkivering)
8. AI-integrasjon (fremtidig)
Under innspilling kan Live AI foreslå nye kort:
- Whisper transkriberer i sanntid
- AI analyserer transkripsjon: "det ble nevnt et nytt tema som ikke er på boardet"
- AI oppretter et foreslått kort (status: "Foreslått", visuelt distinkt — stiplet border)
- 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
Middels–Stor — 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
- Canvas-primitiv (
<Canvas>) med pan, zoom, drag, viewport culling - BlockShell fullskjerm-toggle
message_placements-tabell (PG + SpacetimeDB)StoryboardBlockregistrert i block registry
Fase 2: Kjerne-storyboard
<StoryboardCard>med status-visning og hurtigtaster- Episode-sekvens med SpacetimeDB-synk
- Universell overføring: "Send til..." kontekstmeny
- Drag-and-drop mellom blokker
Fase 3: Innspillingsintegrasjon
- LiveKit-kobling for tidsstempler ved "Tatt opp"
- Episode-oppsummering og arkivering
- Kort opprettet under innspilling (hurtigtast + fra chat)
Fase 4: Polish og utvidelser
- Ghost Cards (forrige episodes kort)
- Pinboard Mode (zoom-ut til fugleperspektiv)
- Flow Meter (visuell progresjon)
- Mobil listevisning
- 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: 200–500 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_viewer 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: Middels–Hø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:23–21: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: Lav–Middels
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)
- 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 imanifest.json+ et API-endepunkt. - Chrome-utvidelse: Minimal popup med "Send til Sidelinja" + workspace-velger. POST til
/api/clip. - Bookmarklet: JavaScript-bookmarklet som sender
window.location.hreftil 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.