From 0a467066baea22feee4288a5c8c708c1b787a30e Mon Sep 17 00:00:00 2001 From: vegard Date: Tue, 17 Mar 2026 06:43:08 +0100 Subject: [PATCH] Synops v2: arkitektur, retninger og dokumentasjon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nystart basert på arkitektonisk innsikt fra Sidelinja v1. Koden er ny, visjon og primitiver er validert gjennom tidligere arbeid. Inneholder: - Komplett arkitekturdokumentasjon (docs/arkitektur.md) - 6 vedtatte retninger (docs/retninger/) - Alle concepts, features, proposals og erfaringer fra v1 - Server-oppsett og drift (docs/setup/) - LiteLLM-konfigurasjon (API-nøkler via env) - Editor.svelte referanse fra v1 Co-Authored-By: Claude Opus 4.6 --- .env.example | 29 + .gitignore | 30 + CLAUDE.md | 129 ++++ config/litellm/config.yaml | 31 + docs/arkitektur.md | 158 ++++ docs/concepts/den_asynkrone_gjesten.md | 125 ++++ docs/concepts/kunnskapsgrafen.md | 52 ++ docs/concepts/møterommet.md | 40 + docs/concepts/podcastfabrikken.md | 130 ++++ docs/concepts/redaksjonen.md | 36 + docs/concepts/studioet.md | 32 + docs/concepts/valgomaten.md | 106 +++ docs/erfaringer/README.md | 21 + docs/erfaringer/adapter_moenster.md | 88 +++ docs/erfaringer/authentik_oidc.md | 52 ++ docs/erfaringer/spacetimedb_integrasjon.md | 183 +++++ docs/erfaringer/svelte5_reaktivitet.md | 73 ++ docs/features/brukerinnstillinger.md | 175 +++++ docs/features/canvas_primitiv.md | 213 ++++++ docs/features/chat.md | 147 ++++ docs/features/kalender.md | 98 +++ docs/features/kanban.md | 59 ++ docs/features/kunnskaps_bridge.md | 78 ++ docs/features/kunnskapsgraf_og_relasjoner.md | 170 +++++ docs/features/live_ai.md | 66 ++ docs/features/live_transkripsjon.md | 40 + docs/features/lydmeldinger.md | 137 ++++ docs/features/meldingsboks.md | 319 ++++++++ docs/features/notater.md | 68 ++ docs/features/podcast_statistikk.md | 30 + docs/features/prompt_lab.md | 101 +++ docs/features/universell_overfoering.md | 202 +++++ docs/features/visuell_graf.md | 20 + docs/features/whiteboard.md | 34 + docs/infra/ai_gateway.md | 249 +++++++ docs/infra/api_grensesnitt.md | 73 ++ docs/infra/jobbkø.md | 140 ++++ docs/infra/synkronisering.md | 176 +++++ docs/proposals/README.md | 64 ++ docs/proposals/artikkel_publisering.md | 194 +++++ docs/proposals/audience_voice_memo.md | 41 + docs/proposals/auto_clipper.md | 26 + docs/proposals/auto_highlight_reel.md | 39 + docs/proposals/avisvisning.md | 230 ++++++ docs/proposals/card_chaining.md | 26 + docs/proposals/card_heat_map.md | 25 + docs/proposals/collaborative_cursors.md | 29 + docs/proposals/contradiction_detector.md | 41 + docs/proposals/debate_club.md | 24 + docs/proposals/editor.md | 385 ++++++++++ docs/proposals/emotion_tags.md | 26 + docs/proposals/flow_meter.md | 24 + docs/proposals/ghost_cards.md | 27 + docs/proposals/ghost_host_tts.md | 29 + docs/proposals/graph_health_monitor.md | 39 + docs/proposals/guest_prep_simulator.md | 29 + docs/proposals/kildevern_modus.md | 34 + docs/proposals/komponerbare_sider.md | 136 ++++ docs/proposals/live_audience_qa.md | 52 ++ docs/proposals/meme_generator.md | 22 + docs/proposals/personlig_workspace.md | 117 +++ docs/proposals/pinboard_mode.md | 26 + docs/proposals/podcast_time_machine.md | 23 + docs/proposals/podcasting_2_0.md | 37 + docs/proposals/serendipity_roulette.md | 22 + docs/proposals/social_posting.md | 150 ++++ docs/proposals/storyboard.md | 298 ++++++++ docs/proposals/tekst_primitiv.md | 122 +++ docs/proposals/valgomat_roast.md | 21 + docs/proposals/waveforms.md | 53 ++ docs/proposals/web_clipper.md | 43 ++ docs/retninger/README.md | 36 + docs/retninger/bruker_ikke_workspace.md | 154 ++++ docs/retninger/datalaget.md | 182 +++++ docs/retninger/maskinrommet.md | 322 ++++++++ docs/retninger/rom_ikke_forum.md | 211 ++++++ docs/retninger/status_quo.md | 72 ++ docs/retninger/universell_input.md | 299 ++++++++ docs/setup/lokal.md | 84 +++ docs/setup/migration_safety.md | 148 ++++ docs/setup/produksjon.md | 424 +++++++++++ ops/README.md | 20 + ops/doc-audit.md | 46 ++ ops/drift-sjekk.md | 45 ++ ops/ryddejobb.md | 65 ++ reference/Editor.v1.svelte | 741 +++++++++++++++++++ reference/server-state.md | 73 ++ scripts/summary.sh | 47 ++ 88 files changed, 9333 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 config/litellm/config.yaml create mode 100644 docs/arkitektur.md create mode 100644 docs/concepts/den_asynkrone_gjesten.md create mode 100644 docs/concepts/kunnskapsgrafen.md create mode 100644 docs/concepts/møterommet.md create mode 100644 docs/concepts/podcastfabrikken.md create mode 100644 docs/concepts/redaksjonen.md create mode 100644 docs/concepts/studioet.md create mode 100644 docs/concepts/valgomaten.md create mode 100644 docs/erfaringer/README.md create mode 100644 docs/erfaringer/adapter_moenster.md create mode 100644 docs/erfaringer/authentik_oidc.md create mode 100644 docs/erfaringer/spacetimedb_integrasjon.md create mode 100644 docs/erfaringer/svelte5_reaktivitet.md create mode 100644 docs/features/brukerinnstillinger.md create mode 100644 docs/features/canvas_primitiv.md create mode 100644 docs/features/chat.md create mode 100644 docs/features/kalender.md create mode 100644 docs/features/kanban.md create mode 100644 docs/features/kunnskaps_bridge.md create mode 100644 docs/features/kunnskapsgraf_og_relasjoner.md create mode 100644 docs/features/live_ai.md create mode 100644 docs/features/live_transkripsjon.md create mode 100644 docs/features/lydmeldinger.md create mode 100644 docs/features/meldingsboks.md create mode 100644 docs/features/notater.md create mode 100644 docs/features/podcast_statistikk.md create mode 100644 docs/features/prompt_lab.md create mode 100644 docs/features/universell_overfoering.md create mode 100644 docs/features/visuell_graf.md create mode 100644 docs/features/whiteboard.md create mode 100644 docs/infra/ai_gateway.md create mode 100644 docs/infra/api_grensesnitt.md create mode 100644 docs/infra/jobbkø.md create mode 100644 docs/infra/synkronisering.md create mode 100644 docs/proposals/README.md create mode 100644 docs/proposals/artikkel_publisering.md create mode 100644 docs/proposals/audience_voice_memo.md create mode 100644 docs/proposals/auto_clipper.md create mode 100644 docs/proposals/auto_highlight_reel.md create mode 100644 docs/proposals/avisvisning.md create mode 100644 docs/proposals/card_chaining.md create mode 100644 docs/proposals/card_heat_map.md create mode 100644 docs/proposals/collaborative_cursors.md create mode 100644 docs/proposals/contradiction_detector.md create mode 100644 docs/proposals/debate_club.md create mode 100644 docs/proposals/editor.md create mode 100644 docs/proposals/emotion_tags.md create mode 100644 docs/proposals/flow_meter.md create mode 100644 docs/proposals/ghost_cards.md create mode 100644 docs/proposals/ghost_host_tts.md create mode 100644 docs/proposals/graph_health_monitor.md create mode 100644 docs/proposals/guest_prep_simulator.md create mode 100644 docs/proposals/kildevern_modus.md create mode 100644 docs/proposals/komponerbare_sider.md create mode 100644 docs/proposals/live_audience_qa.md create mode 100644 docs/proposals/meme_generator.md create mode 100644 docs/proposals/personlig_workspace.md create mode 100644 docs/proposals/pinboard_mode.md create mode 100644 docs/proposals/podcast_time_machine.md create mode 100644 docs/proposals/podcasting_2_0.md create mode 100644 docs/proposals/serendipity_roulette.md create mode 100644 docs/proposals/social_posting.md create mode 100644 docs/proposals/storyboard.md create mode 100644 docs/proposals/tekst_primitiv.md create mode 100644 docs/proposals/valgomat_roast.md create mode 100644 docs/proposals/waveforms.md create mode 100644 docs/proposals/web_clipper.md create mode 100644 docs/retninger/README.md create mode 100644 docs/retninger/bruker_ikke_workspace.md create mode 100644 docs/retninger/datalaget.md create mode 100644 docs/retninger/maskinrommet.md create mode 100644 docs/retninger/rom_ikke_forum.md create mode 100644 docs/retninger/status_quo.md create mode 100644 docs/retninger/universell_input.md create mode 100644 docs/setup/lokal.md create mode 100644 docs/setup/migration_safety.md create mode 100644 docs/setup/produksjon.md create mode 100644 ops/README.md create mode 100644 ops/doc-audit.md create mode 100644 ops/drift-sjekk.md create mode 100644 ops/ryddejobb.md create mode 100644 reference/Editor.v1.svelte create mode 100644 reference/server-state.md create mode 100755 scripts/summary.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af76cae --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# === Sidelinja v2 — Lokalt utviklingsmiljø === +# Kopier til .env.local og fyll inn verdier: +# cp .env.example .env.local +# +# .env.local er gitignored og skal ALDRI committes. + +# === AI-leverandører (sett dine egne nøkler) === +GEMINI_API_KEY= +XAI_API_KEY= +# ANTHROPIC_API_KEY= +# OPENROUTER_API_KEY= + +# === AI Gateway (LiteLLM) === +LITELLM_MASTER_KEY=sk-sidelinja-dev-1234 + +# === Authentik OIDC === +AUTHENTIK_ISSUER=https://auth.sidelinja.org/application/o/sidelinja/ +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= + +# === SvelteKit === +AUTH_SECRET= # openssl rand -base64 33 + +# === SpacetimeDB === +# URL til SpacetimeDB-instansen (server eller lokal) +VITE_SPACETIMEDB_URL=ws://sidelinja.org/spacetime + +# === Database (kun ved lokal PG, ellers via server) === +# DATABASE_URL=postgres://sidelinja:localdev@localhost:5432/sidelinja diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9f4599 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Miljøvariabler (aldri i Git) +.env.local +.env + +# Docker-volumer (flyktige) +.docker-data/ + +# Scratch (testfiler, notater, midlertidig) +.scratch/ + +# Oppgaver for agenter (plukkliste, ikke i git) +tasks/ + +# Node +node_modules/ + +# Rust +target/ + +# SvelteKit build +.svelte-kit/ +build/ + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..79550ba --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# 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. +- **Testmiljø:** `./dev.sh` er den kanoniske måten å starte utviklingsmiljøet. + Når nye steg eller oppsett-quirks oppdages, oppdater alltid `dev.sh`. + Før Vegard tester i browser: kjør `./dev.sh`, verifiser med + `cargo check`/`svelte-check`/`curl`, og meld tilbake at det er klart. +- **Browser-testing:** Claude har ikke tilgang til browser. Visuell testing + gjøres av Vegard. Claude verifiserer backend (kompilering, API, DB-state). +- **Utvikling mot server.** Ingen lokale databaser eller tjenester. + Frontend utvikles lokalt mot server-API. Rust bygges lokalt, + deployes til server for integrasjonstest. +- **Commit og push:** Bruk egen vurdering. Trygt og reverserbart. +- **Deploy til produksjon:** Krever alltid eksplisitt godkjenning fra Vegard. +- **Diskusjon:** Forklar og diskuter før arkitekturendringer. + For implementering innenfor eksisterende spec — bare kjør. + +## Dokumentasjonstre +CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`: + +- `docs/arkitektur.md` — Overordnet arkitektur, lagmodell, teknologivalg +- `docs/retninger/` — Arkitektoniske teser og vedtatte retninger: + - `README.md` — Oversikt og status + - `status_quo.md` — Hva v1 var: ambisiøse primitiver, tradisjonell overflate + - `rom_ikke_forum.md` — Opplevelse-først, to-lags-modell, administrativ opplevelse + - `universell_input.md` — Tre primitiver (input, mottak, kommunikasjon), noder+edges + - `maskinrommet.md` — Rust-orkestrator: fang, prosesser, lever + - `bruker_ikke_workspace.md` — Brukeren er sentrum, samlings-noder med RLS-siloer + - `datalaget.md` — PG(+AGE) som graf og arkiv, SpacetimeDB som sanntidslag +- `docs/primitiver/` — Spesifikasjoner for kjerneprimitivene: + - (kommer: input, mottak, kommunikasjonsnode, node, edge) +- `docs/concepts/` — Brukeropplevelser/produktområder: + - `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`, + `kunnskapsgrafen.md`, `valgomaten.md`, `den_asynkrone_gjesten.md` +- `docs/features/` — Tekniske byggeklosser: + - Se individuelle filer for chat, kanban, kalender, meldingsboks, + kunnskapsgraf, whiteboard, live transkripsjon, m.fl. +- `docs/proposals/` — Idébank med 32+ uimplementerte forslag (se README.md) +- `docs/setup/` — Oppsett og drift: + - `produksjon.md` — Steg-for-steg oppsett av Hetzner VPS fra scratch + - `lokal.md` — Lokalt utviklingsmiljø (WSL2, mot server) + - `migration_safety.md` — Sjekkliste for PostgreSQL-migrasjoner (RLS-verifisering) +- `docs/infra/` — Infrastruktur og drift: + - `ai_gateway.md` — LiteLLM som sentralisert AI-ruter (BYOK + fallback) + - `api_grensesnitt.md` — Kommunikasjonskart: SvelteKit er web-API, Rust er worker + - `jobbkø.md` — PostgreSQL-basert køsystem for bakgrunnsjobber + - `synkronisering.md` — PostgreSQL ↔ SpacetimeDB dataflyt og eierskapsmodell +- `docs/erfaringer/` — Lærdommer fra v1 (adapter-mønster, Svelte 5, SpacetimeDB, Authentik) +- `reference/` — Kode fra v1 med gjenbruksverdi (Editor.svelte) +- `ops/` — Repeterbare vedlikeholdsjobber (ryddejobb, doc-audit, drift-sjekk) + +## Aktører +- **Vegard** — serveradmin, utvikler, bruker. SSH: `vegard@157.180.81.26` +- **Claude** — AI-agent, utvikler. SSH: `claude@157.180.81.26` +- Begge har sudo + docker-tilgang. Tjenestebruker `sidelinja` eier filer/Docker. + +## Stack +- **Orkestrator/Backend:** Rust (maskinrommet) +- **Frontend:** SvelteKit (TypeScript, PWA) +- **Sanntid:** SpacetimeDB +- **Database/Graf:** PostgreSQL (+Apache AGE ved behov) +- **Binærlagring:** CAS (content-addressable store) +- **AI:** LiteLLM (AI Gateway), faster-whisper (STT), ElevenLabs (TTS) +- **Infra:** Docker Compose, Caddy, Authentik (SSO) + +## Produksjonsserver +- **IP:** 157.180.81.26 +- **SSH:** `ssh vegard@157.180.81.26` / `ssh claude@157.180.81.26` +- **Root-login:** Deaktivert +- **SSH-nøkkel (lokal WSL2):** `/home/vegard/.ssh/id_ed25519` +- **Server-filer:** `/srv/synops/` (docker-compose.yml, .env, config/, data/) +- **Domener:** + - `sidelinja.org` — Tenant-app (Sidelinja podcastredaksjonen) + - `auth.sidelinja.org` — Authentik SSO + - `git.sidelinja.org` — Forgejo (SSH port 222) + - `vegard.info` — Separat nettsted + - `synops.no` — Plattformdomene (reservert, ikke i bruk ennå) + +## Git +- **Forgejo-org:** `sidelinja` +- **Repos:** + - `synops` — plattformkode og arkitektur + - `sidelinja` — podcastinnhold: `ssh://git@git.sidelinja.org:222/sidelinja/sidelinja.git` +- **Git-identitet:** vegard / vnotnes@pm.me +- **Forgejo-bruker:** vegard (admin) +- **CLI:** Bruk `tea`, ikke `gh` (vi bruker Forgejo, ikke GitHub) + +## Viktige regler +- Aldri eksponere databaseporter mot internett +- All AI-trafikk via maskinrommet, aldri direkte til leverandør-APIer +- Tunge jobber (Whisper, LLM, TTS) blokkerer aldri brukerforespørsler +- Sjekk alltid relevant doc i `docs/` før implementering +- Dokumenter lærdommer i `docs/erfaringer/` + +## Lagmodell +``` +GUI (SvelteKit) + │ skriv │ les (sanntid, direkte WebSocket) + ▼ ▼ +Maskinrommet (Rust) SpacetimeDB ──→ GUI + │ + ▼ +Tjenester: PG+AGE, SpacetimeDB, CAS, Whisper, LiteLLM, LiveKit ... +``` + +## Kjerneprinsipper +1. **Alt er noder og edges.** Ingen separate tabeller for chat, kanban, + kalender, notater. Visninger er spørringer mot grafen. +2. **Tre primitiver:** Input (fanger), Mottak (presenterer), Kommunikasjon + (samler folk). Alt annet er visninger og edges. +3. **Maskinrommet orkestrerer alt.** Fang, prosesser, lever. Edge-drevet + ressursallokering. Tjenester under er utbyttbare. +4. **Brukeren er sentrum.** Ingen workspace-velger. Du ser dine edges. + Samlings-noder gir felles kontekst med RLS-siloer under. +5. **Privat er default.** Input uten mottaker-edge er privat. Security + by design, ikke konfigurasjon. +6. **PG er arkivet, SpacetimeDB er nåtid.** Ingen eierskapskonflikt. + To lag, to roller. diff --git a/config/litellm/config.yaml b/config/litellm/config.yaml new file mode 100644 index 0000000..a262017 --- /dev/null +++ b/config/litellm/config.yaml @@ -0,0 +1,31 @@ +model_list: + - model_name: "kjapp" + litellm_params: + model: "xai/grok-4-1-fast-non-reasoning" + api_key: "os.environ/XAI_API_KEY" + - model_name: "kjapp" + litellm_params: + model: "xai/grok-3-mini" + api_key: "os.environ/XAI_API_KEY" + - model_name: "kjapp" + litellm_params: + model: "gemini/gemini-2.5-flash-lite" + api_key: "os.environ/GEMINI_API_KEY" + - model_name: "kjapp" + litellm_params: + model: "gemini/gemini-flash-lite-latest" + api_key: "os.environ/GEMINI_API_KEY" + - model_name: "kjapp" + litellm_params: + model: "gemini/gemini-flash-latest" + api_key: "os.environ/GEMINI_API_KEY" + +router_settings: + routing_strategy: "simple-shuffle" + num_retries: 2 + timeout: 60 + allowed_fails: 1 + retry_after: 5 + +general_settings: + master_key: "os.environ/LITELLM_MASTER_KEY" diff --git a/docs/arkitektur.md b/docs/arkitektur.md new file mode 100644 index 0000000..322dfa4 --- /dev/null +++ b/docs/arkitektur.md @@ -0,0 +1,158 @@ +# Arkitektur — Sidelinja v2 + +## Visjon + +Sidelinja er en plattform for redaksjonelt arbeid og podcast-produksjon. +Ikke en webapp med features — en plattform med primitiver som kan bli +hva som helst. + +Alt er noder og edges i en graf. Input fanger, mottak presenterer, +kommunikasjonsnoder samler folk. Maskinrommet orkestrerer tekniske +tjenester under. Frontend er et tynt lag som viser grafen. + +## Lagmodell + +``` +┌─────────────────────────────────────┐ +│ GUI (SvelteKit) │ +│ Visninger: spørringer mot grafen │ +└────────┬──────────────────┬─────────┘ + │ skriv │ les (sanntid) + │ │ direkte WebSocket +┌────────▼────────┐ ┌─────▼─────────┐ +│ Maskinrommet │ │ SpacetimeDB │ +│ (Rust) │ │ Aktive noder │ +│ Orkestrering │ └───────────────┘ +└──┬─────┬─────┬──┘ + │ │ │ + ▼ ▼ ▼ +┌─────┐┌─────┐┌─────┐┌─────────────┐ +│ PG ││STDB ││ CAS ││ Whisper, │ +│ ││(skr)││ ││ LiteLLM, │ +│ ││ ││ ││ LiveKit ... │ +└─────┘└─────┘└─────┘└─────────────┘ +``` + +### Skrivestien +GUI → Maskinrommet (Rust) → tjenester + +All orkestrering, edge-logikk, validering og ressursallokering +går gjennom Rust. Maskinrommet leser edges og bestemmer hvilke +tjenester som trigges. + +### Lesestien (sanntid) +SpacetimeDB → GUI (direkte WebSocket) + +STDB klient-SDK gir ~10μs-oppdateringer med lokal cache. +En bevisst optimering — ikke et hull i lagmodellen. + +### Lesestien (tradisjonell) +GUI → Maskinrommet (Rust) → PG + +Søk, historikk, statistikk, arkiv. Cypher via AGE for +graftraversering når CTEs ikke holder. + +## Datamodell + +### Noder +Én tabell. Alt innhold er noder: meldinger, oppgaver, notater, +faktoider, mediefiler, kommunikasjonsrom, samlings-noder, brukere. + +Felles skjema: id, innhold, created_at, author, content_hash (→ CAS). +Modalitetsspesifikk metadata i JSONB. + +### Edges +Én tabell. Alle relasjoner er edges: tilhørighet, tilgang, type, +synlighet, rolle, status. + +Hva en node "er" bestemmes utelukkende av 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 + edge til topic = faktoid +- Node uten edges = løs tanke + +### Visninger +Visninger er spørringer mot grafen med ulike 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 + +## Tre primitiver + +### 1. Input +Én multimodal overflate som fanger alt: tekst, lyd, bilde, AI, +URL. To versjoner — sanntid (STDB-lag) og flat (PG-lag). +Output er alltid en node. + +### 2. Mottak +Én personlig flate som presenterer alt tilpasset deg. Format +bestemmes av mottaker (tekst/lyd). Filtrering via dine edges. +Sanntid (stream) eller asynkront (digest). + +### 3. Kommunikasjon +En node som samler folk med tilgangsregler. Samme type fra +én-til-én-samtale til livesending. Forskjellen er edges: +eier, input-tilgang, mottak-tilgang, rolle. + +## Maskinrommet + +Rust-tjeneste med tre operasjoner: **fang**, **prosesser**, **lever**. + +Edge-drevet ressursorkestrering: maskinrommet leser edges og +bestemmer hvilke tjenester som spinnes opp. Dagboknotat = bare +transkriber. Livesending = transkriber + LiveKit + AI + mediaprosessering. + +Forvalter også CAS (binærlagring) med intelligent pruning basert +på modalitet, edges og aksessmønstre. + +## Sikkerhet + +### RLS-siloer +Samlings-noder (det som var workspaces) er harde sikkerhetsgenser +i PG med RLS. `workspace_id = current_setting(...)` — instant, +vanntett. Edge-basert tilgang er UX *innenfor* siloen. + +### Privat er default +Input uten mottaker-edge er automatisk privat. Ingen ser det. +Deling er å legge til edges. + +## Datalag + +### PostgreSQL +Arkiv og graf. Alle noder og edges. Fulltekstsøk, pgvector +(semantisk søk), JSONB. Apache AGE for Cypher når CTEs ikke holder. + +### SpacetimeDB +Sanntidslag. Aktive noder og edges speilet fra PG. Frontend +abonnerer via WebSocket. Ingen eierskapskonflikt — STDB er en +live-visning av en delmengde av PG-grafen. + +### CAS (Content-Addressable Store) +Binærdata (lyd, bilde, video) lagret med hash. TTL basert på +modalitet, edges og aksesslog. Tekst lever evig, lyd 30 dager, +video 14 dager — forlenges ved aksess. Generert innhold (TTS, +thumbnails) er en cache som regenereres on-demand. + +## Teknologivalg + +| Rolle | Teknologi | Begrunnelse | +|-------|-----------|-------------| +| Orkestrator | Rust | Ytelse, typesikkerhet, allerede i stacken | +| Frontend | SvelteKit | PWA, SSR, allerede i stacken | +| Database | PostgreSQL | Økosystem (pgvector, fulltekstsøk, AGE), stabilitet | +| Sanntid | SpacetimeDB | In-memory, 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. diff --git a/docs/concepts/den_asynkrone_gjesten.md b/docs/concepts/den_asynkrone_gjesten.md new file mode 100644 index 0000000..27b700f --- /dev/null +++ b/docs/concepts/den_asynkrone_gjesten.md @@ -0,0 +1,125 @@ +# Konsept: Den Asynkrone Gjesten +**Filsti:** `docs/concepts/den_asynkrone_gjesten.md` + +## 1. Konsept +Mange interessante gjester har ikke tid til å stille i studio. Den Asynkrone Gjesten lar redaksjonen sende en unik lenke til en gjest som kan svare på spørsmål via tale — fra mobilen, når det passer dem. Svarene lander direkte i redaksjonens arbeidsflyt, transkriberes automatisk, og kan brukes i podcasten. + +## 2. Brukeropplevelse + +### 2.1 Redaksjonens side +1. Redaksjonen oppretter en "Gjestesesjon" knyttet til et Tema. +2. Legger inn spørsmål (tekst) som gjesten skal svare på. +3. Systemet genererer en unik, tidsbegrenset URL. +4. URL-en sendes til gjesten via e-post, SMS eller chat. +5. Gjestens svar (lydmeldinger) dukker opp i Tema-chatten som `voice_memo`-meldinger, automatisk transkribert. +6. Redaksjonen triagerer svarene — kan tagge, klippe inn i episode, eller bruke som research. + +### 2.2 Gjestens side +1. Gjesten åpner lenken i mobilnettleseren. Ingen app, ingen konto, ingen registrering. +2. Ser en enkel, ren flate: podcast-logo, spørsmålene fra redaksjonen, og en opptaksknapp per spørsmål. +3. Trykker record, snakker, trykker stopp. Kan lytte tilbake og ta om igjen. +4. Ved innsending lastes lydfilene opp og gjesten ser en bekreftelse. +5. Lenken utløper etter gitt tid eller antall besøk. + +### 2.3 Minimal friksjon +- Ingen Authentik-innlogging — tilgang via signert token +- Ingen app — ren PWA/nettleser +- Ingen redigering — gjesten snakker bare +- Responsivt, mobil-first design + +## 3. Komponenter + +| Feature | Rolle | +|---|---| +| Lydmeldinger | Opptakskomponent gjenbrukes (se `docs/features/lydmeldinger.md`) | +| Chat (channels) | Svarene lander i en channel knyttet til Temaet | +| Live transkripsjon | Whisper transkriberer via jobbkø (se `docs/features/live_transkripsjon.md`) | +| Podcastfabrikken | Lydklipp kan trekkes inn som segment (se `docs/concepts/podcastfabrikken.md`) | + +## 4. Autentisering: Gjeste-tokens + +### 4.1 Datamodell + +```sql +guest_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + 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 workspace-tilhørighet. +- Gjestens meldinger merkes med `author_id = NULL` og `metadata.guest_name` + `metadata.guest_token_id` for sporbarhet. +- Ingen tilgang til andre channels, workspaces 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:** +```yaml +clamav: + image: clamav/clamav:latest + restart: unless-stopped + volumes: + - /srv/sidelinja/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 media/{workspace_slug}/voice/ + → 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, ingen workspace-switcher). +* Gjenbruk lydmeldinger-komponenten — ikke bygg en egen opptaksflyt. +* Meldinger fra gjester har `author_id = NULL`. Frontend må håndtere dette gracefully (vis `guest_name` i stedet). +* Tokenet skal **aldri** gi tilgang til å lese andre meldinger i channelen — gjesten kan kun skrive. +* Alt er workspace-scopet. Token bærer workspace_id eksplisitt. diff --git a/docs/concepts/kunnskapsgrafen.md b/docs/concepts/kunnskapsgrafen.md new file mode 100644 index 0000000..221ac07 --- /dev/null +++ b/docs/concepts/kunnskapsgrafen.md @@ -0,0 +1,52 @@ +# Konsept: Kunnskapsgrafen (Utforsking og redigering) +**Filsti:** `docs/concepts/kunnskapsgrafen.md` + +## 1. Konsept +Kunnskapsgrafen er Sidelinjas kjerne — et levende nettverk av Temaer, Aktører, Faktoider, Episoder og Segmenter. Inspirert av Logseq og Obsidian bygger den seg opp organisk gjennom daglig bruk og skaper "serendipity" (lykketreff) i research-fasen ved å synliggjøre uventede forbindelser. + +## 2. Brukeropplevelse + +### 2.1 Organisk vekst +Grafen vokser gjennom daglig bruk av Sidelinja: +1. **Chat-meldinger** med `#`-tags oppretter automatisk `MENTIONS`-relasjoner i grafen. +2. **AI-behandling i editoren** trekker ut aktører og faktoider fra innlimt tekst (se `docs/proposals/editor.md`). +3. **Podcastfabrikken** kobler episode-segmenter til temaer og aktører. +4. **Møtereferater** trådes automatisk mot temaer og aktører av AI-referenten. + +### 2.2 Visuell utforsking +En interaktiv graf-visning (se `docs/features/visuell_graf.md`) lar redaksjonen: +- Navigere nettverket rundt en Aktør eller et Tema (2-3 ledd ut) +- Dra streker mellom noder for å opprette nye relasjoner +- Filtrere etter nodetype, relasjonstype, tidsperiode eller fritekst +- Bruke `PART_OF`-hierarkier for fleksibel prosjektorganisering uten stive mappestrukturer + +### 2.3 Søk +Full-text search på norsk (`to_tsvector('norwegian', ...)`) gjør det mulig å søke på tvers av alle episoder, segmenter og faktoider. + +## 3. Komponenter + +| Feature | Rolle i Kunnskapsgrafen | +|---|---| +| Kunnskapsgraf datamodell | Nodes/edges i PostgreSQL (se `docs/features/kunnskapsgraf_og_relasjoner.md`) | +| Visuell graf | Interaktiv D3.js/Vis.js-visning (se `docs/features/visuell_graf.md`) | +| Chat | Mentions (`#`/`@`) oppretter edges automatisk (se `docs/features/chat.md`) | +| Editor (AI-knapp) | Trekker ut aktører/faktoider til grafen (se `docs/proposals/editor.md`) | + +## 4. Entity Resolution (Merge Entities) + +Grafen vokser organisk via `#`-mentions, men dette skaper uunngåelig fragmentering: `#Jonas`, `#Støre` og `#Jonas Gahr Støre` ender som tre separate noder. Uten en strategi for sammenslåing dør serendipity-effekten — faktoidene spres over duplikater som AI-en tror er ulike konsepter. + +**Løsning: Merge Entities admin-verktøy (Lag 2)** + +1. Velg autoritativ node (Node A) og duplikat(er) (Node B, C, ...) +2. Flytt alle `graph_edges` som peker på Node B → Node A (`UPDATE graph_edges SET source_id/target_id = A WHERE ... = B`) +3. Flytt alle `messages`-mentions som refererer til Node B → Node A +4. Legg til `name` fra Node B som alias i `entities.aliases` på Node A +5. Slett Node B (`DELETE FROM nodes` → cascader) + +`aliases`-arrayet i `entities`-tabellen finnes allerede og er indeksert med GIN — autocomplete søker i både `name` og `aliases`, noe som forebygger fremtidige duplikater. + +**Forebygging:** Autocomplete bør vise eksisterende entiteter med matchende aliases *før* brukeren oppretter nye. "Mente du #Jonas Gahr Støre?" ved skriving av `#Støre`. + +## 5. Datamodell +Den tekniske datamodellen (nodes-supertabell, graph_edges, detailtabeller, deterministiske UUIDs, workspace-isolasjon) er dokumentert i `docs/features/kunnskapsgraf_og_relasjoner.md`. diff --git a/docs/concepts/møterommet.md b/docs/concepts/møterommet.md new file mode 100644 index 0000000..c2ec9eb --- /dev/null +++ b/docs/concepts/møterommet.md @@ -0,0 +1,40 @@ +# Konsept: Møterommet (Interne redaksjonsmøter) +**Filsti:** `docs/concepts/møterommet.md` + +## 1. Konsept +Et fullverdig virtuelt møterom for Sidelinjas redaksjon. Bygget på LiveKit for lyd/video, med AI-referent som automatisk genererer referat og action points, delt whiteboard for visuell brainstorming, og søkbar møtehistorikk i Kunnskapsgrafen. + +## 2. Brukeropplevelse +1. En bruker oppretter et møterom i SvelteKit. Alle deltakere kobler seg til via LiveKit (WebRTC). +2. Under møtet er følgende verktøy tilgjengelige: + - **Video/lyd** — LiveKit-strøm mellom deltakere + - **Whiteboard** — Frihåndstavle for skisser (se `docs/features/whiteboard.md`) + - **Delt scratchpad** — SpacetimeDB-drevet tekstfelt for kjappe notater + - **Aha-markør** — Markerer viktige øyeblikk med tidsstempel + - **Off-the-record** — Pauser AI-lytting og opptak +3. Whisper transkriberer møtet via live transkripsjonspipelinen (se `docs/features/live_transkripsjon.md`). +4. Ved møteslutt genererer AI-referenten (se `docs/features/live_ai.md`, møte-modus): + - Strukturert referat (Markdown) + - Action points → foreslåtte Kanban-kort (se `docs/features/kanban.md`) + - Identifiserte `#Temaer` og `@Aktører` → automatisk tråding i Kunnskapsgrafen + +## 3. Komponenter + +| Feature | Rolle i Møterommet | +|---|---| +| LiveKit | WebRTC lyd/video | +| Live transkripsjon | Whisper-pipeline for møtetranskripsjon (se `docs/features/live_transkripsjon.md`) | +| Live AI (møte-modus) | Referat, action points, tråding (se `docs/features/live_ai.md`) | +| Whiteboard | Frihåndstavle for visuell brainstorming (se `docs/features/whiteboard.md`) | +| Chat | Scratchpad / tekstnotater (se `docs/features/chat.md`) | +| Kanban | Mottaker av foreslåtte action-point-kort (se `docs/features/kanban.md`) | + +## 4. Off-the-record +Når off-the-record er aktivt: +- Whisper-strømmen stopper — ingen data lagres +- Visuell indikator i alle deltakeres grensesnitt +- Transkripsjonen får et gap i tidslinja +- Whiteboard forblir aktivt (visuelt, ikke tale) + +## 5. Søkbar møtehistorikk +Møter transkriberes i segmenter (samme modell som episode-segmenter) og indekseres i PostgreSQL med full-text search. Møtereferater lenkes til Temaer og Aktører via `graph_edges`, slik at intern møtehistorikk blir søkbar i Kunnskapsgrafen. diff --git a/docs/concepts/podcastfabrikken.md b/docs/concepts/podcastfabrikken.md new file mode 100644 index 0000000..22c62ce --- /dev/null +++ b/docs/concepts/podcastfabrikken.md @@ -0,0 +1,130 @@ +# Konsept: Podcastfabrikken (Lyd & Publiserings-Pipeline) +**Filsti:** `docs/concepts/podcastfabrikken.md` + +## 1. Konsept +Den automatiserte "samlebåndet" som tar over når en ferdigklippet episode er klar, samt verktøyet for å **oppdatere eksisterende episoder** (f.eks. en rullerende intro-episode). Målet er at maskinen gjør 90 % av grovarbeidet (transkripsjon, metadata, kapittelinndeling), men at redaksjonen alltid kan overstyre resultatet manuelt før publisering. + +## 2. Arkitektur & Dataflyt +Dette er en asynkron arbeidsflyt som kombinerer filsystem, AI, databaser og CI/CD. + +1. **Trigger (Opplasting/Oppdatering):** Brukeren laster opp en `.mp3`-fil via SvelteKit-grensesnittet. Dette rutes enten som en *ny* episode (`INSERT`), eller en *oppdatering* av en eksisterende (`UPDATE`). +2. **Kø-system (PostgreSQL jobbkø):** Siden lydprosessering tar tid (CPU-intensivt), legges oppgaven i den felles jobbkøen (se `docs/infra/jobbkø.md`). Opplastingen oppretter to jobber i sekvens: først `whisper_transcribe`, deretter `openrouter_analyze` (som trigges automatisk ved fullført transkripsjon). +3. **Transkripsjon (faster-whisper):** Rust-worker kaller faster-whisper-server (OpenAI-kompatibelt API, `POST /v1/audio/transcriptions`) med `response_format=srt` og mottar SRT direkte. Modell: `Systran/faster-whisper-medium` med `initial_prompt` (navneliste). +4. **Lagring av transkripsjon (Git):** Rust-worker committer SRT-filen til Forgejo. SRT er master-formatet — redigerbart, tidsstemplet, og et etablert standardformat. Git gir diff, historikk og sporbarhet. Redaksjonen kan redigere SRT direkte. +5. **Avledede formater (PostgreSQL):** Ved commit (via Forgejo webhook) parser en Rust-worker SRT-filen og genererer: + * **Ren tekst** — strippes fra SRT (fjern tidsstempler/sekvensnummer) for lesbart publiseringsdokument + * **Segmenter** — tidsstemplede utdrag koblet til Aktører/Temaer i kunnskapsgrafen + * **Full-text søkeindeks** — for oppslag på tvers av episoder +6. **AI-Analyse (OpenRouter):** Transkripsjonen sendes til OpenRouter (Claude-modell) for uttrekk av forslag til tittel, sammendrag, show notes og kapittler. +7. **Manuell Godkjenning & Fletting (SvelteKit):** + * *For nye episoder:* Presenteres som et ferskt utkast. + * *For oppdateringer:* Viser AI-ens nye forslag side-om-side med eksisterende metadata. Redaksjonen kan da velge hva som skal beholdes eller flettes (merge). +8. **Publisering (PostgreSQL):** Ved "Godkjenn" lagres metadataene permanent i databasen. +9. **RSS-Generering:** SvelteKit-appen genererer en oppdatert `/feed.xml`. + +### 2.1 Episodeside (publisert visning) +Hver publisert episode får en side med: +* Lydavspiller + sammendrag + kapitler + stikkord +* Personreferanser og artikler (koblet via kunnskapsgrafen) +* Fane: **SRT** (nedlastbar undertekstfil — master-kopi fra Git) +* Fane: **Ren tekst** (lesbart transkripsjonsdokument — avledet fra SRT, lagret i PG) + +## 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 `` (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 ``:** URL-en i `enclosure`-taggen (som peker på `.mp3`-filen) oppdateres i databasen til å reflektere det *nye* filnavnet. +* **RSS ``:** SvelteKit-grensesnittet skal gi redaksjonen en toggle-knapp ved oppdatering: + * Alternativ A: "Behold opprinnelig dato" (Episoden oppdateres i det stille for nye lyttere). + * Alternativ B: "Sett dato til NÅ" (Episoden spretter til toppen av feeden som en ny utgivelse). + +## 4. Whisper-konfigurasjon +* **Tjeneste:** `fedirz/faster-whisper-server` (Docker, OpenAI-kompatibelt API) +* **Endepunkt:** `POST /v1/audio/transcriptions` med `response_format=srt` +* **Beslutning:** SRT direkte fra Whisper, ikke verbose JSON. Verbose JSON inneholder diagnostikk (tokens, logprob, temperatur) som ikke har verdi for oss. SRT gir tidsstempler + tekst i et etablert format som er redigerbart, diffbart i Git, og trivielt å parse til ren tekst og segmenter. +* **Modeller (benchmarket med E277.mp3, 32:45 norsk tale, CPU i7-13900K):** + +| Konfigurasjon | Tid (CPU) | Seg | Tegn | Kommentar | +|---|---|---|---|---| +| `small` | ~6 min | 777 | 25851 | Rask, men hyppige feil i egennavn | +| `medium` | ~18 min | 442 | 26938 | God balanse, noen navnefeil | +| `medium` + prompt | ~17 min | 455 | 26957 | Riktige egennavn, anbefalt standard | +| `large-v3` | ~24 min | 520 | 14559 | Hallusinerer uten VAD — IKKE bruk uten VAD | +| `large-v3` + VAD | ~31 min | 964 | 28291 | God kvalitet, men noen navnefeil | +| `large-v3` + VAD + prompt | ~31 min | 964 | 28295 | Best kvalitet, riktige egennavn | + +* **Anbefaling:** `medium` + `initial_prompt` som standard. `large-v3` + VAD + prompt for best mulig kvalitet der det er verdt ventetiden. +* **Viktig:** `large-v3` KREVER `vad_filter=true` — uten hallusinerer modellen repeterende tekst. +* **Språk:** Sett `language=no` eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk. + +### 4.1 initial_prompt (navneliste) +`initial_prompt` primes Whisper med ordforråd som forbedrer gjenkjenning av egennavn. Effekten er tydelig: +* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja" +* Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig) + +Prompten bygges automatisk av Rust-worker fra en statisk navneliste + aktører i kunnskapsgrafen: +``` +Sidelinja podcast med Vegard Nøtnæs, Trond Sørensen, Arne Eidshagen, +Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie +``` + +## 5. Workspace-spesifikk konfigurasjon +Hver workspace har sin egen podcast-konfigurasjon, lagret i `workspaces.settings` (JSONB): + +### 5.1 Mediefiler +Lydfiler lagres i undermapper per workspace: `/srv/sidelinja/media/{workspace_slug}/`. Caddy ruter trafikk basert på domene (fra `workspaces.domain`) til riktig undermappe. + +### 5.2 Transkripsjoner (Git-repostruktur) +Det opprettes **ett Forgejo-repo per workspace** 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 workspace, via Forgejo API. Ikke alle workspaces 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 `/srv/sidelinja/media/{workspace_slug}/` 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, workspace_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 workspace i `settings.whisper_prompt`. Rust-worker bygger prompten fra statisk liste + aktører i workspace-ets kunnskapsgraf. +* **LLM system-prompts:** OpenRouter-prompts for metadata-uttrekk lagres i `settings.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 `workspaces.domain`), eller workspace-slug som fallback. + +### 5.5 Statistikk +Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig `workspace_id` basert på filsti i loggen. + +## 6. Instruks for Claude Code +* **Lydfiler:** Håndter filopplasting i SvelteKit strømmende (streaming) for filer >100MB for å unngå minne-lekkasjer. +* **Feilhåndtering:** Hvis OpenRouter timer ut eller Whisper feiler, må oppgaven flagges med status `error` i databasen slik at brukeren kan trigge jobben på nytt manuelt via UI. +* **Opprydding (Disk):** Når en fil oppdateres vellykket, skal den gamle/foreldede `.mp3`-filen enten slettes fra Hetzner-serveren automatisk, eller flyttes til en `/archive/`-mappe basert på en miljøvariabel. +* **Transkripsjoner:** Master-kopi alltid i Git. Aldri rediger avledede formater direkte i PG — de regenereres fra Git-kilden. +* **Workspace:** Alle jobber, mediefiler og metadata opprettes med riktig `workspace_id`. Hent workspace-config (prompts, domene) fra `workspaces.settings`. \ No newline at end of file diff --git a/docs/concepts/redaksjonen.md b/docs/concepts/redaksjonen.md new file mode 100644 index 0000000..98542a0 --- /dev/null +++ b/docs/concepts/redaksjonen.md @@ -0,0 +1,36 @@ +# 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. +* Alle data er workspace-scopet. SpacetimeDB-tilkoblinger bærer `workspace_id`. diff --git a/docs/concepts/studioet.md b/docs/concepts/studioet.md new file mode 100644 index 0000000..806269e --- /dev/null +++ b/docs/concepts/studioet.md @@ -0,0 +1,32 @@ +# Konsept: Studioet (Podcast-innspilling) +**Filsti:** `docs/concepts/studioet.md` + +## 1. Konsept +Det virtuelle podcast-studioet er Sidelinjas innspillingsmiljø. LiveKit håndterer WebRTC for flerbruker lyd/video, mens AI-assistenten lytter med og dytter relevante faktoider til programlederne i sanntid. + +## 2. Brukeropplevelse +1. Programlederne åpner studioet i SvelteKit (PWA) og kobler seg til et LiveKit-rom. +2. Høykvalitetslyd streames mellom deltakerne via WebRTC. +3. I bakgrunnen transkriberer Whisper lydstrømmen i chunks (~5 sek) via live transkripsjonspipelinen (se `docs/features/live_transkripsjon.md`). +4. AI-assistenten analyserer transkripsjonen for entiteter (NER) og slår opp i Kunnskapsgrafen. Relevante faktoider popper lydløst opp på skjermen (se `docs/features/live_ai.md`, studio-modus). +5. Programlederne kan trykke **Aha-markør** for å markere viktige øyeblikk. Tidsstempelet lagres i SpacetimeDB, koblet til episoden. +6. Etter innspilling skyves lydfilen inn i Podcastfabrikken for full transkripsjon og publisering (se `docs/concepts/podcastfabrikken.md`). + +## 3. Komponenter + +| Feature | Rolle i Studioet | +|---|---| +| LiveKit | WebRTC lyd/video mellom deltakere | +| Live transkripsjon | Whisper `small` for lav latens, ~1s forsinkelse (se `docs/features/live_transkripsjon.md`) | +| Live AI (studio-modus) | NER + faktoid-oppslag fra Kunnskapsgrafen (se `docs/features/live_ai.md`) | +| Aha-markør | Manuell markering av viktige øyeblikk, lagres i SpacetimeDB | + +## 4. Avgrensning +- Studioet er for **innspilling**, ikke redigering. Klipping/postproduksjon skjer utenfor Sidelinja. +- Live-transkripsjonen her er **flyktig** (TTL 30 dager) — den endelige transkripsjonen lages via Podcastfabrikken med `medium` + `initial_prompt`. +- Aha-markøren deles med Møterommet (se `docs/concepts/møterommet.md`), men i studio-konteksten brukes den primært til klippepunkter. + +## 5. Utviklingsfaser +1. Bygg SpacetimeDB-lytter i frontend + dummy faktoid-push for å verifisere UI. +2. Koble Whisper til et offline lydopptak, kjør NER/oppslag mot PostgreSQL. +3. Koble LiveKit-strømmen til Whisper for sanntid. diff --git a/docs/concepts/valgomaten.md b/docs/concepts/valgomaten.md new file mode 100644 index 0000000..88249e6 --- /dev/null +++ b/docs/concepts/valgomaten.md @@ -0,0 +1,106 @@ +# Konsept: Valgomaten (Crowdsourced & Datadrevet) +**Filsti:** `docs/concepts/valgomaten.md` + +## 1. Konsept & Kjernefilosofi +Neste generasjons valgomat tar et oppgjør med den redaktørstyrte, endimensjonale modellen. Istedenfor forhåndsdefinerte akser styrer brukerne innholdet "bottom-up" gjennom et marked for akser. Valgomaten kombinerer friksjonsfri "Tinder-swiping" for folk flest, med et dyptgående redaksjonelt verktøy for nerdene, alt bygget på toppen av Sidelinjas eksisterende Kunnskapsgraf og sanntidsinfrastruktur. + +### 1.1 Markedsmekanisme +Brukere kan fritt opprette nye politiske akser og spørsmål. Dimensjoner som engasjerer (får mange svar) "bobler opp" og blir standardakser, mens irrelevante forsvinner. Ingen begrensning på antall akser — systemet støtter komplekse skillelinjer i samfunnet. + +### 1.2 Teoretisk fundament (Default-akser) +For å unngå "Blank Canvas"-syndromet startes plattformen med anerkjente rammeverk som fungerer som ankere: +* **John Haidts Moral Foundations Theory:** Omsorg, Rettferdighet, Lojalitet, Autoritet, Renhet. +* **Rokkans skillelinjer:** Sentrum/periferi, by/land, religiøs/sekulær. +* **Nanny State Index:** Formynderstat vs. personlig frihet og ansvar. +* **Intensjon vs. Resultat:** Støttes politikk på bakgrunn av teoretisk målsetting eller empirisk utfall? + +## 2. Brukeropplevelse (Trakten) +Valgomaten er bygget som en to-trinns trakt for å maksimere viral spredning samtidig som dataintegriteten bevares. + +### 2.1 Forsiden: Friksjonsfri & Anonym +* **Mekanikk:** Ingen registrering kreves for å starte. SvelteKit genererer en anonym UUID som lagres i `localStorage` og brukes mot SpacetimeDB. +* **UX:** Et rent kortstokk-grensesnitt (Tinder-stil swipe). Påstand på forsiden, brukeren velger Enig/Uenig. +* **Tre-trinns kalibrering (OKCupid-inspirert):** + 1. Hva mener du? + 2. Hva ønsker du at kandidaten skal mene? + 3. Hvor viktig er dette for deg? +* **Dealbreakers (Negative Veto):** Brukere kan sette absolutte grenser (f.eks. "Uansett hvor enige vi er om skatt, matcher jeg aldri med en som er for/mot vindkraft"). +* **Sanntid:** SpacetimeDB beregner PCA (Prinsipalkomponentanalyse) og oppdaterer brukerens posisjon lynraskt i minnet for hvert swipe. + +### 2.2 Baksiden: "Snu kortet" (Krever innlogging) +For avansert interaksjon må brukeren logge inn via Authentik (SSO). Den anonyme sesjonen flettes da automatisk med brukerprofilen. På baksiden av kortet finner man: +* **Folkets Redaksjonsmøte:** Mulighet til å se andres kommentarer til spørsmålet, foreslå endringer, og stemme opp/ned forbedringsforslag. +* **Podcast-snarveien:** Hvis spørsmålet er knyttet til et *Tema* i Kunnskapsgrafen, vises en "Play"-knapp. Caddy (`Accept-Ranges: bytes`) streamer tidsstemplede lydsegmenter fra tidligere Sidelinja-episoder der dette temaet ble diskutert. +* **Multi-akse vekting:** Innsikt i hvilke akser spørsmålet påvirker (f.eks. 80% Klima, 20% Sentrum/Periferi), med mulighet for å foreslå nye akse-koblinger. + +## 3. Matching & Resultat + +### 3.1 Individfokus +Matcher velgeren mot spesifikke lokalpolitikere og listekandidater, ikke bare mot det sentrale partiprogrammet. + +### 3.2 PCA (Prinsipalkomponentanalyse) +Matematisk reduksjon av kompleksitet. Algoritmen koker ned brukerens svar på tvers av 50+ variabler til de 2-3 hovedfaktorene som faktisk styrer vedkommendes politiske kompass, og visualiserer disse spesifikt for brukeren. + +## 4. Crowdsourcing & Dataintegritet + +### 4.1 Uforanderlig Historikk (Versjonering) +Et spørsmål i PostgreSQL kan *aldri* endres (`UPDATE`) etter at folk har begynt å svare på det. +* Hvis et forbedringsforslag stemmes frem (eller godkjennes av redaksjonen), opprettes en ny versjon (f.eks. `Spørsmål 42 (v2)`). +* Brukere beholder sine resultater knyttet til den nøyaktige teksten de leste. Algoritmen vet at v1 og v2 tilhører samme politiske konsept. + +### 4.2 Opprettelse av nye spørsmål og akser (Sandkassen) +* **Inkubator:** Innloggede brukere kan opprette nye spørsmål eller definere helt nye akser (ved å angi to motpoler). Disse havner i en "Sandkasse" og vises ikke på forsiden før de har fått nok organisk engasjement fra andre innloggede brukere. +* **Vekting via Graph Edges:** Koblingen mellom et spørsmål og en akse lagres som en relasjon i `graph_edges`. Når brukere "stemmer opp" at et spørsmål tilhører en spesifikk akse, øker feltet `confidence`. SpacetimeDB bruker denne `confidence`-scoren som multiplikator i match-algoritmen. + +## 5. Visualisering & Sidelinja Explorer + +### 5.1 Innhenting av referansedata +For å bygge nøyaktige velgerkart, introduseres et valgfritt spørsmål før resultatet vises: *"For å kalibrere landskapet: Hva stemte du ved forrige valg?"*. Dette knytter den anonyme eller innloggede sesjonen mot en parti-`aktør` i Kunnskapsgrafen. + +### 5.2 Sidelinja Explorer (Offentlig Graf) +En egen SvelteKit-side der lekfolk og journalister kan analysere dataene. +* **Heatmaps:** Viser klynger ("skyer") av velgermasser basert på partipreferanse på to valgfrie akser. +* **Varder i landskapet:** Historiske figurer (Churchill, Stalin), nåværende politikere og Sidelinjas egne verter ligger inne som faste referansepunkter i grafen. AI-estimerte profiler gir pedagogisk (og underholdende) kontekst. + +## 6. AI & Asynkron Prosessering +Av kostnads- og ytelseshensyn skjer all AI-bruk asynkront i backend via jobbkøen og `ai-gateway` (LiteLLM). Ingen live AI-kall i klienten. + +* **AI-Kandidater (`valgomat_generate_profile`):** En bakgrunnsjobb analyserer partiprogrammer via `sidelinja/rutine` (Gemini) og genererer "syntetiske" referanseprofiler for listekandidater og historiske figurer. Kandidater kan senere logge inn (Authentik) og overstyre AI-ens svar manuelt. +* **Semantisk deduplisering (`valgomat_moderation`):** En asynkron jobb overvåker nye brukerskapte akser og spørsmål, slår sammen duplikater (f.eks. "Skattetrykk" og "Skattenivå"), og flagger emosjonelt ladede spørsmål for Sidelinja-redaksjonen i Redaksjonens chat. + +## 7. Arkitektur & Ansvarsfordeling + +| Komponent | Rolle / Ansvar | +|---|---| +| **SvelteKit (Klient)** | UX, Anonym UUID-håndtering i `localStorage`, kortstokk-swipe-grensesnitt, "lazy loading" av baksiden ved innlogging, visning av Heatmaps via Sidelinja Explorer. | +| **Authentik** | SSO for brukere, kandidater og redaksjon. Sammenfletting av anonym UUID med ekte bruker-ID. | +| **SpacetimeDB** | Sanntids match-kalkulering (Rust Reducers), swiping-logikk, PCA-beregning, og "Multiplayer"-rom ("Sofagruppa"). Holder kun aktive sesjoner i minnet. | +| **PostgreSQL** | Kunnskapsgrafen (`nodes`, `graph_edges`). Permanent lagring av spørsmål, akser, versjonshistorikk og aggregerte data for Sidelinja Explorer. | +| **Rust Worker (Sync)** | Synkroniserer batcher av svar fra SpacetimeDB over til PostgreSQL-lagringen via standard sync-mekanismen (se `synkronisering.md`). | +| **Rust Worker (AI)** | Kjører `valgomat_generate_profile` og `valgomat_moderation` via jobbkøen. | + +## 8. Dataklassifisering (ref. docs/arkitektur.md 2.2) + +| Data | Kategori | Detaljer | +|---|---|---| +| Spørsmål, akser, versjonshistorikk | Kritisk (PG) | Brukergenerert innhold, krever backup | +| Individuelle svar (aggregert) | Kritisk (PG) | Synket fra SpacetimeDB | +| Aktive sesjoner, live PCA-state | Flyktig (SpacetimeDB) | Tåler tap — bruker svarer på nytt | +| AI-genererte kandidatprofiler | Avledet (PG) | Kan regenereres fra partiprogrammer | + +## 9. Skaleringsrisiko + +### PCA i SpacetimeDB +PCA-beregning i SpacetimeDB er minnekrevende og udokumentert for store datasett. Ved tusenvis av samtidige brukere med 50+ akser kan minnebruken eksplodere. Tiltak: +- **Materialized Views i PostgreSQL** for Sidelinja Explorer — aldri kjør tunge aggregeringer on-the-fly. Oppdater views via nattlig jobb eller etter batch-sync fra SpacetimeDB. +- **Batch-sync med størrelsesbegrensning** — sync_outbox kan bli bottleneck ved høy svaraktivitet. Vurder dedikert sync-frekvens for valgomat-data. +- **PCA-fallback i PG** — hvis SpacetimeDB-minnebruk overstiger terskel, flytt PCA-beregning til PostgreSQL (via `pg_stat_statements` for kostnadsmåling) med lengre oppdateringsintervall. +- **Overvåk tidlig** — legg til valgomat-spesifikke metrikker i `/admin/observability` (antall aktive sesjoner, PCA-beregningstid, SpacetimeDB-minnebruk for valgomat-tabeller). + +## 10. Instruks for Claude Code +1. **Datamodell:** Utvid enum `node_type` i PostgreSQL med `valgomat_question` og `valgomat_axis`. Bruk `graph_edges` med `relation_type = 'AFFECTS_AXIS'` og oppdater `confidence`-feltet for å håndtere crowdsourcet vekting av spørsmål opp mot ulike akser. +2. **SpacetimeDB Reducers:** Implementer innkommende events som `SubmitSwipe`, `SuggestAxis`, og match-algoritmen i Rust inne i SpacetimeDB. Pass på at reducere støtter anonym `session_id`. +3. **State Management:** SvelteKit skal ikke kreve innlogging for forsiden. Implementer Auth-guards slik at opprettelse av spørsmål, stemmegivning på andres forslag og visning av kommentarer gir `403 Forbidden` for uautoriserte og trigger Authentik-flyten. +4. **Explorer API:** Bygg aggregerte Materialized Views i PostgreSQL for Sidelinja Explorer for å unngå tung on-the-fly kalkulering av Heatmaps over millioner av rader. +5. **Jobbtyper:** Registrer `valgomat_generate_profile` og `valgomat_moderation` som jobbtyper i jobbkøen (se `jobbkø.md`). Bruk `sidelinja/rutine` som modellalias. +6. **Versjonering:** Spørsmål er append-only. Nye versjoner er nye rader med referanse til forgjengeren — aldri `UPDATE` på tekst som har mottatt svar. diff --git a/docs/erfaringer/README.md b/docs/erfaringer/README.md new file mode 100644 index 0000000..b58ed2e --- /dev/null +++ b/docs/erfaringer/README.md @@ -0,0 +1,21 @@ +# 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 install` installerer pakker. +- **Oppdater fremfor å legge til.** Hvis en erfaring utdypes, oppdater eksisterende fil. +- **Kodereferanser.** Vis til filer der mønsteret er implementert, så man kan lese koden. diff --git a/docs/erfaringer/adapter_moenster.md b/docs/erfaringer/adapter_moenster.md new file mode 100644 index 0000000..4e4b694 --- /dev/null +++ b/docs/erfaringer/adapter_moenster.md @@ -0,0 +1,88 @@ +# Erfaring: Adapter-mønster for chat (PG ↔ SpacetimeDB) + +## 1. Mønsteret + +Et felles interface (`ChatConnection`) med to implementasjoner: +- **SpacetimeDB-adapter** (primær) — all data fra SpacetimeDB, worker håndterer warmup + sync +- **PG-adapter** (fallback) — polling hvert 3 sek, brukes kun når SpacetimeDB ikke er konfigurert + +Factory-funksjon velger adapter basert på miljøvariabel (`VITE_SPACETIMEDB_URL`). + +``` +ChatBlock.svelte → createChat() → SpacetimeDB-adapter (primær) + → PG-adapter (fallback, readonly) +``` + +**Fordeler:** +- Kan teste PG-adapter isolert uten Docker/SpacetimeDB +- Fallback er trivielt — fjern env-variabelen +- Komponenten vet ingenting om hvilken adapter som brukes +- ChatConnection-interface har `edit()`, `delete()`, `react()` — ingen direkte PG API-kall fra komponenten + +**Referanse:** `web/src/lib/chat/` — hele mappen er organisert etter dette mønsteret. + +## 2. Historisk anti-pattern: Lazy wrapper som byttet adapter + +Vi prøvde først en "lazy wrapper" som startet med PG-adapter og byttet til SpacetimeDB-adapter når tilkoblingen var klar. Problemet: + +- En plain `let activeConnection` i wrapperen er **ikke reaktiv** i Svelte 5 +- Når wrapperen byttet adapter, forsvant meldingene — ny adapter startet med tom liste +- Svelte-komponentene så aldri byttet fordi proxy-referansen ikke oppdaterte seg + +**Lærdom:** Ikke bytt adapter runtime. Velg én ved oppstart. + +## 3. Historisk anti-pattern: Hybrid-adapter (PG + SpacetimeDB samtidig) + +Den andre iterasjonen brukte en hybrid-adapter som hentet historikk fra PG via REST og lyttet på SpacetimeDB for nye meldinger. Dette skapte: + +- Kompleks dedup-logikk (`deletedIds` Set, merge av PG- og ST-meldinger) +- Race conditions mellom PG-polling og SpacetimeDB-callbacks +- BigInt-konverteringer og workarounds i frontend + +**Løsningen:** SpacetimeDB som cache foran PG. Worker gjør warmup (PG → ST) ved oppstart, frontend snakker kun med SpacetimeDB. Ingen merge-logikk nødvendig. + +## 4. Nåværende arkitektur + +SpacetimeDB er en varm cache foran PostgreSQL: +- **Worker warmup:** Ved oppstart lastes meldinger + reaksjoner fra PG → SpacetimeDB per kanal +- **Frontend → SpacetimeDB:** Subscription gir alle meldinger (historikk fra warmup + nye) +- **SpacetimeDB → PG:** Sync-worker poller `sync_outbox` hvert sekund +- **PG er autoritativ** — ved SpacetimeDB-restart oppvarmes fra PG + +**Fordeler over hybrid:** +- Ingen dedup, merge eller deletedIds +- Frontend-koden er dramatisk enklere +- Konsistent datamodell — alt kommer fra én kilde +- Reaksjoner håndteres via SpacetimeDB-tabeller, ikke PG API + +## 5. Historisk anti-pattern: "PG-lekkasje" i SpacetimeDB-adapteren + +Gjentatt feil (mars 2026, minst 3 iterasjoner): Når en ny feature trenger data som SpacetimeDB-modulen ikke har (metadata, edited_at, revisjoner), er det fristende å legge til en `enrichFromPg()`-funksjon som henter fra PG direkte. Dette bryter hele poenget med caching-laget. + +**Symptomer:** +- SpacetimeDB-adapteren har `fetch('/api/messages/...')` kall +- Worker skriver til PG først, SpacetimeDB er "best-effort" +- Etter AI-vask vises ikke metadata/revisjoner fordi de bare finnes i PG +- Race conditions mellom SpacetimeDB-oppdateringer og PG-fetch + +**Hvorfor det skjer:** Det er raskere å lage en PG API-rute enn å utvide SpacetimeDB-modulen (Rust compile, publish, regenerer bindings). Men det skaper teknisk gjeld som akkumulerer og undergraver hele arkitekturen. + +**Riktig løsning når SpacetimeDB mangler et felt:** +1. Legg til feltet i SpacetimeDB Rust-modul (`spacetimedb/src/lib.rs`) +2. Utvid warmup til å laste feltet fra PG +3. Utvid sync til å persistere feltet til PG +4. Worker skriver til SpacetimeDB via reducer +5. Frontend leser kun fra SpacetimeDB + +**Aldri:** Legg til `fetch('/api/.../metadata')` i SpacetimeDB-adapteren. + +## 6. Anbefaling for neste komponent + +Når Kanban eller Whiteboard skal bygges med SpacetimeDB: + +1. **Start med PG-adapter.** Få hele flyten til å fungere med REST/polling først. +2. **Lag SpacetimeDB-adapter med warmup.** Worker laster data fra PG ved oppstart. +3. **Bruk samme factory-mønster.** Felles interface, env-variabel for valg. +4. **Legg til warmup-config** i `channels.config` (eller tilsvarende config-felt). +5. **Test begge adaptere uavhengig** før du integrerer i UI-komponenten. +6. **Sjekk at alle felter frontend trenger finnes i SpacetimeDB-modulen** før du implementerer adapteren. Utvid modulen først hvis nødvendig. diff --git a/docs/erfaringer/authentik_oidc.md b/docs/erfaringer/authentik_oidc.md new file mode 100644 index 0000000..e71d3e0 --- /dev/null +++ b/docs/erfaringer/authentik_oidc.md @@ -0,0 +1,52 @@ +# 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: + +```typescript +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:///auth/callback/`. For oss: `https://sidelinja.org/auth/callback/authentik`. + +Denne MÅ være registrert som redirect-URI i Authentik sin OAuth2-provider. Verifiser via: + +```sql +SELECT _redirect_uris FROM authentik_providers_oauth2_oauth2provider +WHERE client_id = ''; +``` + +**Tips:** Legg til en regex-variant for lokal utvikling: `http://localhost:\d+/auth/callback/authentik` med `matching_mode: "regex"`. diff --git a/docs/erfaringer/spacetimedb_integrasjon.md b/docs/erfaringer/spacetimedb_integrasjon.md new file mode 100644 index 0000000..a267ac0 --- /dev/null +++ b/docs/erfaringer/spacetimedb_integrasjon.md @@ -0,0 +1,183 @@ +# 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) + +```typescript +DbConnection.builder() + .withUri(spacetimeUrl) + .withDatabaseName(moduleName) // IKKE withModuleName + .withToken(token) + .onConnect((connection) => { // første param er DbConnection, ikke EventContext + connection.subscriptionBuilder() + .subscribe([`SELECT * FROM chat_message WHERE channel_id = '${id}'`]); + }) + .build(); +``` + +**Feller:** +- `withModuleName()` finnes ikke — bruk `withDatabaseName()` +- `onConnect`-callback mottar `DbConnection`, ikke `EventContext` +- `onConnectError`-callback har signatur `(ctx, errMessage)` der `errMessage` er en string + +## 3. Timestamps — bruk SDK-metoder, ikke interne felter + +SpacetimeDB `Timestamp`-objektet har en intern property `__timestamp_micros_since_unix_epoch__` (BigInt). **Ikke bruk den direkte** — bruk SDK-metodene: + +```typescript +// 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`: + +```rust +// FEIL — chrono gir ParseError(TooShort) på "+00" +let dt = s.parse::>(); + +// 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: + +```rust +// 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 + +```bash +# 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:** Bruk `./dev.sh` for å starte hele stacken automatisk (inkl. SpacetimeDB publish + binding-generering). `./dev.sh --clean` starter blankt. + +## 6. Arkitekturendring: SpacetimeDB som cache foran PG (mars 2026) + +Tidligere: Hybrid-adapter der frontend merget data fra PG (historikk) og SpacetimeDB (sanntid) med dedup, deletedIds og BigInt-workarounds. + +Ny modell: +- **PG autoritativ** — all persistent data i PostgreSQL +- **SpacetimeDB = varm cache** — worker gjør warmup (PG → ST) ved oppstart +- **Frontend snakker KUN med ST** — ingen PG API-kall fra chat-adapteren +- **Worker håndterer toveissynk** — ST → PG for nye/redigerte/slettede meldinger og reaksjoner + +### Warmup-flyt +1. Worker starter → `warmup::run()` leser kanaler med config fra PG +2. Per kanal: sjekker `channels.config.warmup_mode` (all/messages/days/none) +3. Kaller `clear_channel` reducer (unngår duplikater ved restart) +4. **Trådbasert henting:** Finner kvalifiserende tråder, henter alle meldinger i disse (komplett med svar) + - `messages`-modus: de N nyeste trådene (sortert etter siste aktivitet) + - `days`-modus: alle tråder med minst én melding i tidsvinduet + - Et svar som kvalifiserer tar med hele tråden (inkludert eldre trådstarter) +5. Kaller `load_messages` reducer med JSON-array +6. Laster også reaksjoner via `load_reactions` reducer + +### Per-kanal konfigurasjon +- Lagres i `channels.config` JSONB: `warmup_mode` + `warmup_value` +- Admin-UI: `/admin/channels` — tabell med inline-redigering +- Default: `"all"` (last alt). Andre: `"messages"` (siste N tråder), `"days"` (siste N dager), `"none"` (inaktiv) + +### Sync-flyt (ST → PG) +- SyncOutbox-events prosesseres hver 1. sekund +- Støtter: `messages/insert`, `messages/delete`, `messages/update`, `messages/ai_update`, `message_reactions/insert`, `message_reactions/delete` +- `ai_update`-action: oppdaterer body + metadata + edited_at i PG, inserter revisjon + +## 7. Subscription-begrensninger + +**SpacetimeDB-subscriptions støtter IKKE JOINs.** En subscription-query som `SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE ...` feiler stille — `onApplied` kalles aldri, og ingen data vises. + +Bruk kun enkle `SELECT * FROM tabell WHERE ...`-queries i `.subscribe([...])`. Filtrer heller klient-side etter at data er lastet. + +Eksempel: +```typescript +// 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 + +SpacetimeDB eksponerer Rust-parameternavn direkte i HTTP JSON API-et. Underscore-prefix (`_workspace_id`) blir til `_workspace_id` i JSON, ikke `workspace_id`: + +```rust +// 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: + +```bash +# Feiler uten --delete-data: +# "Adding a column metadata to table chat_message requires a default value annotation" +echo "y" | spacetime publish sidelinja-realtime --server local --delete-data +``` + +## 10. AI-worker-flyt via SpacetimeDB + +Worker som gjør AI-behandling av meldinger: +1. Leser meldingens body fra PG (OK — PG er persistent lager) +2. Kaller `set_ai_processing` reducer → frontend ser pulsering umiddelbart +3. Kaller AI Gateway med prompt +4. Kaller `ai_update_message` reducer → SpacetimeDB oppdaterer body/metadata/edited_at atomisk, lagrer revisjon, legger outbox-entry +5. Sync-worker persisterer til PG via `ai_update` action +6. Ved feil: `clear_ai_processing` reducer rydder flagget diff --git a/docs/erfaringer/svelte5_reaktivitet.md b/docs/erfaringer/svelte5_reaktivitet.md new file mode 100644 index 0000000..195acfc --- /dev/null +++ b/docs/erfaringer/svelte5_reaktivitet.md @@ -0,0 +1,73 @@ +# 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: + +```typescript +// 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. + +```typescript +// FEIL — krasjer ved SSR +let chat = createChat(channelId); // kjøres på server + +// RIKTIG — kun i browser +import { onMount } from 'svelte'; +let chat = $state(null); +onMount(() => { + chat = createChat(channelId); + return () => chat?.destroy(); +}); +``` + +Alternativt kan factory-funksjonen selv sjekke: +```typescript +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: + +```typescript +let chat = $state(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. diff --git a/docs/features/brukerinnstillinger.md b/docs/features/brukerinnstillinger.md new file mode 100644 index 0000000..747ac87 --- /dev/null +++ b/docs/features/brukerinnstillinger.md @@ -0,0 +1,175 @@ +# 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: + +```sql +ALTER TABLE users ADD COLUMN settings JSONB NOT NULL DEFAULT '{}'; +``` + +Eksempel: +```json +{ + "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 workspace +- JSONB er fleksibelt — nye innstillinger legges til uten migrering +- Ingen relasjoner, ingen spørringer mot enkeltfelt — bare hent hele objektet + +**Workspace-spesifikke overrides** kan legges i `workspace_members` ved behov: +```sql +ALTER TABLE workspace_members ADD COLUMN settings JSONB NOT NULL DEFAULT '{}'; +``` +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`: + +```css +: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: + +```css +.message-body { + font-size: var(--user-font-size); + line-height: var(--user-line-height); + max-width: var(--user-content-width); +} +``` + +### 4.3 Svelte Store + +```typescript +// $lib/stores/userSettings.ts +export const userSettings = writable(defaults); + +// Ved innlogging: hent fra API +// Ved endring: debounce → PATCH /api/user/settings +// CSS variables oppdateres reaktivt +``` + +## 5. API + +| Metode | Sti | Beskrivelse | +|---|---|---| +| GET | `/api/user/settings` | Hent brukerens innstillinger | +| PATCH | `/api/user/settings` | Oppdater innstillinger (merge med eksisterende) | + +PATCH gjør en shallow merge: `settings = settings || patch`. Kun oppgitte felt endres. + +## 6. Viktige prinsipper + +- **Innstillinger påvirker aldri innhold.** En bruker med 24px skrift og en bruker med 12px skrift ser identisk innhold. Publiserte artikler for eksterne lesere bruker typografi-stacken fra artikkel-publisering, ikke forfatterens personlige innstillinger. +- **Defaults er gode.** En bruker som aldri åpner innstillingspanelet skal ha en god opplevelse. Innstillinger er for tilpasning, ikke for at ting skal fungere. +- **Ingen overraskelser.** Endringer gjelder kun brukeren som gjør dem. Workspace-admin kan ikke overstyre personlige innstillinger. +- **Progressiv avsløring.** Start med de viktigste innstillingene (tema, skriftstørrelse). Legg til flere etter hvert som behov oppstår. + +## 7. Instruks for Claude Code +- Innstillinger lagres i `users.settings` JSONB — hent hele objektet, merge ved oppdatering +- Bruk CSS custom properties for all visuell tilpasning — aldri hardkodede verdier i komponenter +- Innstillingspanelet er en modal/drawer, ikke en egen side +- Auto-save med debounce (500ms) — ingen "lagre"-knapp +- Ved nye innstillinger: legg til i defaults-objektet, ingen migrering nødvendig diff --git a/docs/features/canvas_primitiv.md b/docs/features/canvas_primitiv.md new file mode 100644 index 0000000..317e306 --- /dev/null +++ b/docs/features/canvas_primitiv.md @@ -0,0 +1,213 @@ +# 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: 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: + +```typescript +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: + +```css +.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. + +```typescript +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: + +```typescript +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: + +```svelte + + + + +``` + +## 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: + +```typescript +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: + +```typescript +interface CanvasEvents { + onObjectMove: (id: string, x: number, y: number) => void; + onObjectResize: (id: string, w: number, h: number) => void; + onCameraChange: (camera: Camera) => void; + onSelectionChange: (ids: string[]) => void; +} +``` + +Storyboard-consumeren bruker `onObjectMove` til å kalle en SpacetimeDB-reducer for å synkronisere posisjon til andre klienter. + +## 9. Bygger på + +- **SvelteKit:** Svelte 5 `$state`/`$derived` for reaktiv kamera- og objekt-state +- **CSS transforms:** Ingen Canvas2D eller WebGL — DOM-basert rendering for å beholde Svelte-komponent-rendering inne i objektene +- **Pointer Events API:** Unified input for mus og touch + +## 10. Implementeringsstrategi + +### Fase 1: Kjerne-primitiv +- `` 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 +- `` rendrer meldingsboks-kort på canvaset +- SpacetimeDB-synk for posisjon og status +- Portal-soner for overføring + +### Fase 3: Whiteboard-migrering +- Migrere eksisterende whiteboard-spec til å bruke canvas-primitivet +- Tegneverktøy som overlay oppå primitivet + +## 11. Instruks for Claude Code +- Canvas-primitivet er en ren Svelte-komponent uten backend-avhengigheter +- Bruk CSS transforms, ikke Canvas2D — innholdet inne i objekter er vanlige Svelte-komponenter +- All state styres via Svelte 5 `$state` og `$derived` — ingen external state management +- Pointer events, ikke mouse events — unified input +- Test med touch-emulering i DevTools for responsivitet +- Viewport culling er påkrevd fra dag 1 — ikke optimaliser bort diff --git a/docs/features/chat.md b/docs/features/chat.md new file mode 100644 index 0000000..a54a854 --- /dev/null +++ b/docs/features/chat.md @@ -0,0 +1,147 @@ +# 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 workspace-isolasjon. + +``` +┌─────────────┐ 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: + +```json +{ + "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 workspace-admin 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'`): + +```sql +messages ( + id UUID PK → nodes(id), + channel_id UUID NOT NULL → channels(id), + reply_to UUID → messages(id), -- tråder (hvis config.threads = true) + author_id TEXT NOT NULL → users, + message_type message_type, -- 'text', 'research_clip', 'factoid', 'system' + body TEXT NOT NULL, + metadata JSONB, -- ekstra data (research-klipp AI-resultat, etc.) + edited_at TIMESTAMPTZ, + created_at TIMESTAMPTZ +) +``` + +### 3.2 SpacetimeDB (sanntid) +SpacetimeDB holder aktive channels' meldinger i minnet som varm cache foran PG. Ved oppstart gjør worker warmup fra PG → SpacetimeDB (per-kanal konfigurasjon). Nye meldinger sendes via SpacetimeDB-reducers og kringkastes til alle tilkoblede klienter. Synkes til PostgreSQL med ~1 sek forsinkelse (se `docs/infra/synkronisering.md`). + +## 4. Mentions & Autocomplete +Kun aktive når `config.mentions = true`. + +* **Trigger-tegn:** `#` (Temaer/Aktører fra Kunnskapsgrafen), `@` (brukere/redaksjonsmedlemmer), `/` (kommandoer). +* **Filtrering:** Svelte-klienten filtrerer den lokale SpacetimeDB-cachen umiddelbart. Skriver man `#Ha...` vises en klikkbar liste ("Hans Petter Sjøli", "Høyre"). +* **Grafkobling:** Ved `#`-mention opprettes automatisk `MENTIONS`-edges i `graph_edges` mellom meldingen og den nevnte noden. +* **Mobil-optimalisert:** Autocomplete-listen er tappbar og tilpasset mindre skjermer. + +## 5. Tråder +Kun aktive når `config.threads = true`. Meldinger kan ha en `reply_to`-referanse. Frontend grupperer meldinger i tråder (rot + svar) med visuell skillelinje mellom hver tråd. Svar vises med innrykk og vertikal linje under rot-meldingen, uten ekstra skillelinje mellom rot og svar. + +## 6. Vedlegg +Kun aktive når `config.attachments = true`. Meldinger kan ha vedlegg via `message_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 +1. Bruker trykker og holder (eller toggler) mikrofon-knappen i chat-feltet. +2. Nettleseren fanger lyd via `MediaRecorder` API (WebM/Opus). +3. Lydklippet sendes til Whisper (`POST /v1/audio/transcriptions`, `response_format=text`, `language=no`) via SvelteKit server-side. +4. Transkripsjonen settes inn i meldingsfeltet — brukeren kan redigere før sending. +5. Ingen lagring av lydfilen — den kastes etter transkripsjon. + +### 8.2 Avgrensning +* Dette er **ikke** en lydmelding-feature (à la WhatsApp). Lyden er et transportmiddel for tekst. Kun teksten lagres. +* Whisper-kallet er kort (<30 sek tale) og kan rutes direkte til Whisper-serveren uten jobbkø. +* Bruk `small`-modellen for lav latens. Navnenøyaktighet er mindre viktig for korte chatmeldinger. + +## 9. TTL (automatisk opprydding) +Channels med `config.ttl_days` satt til et tall får sine meldinger automatisk slettet av en nattlig jobbkø-jobb. Brukes for flyktige kontekster (scratchpads, studio-chat). + +## 10. Implementeringsstatus + +### Ferdig (mars 2026) +- **ChatBlock.svelte:** Adapter-mønster via `createChat()` factory. Bruker `chat.edit()`, `chat.delete()`, `chat.react()` — ingen direkte PG API-kall. +- **SpacetimeDB-adapter (`spacetime.svelte.ts`):** Ren SpacetimeDB-adapter. All data fra SpacetimeDB (historikk via warmup + sanntid). Reaksjoner fra `message_reaction`-tabellen. +- **PG-adapter (`pg.svelte.ts`):** Polling hvert 3. sek. Readonly fallback når SpacetimeDB ikke er konfigurert. +- **Factory (`create.svelte.ts`):** Velger adapter basert på `VITE_SPACETIMEDB_URL`. SSR-safe med `browser`-guard. +- **Shared types (`types.ts`):** `ChatConnection` interface med `send`, `edit`, `delete`, `react`, `readonly`. +- **SpacetimeDB Rust-modul (`spacetimedb/src/lib.rs`):** `ChatMessage`, `MessageReaction`, `SyncOutbox`-tabeller. Reducers: `send_message`, `delete_message`, `edit_message`, `add_reaction`, `remove_reaction`, `load_messages`, `load_reactions`, `clear_channel`, `mark_synced`. +- **Worker warmup (`worker/src/warmup.rs`):** PG → SpacetimeDB ved oppstart. Per-kanal konfig (all/messages/days/none). Trådbasert henting. +- **Worker sync (`worker/src/sync.rs`):** SpacetimeDB → PG hvert sekund. Insert/delete/update meldinger + reaksjoner. +- **Admin-side (`/admin/channels`):** Per-kanal warmup-konfigurasjon. +- **Tråder:** Komplett trådvisning med datogruppering, autoscroll og visuell skillelinje mellom tråder. +- **Reaksjoner:** Via SpacetimeDB-reducers, synket til PG. +- **Meldingskollaps:** Lange meldinger begrenses til 2 linjer med "Vis mer"/"Vis mindre". +- **AI-behandling:** Meldinger kan AI-behandles (✨-knapp). Revisjons-toggle viser original vs. AI-versjon. Markdown-rendering for AI-output. +- **Konvertering:** Meldinger kan opprettes som kanban-kort eller kalenderhendelse (dialog sier "Opprett", ikke "Konverter" — meldingen beholdes i chatten). + +### Gjenstår +- **Vedlegg, TTL** — avventer implementering. +- **Workspace-partisjonering:** SpacetimeDB har `workspace_id` men bruker ikke token ennå. +- **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 med riktig `workspace_id`. +* **Channel-opprettelse:** Når en Tema, Episode eller Møte opprettes, opprett alltid en default-channel i samme transaksjon. +* **Mentions-parsing:** Skjer i sync-workeren ved persistering til PG. Parser mention-UUIDs fra HTML body og oppretter `graph_edges`. +* **Config-respekt:** Frontend-komponenten må lese `channel.config` og slå av/på UI-elementer. `channels.config` inneholder også `warmup_mode`/`warmup_value` for SpacetimeDB-oppvarming. +* **PG er autoritativ** — SpacetimeDB er varm cache. Frontend snakker kun med SpacetimeDB. +* **Alt er workspace-scopet.** Channels arver workspace via `nodes.workspace_id`. diff --git a/docs/features/kalender.md b/docs/features/kalender.md new file mode 100644 index 0000000..34df49d --- /dev/null +++ b/docs/features/kalender.md @@ -0,0 +1,98 @@ +# 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 — arver workspace-isolasjon automatisk +- Heldagshendelser (`T12:00:00` for tidssone-trygghet) vs. tidshendelser med klokkeslett +- Fargekoder per hendelse (7 forhåndsdefinerte) + standard kalenderfarge +- REST API: GET med tidsvindu-filtrering, POST/PATCH/DELETE hendelser +- PG polling-adapter med 5 sek intervall +- CalendarBlock.svelte: månedsrutenett, navigering, opprett/rediger-modal, Escape-lukking +- `linked_node`-kolonne for fremtidig kobling til kanban-kort, episoder etc. + +### Gjenstår — Fase 2 +- Kobling til kanban-kort (vis deadline på kalender) +- Uke- og dagsvisning +- Gjentakende hendelser (RRULE) +- Dra-og-slipp for å flytte hendelser mellom datoer +- Flerdag-hendelser (vises over flere celler) +- Abonnementsmodell (kalender → kalender via graph_edges) +- Personlige vs. workspace-kalendere +- ICS/CalDAV-eksport +- SpacetimeDB-modul + hybrid-adapter +- Varsler/påminnelser via jobbkøen + +## 3. Datamodell (implementert) + +```sql +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 | +|------|------|-----------| +| Workspace | Workspace (admin) | Alle medlemmer | +| Personlig | Bruker | Kun eier + eksplisitt deling | +| Offentlig | Workspace | Alle som abonnerer | + +## 8. Fremtidig: ICS-eksport +Offentlige kalendere får en ICS-URL (`/cal/{workspace_slug}/{calendar_id}.ics`) for Google Calendar, Apple Calendar etc. + +## 9. Instruks for Claude Code +* Bruk `eventsForDate()` med lokal dato-konvertering, ikke UTC-substring +* Heldagshendelser: `T12:00:00`, aldri `T00:00:00` (tidssone-felle) +* Alt er workspace-scopet via node-modellen +* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi diff --git a/docs/features/kanban.md b/docs/features/kanban.md new file mode 100644 index 0000000..93b8349 --- /dev/null +++ b/docs/features/kanban.md @@ -0,0 +1,59 @@ +# 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 (arver workspace-isolasjon) +- 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 — all workspace-isolasjon arves automatisk via `nodes.workspace_id`. + +## 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. +* Alt er workspace-scopet via node-modellen. +* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi. diff --git a/docs/features/kunnskaps_bridge.md b/docs/features/kunnskaps_bridge.md new file mode 100644 index 0000000..8d00305 --- /dev/null +++ b/docs/features/kunnskaps_bridge.md @@ -0,0 +1,78 @@ +# Feature: Kunnskaps-Bridge (Cross-Workspace Discovery) +**Filsti:** `docs/features/kunnskaps_bridge.md` + +## 1. Konsept +En valgfri, opt-in feature som lar brukere oppdage semantisk beslektet kunnskap på tvers av workspaces de har tilgang til. Bryter ikke workspace-isolasjonen — resultatet er en *peker* ("dette finnes i Podcast B"), ikke selve innholdet. Brukeren må ha tilgang til begge workspaces for å se treffet. + +## 2. Avgrensning: Hva dette IKKE er +- **Ikke datadeling.** Ingen data kopieres mellom workspaces. Bridge viser kun at en relevant node *finnes*. +- **Ikke automatisk.** Begge workspaces må ha Bridge eksplisitt aktivert av en admin. +- **Ikke synlig for gjester.** Kun for workspace-medlemmer med tilgang til begge sider. +- **Ikke et søk i andres data.** Du ser bare treff i workspaces du allerede er medlem av. + +## 3. Teknisk arkitektur + +### 3.1 Vector Embeddings (pgvector) +Krever `pgvector`-extension i PostgreSQL: + +```sql +CREATE EXTENSION IF NOT EXISTS vector; + +ALTER TABLE actors ADD COLUMN embedding vector(768); +ALTER TABLE topics ADD COLUMN embedding vector(768); +ALTER TABLE factoids ADD COLUMN embedding vector(768); + +CREATE INDEX idx_actors_embedding ON actors USING ivfflat (embedding vector_cosine_ops); +CREATE INDEX idx_topics_embedding ON topics USING ivfflat (embedding vector_cosine_ops); +CREATE INDEX idx_factoids_embedding ON factoids USING ivfflat (embedding vector_cosine_ops); +``` + +### 3.2 Embedding-generering (`generate_embeddings`) +En jobbkø-jobb som genererer embeddings for nye/endrede noder: +1. Rust-worker plukker opp jobben fra jobbkøen. +2. Bygger en tekst-representasjon av noden (navn, body, tilknyttede faktoider). +3. Sender til AI Gateway (`sidelinja/rutine`) for embedding-generering. +4. Lagrer vektoren i pgvector-kolonnen. +5. Re-genereres ved vesentlige endringer av noden. + +### 3.3 Cross-workspace søk +Når en bruker utforsker en node (f.eks. Tema "Skolepolitikk"): +1. SvelteKit server-side henter brukerens tilgjengelige workspaces fra `workspace_members`. +2. Kjører et similarity-søk med `<=>` (cosine distance) **som superuser** (bypasser RLS) — men filtrerer eksplisitt mot brukerens workspace-liste: + ```sql + SELECT n.workspace_id, t.name, t.embedding <=> $target_embedding AS distance + FROM topics t + JOIN nodes n ON t.id = n.id + WHERE n.workspace_id = ANY($user_workspace_ids) + AND n.workspace_id != $current_workspace_id + AND t.embedding <=> $target_embedding < 0.3 + ORDER BY distance + LIMIT 10; + ``` +3. Resultatet vises som en diskret "Finnes også i..."-seksjon i UI-et. + +### 3.4 Workspace-config +Bridge aktiveres per workspace i `workspaces.settings`: +```json +{ + "bridge_enabled": true, + "bridge_discoverable": true +} +``` +- `bridge_enabled` — workspace kan søke i andre workspaces +- `bridge_discoverable` — andre workspaces kan finne noder i dette workspace-et + +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-workspace søk er det **eneste** stedet i systemet der bruker-initierte handlinger bypasser RLS. Isolér denne koden grundig — egen funksjon med eksplisitt workspace-filtrering, aldri gjenbruk i andre kontekster. +* Embedding-dimensjon (768) bør matche modellen som brukes. Konfigurér som konstant, ikke hardkod overalt. +* Jobbtype `generate_embeddings` bruker `sidelinja/rutine` som modellalias. +* Bridge er **Lag 4+** — krever fylt kunnskapsgraf i minst to workspaces. diff --git a/docs/features/kunnskapsgraf_og_relasjoner.md b/docs/features/kunnskapsgraf_og_relasjoner.md new file mode 100644 index 0000000..07906e9 --- /dev/null +++ b/docs/features/kunnskapsgraf_og_relasjoner.md @@ -0,0 +1,170 @@ +# 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. + +```sql +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(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + 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. + +```sql +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 + +```sql +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 , 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. `workspace_id` er denormalisert fra `nodes` for å muliggjøre RLS direkte på edges: + +```sql +CREATE TABLE graph_edges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + 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); +CREATE INDEX idx_edges_workspace ON graph_edges(workspace_id); +``` + +**Merk:** `UNIQUE(source_id, target_id, relation_type)` forhindrer duplikate relasjoner. Bruk `ON CONFLICT DO NOTHING` eller `ON CONFLICT DO UPDATE SET confidence = ...` ved upsert. + +**Merk:** `node_type`-enumet kan utvides av feature-spesifikke migrasjoner (f.eks. `valgomat_question`, `valgomat_axis`). Se `docs/concepts/valgomaten.md` for eksempel. + +## 4. Segmenter og Transkripsjoner + +### 4.1 Segment som grafnode +Episoder deles i **segmenter** — tidsavgrensede deler med egen transkripsjon. Hvert segment er en node i grafen og kan kobles til Temaer, Aktører og Faktoider. Dette muliggjør presise oppslag som: "I Episode 42, fra 14:23 til 21:07, diskuterte dere Skolepolitikk og sa følgende..." + +``` +Episode 42 (node: episode) + ├── Segment 00:00-14:22 (node: segment) ──DISCUSSED_IN──► Tema: Mediepolitikk + ├── Segment 14:23-21:07 (node: segment) ──DISCUSSED_IN──► Tema: Skolepolitikk + │ ──MENTIONS──────► Aktør: Støre + └── Segment 21:08-45:00 (node: segment) ──DISCUSSED_IN──► Tema: Kommuneøkonomi +``` + +Kapitler i RSS-feeden genereres fra segmentene, men er et eget konsern (se `docs/concepts/podcastfabrikken.md`). + +### 4.2 Transkripsjoner: Git som master, PG som søkeindeks +Transkripsjoner lever i **to steder** med klart eierskap: + +| Sted | Rolle | Format | +|---|---|---| +| **Git (Forgejo)** | Kilde til sannhet. Redigerbar, sporbar, diffbar | Markdown med tidsstempler | +| **PostgreSQL** | Søkeindeks. Full-text search, koblet til grafen | Segmentert i `segments`-tabellen | + +**Flyt:** +``` +Whisper → Git (rå transkripsjon med tidsstempler) + → Redaksjonen korrigerer manuelt ved behov + → Push til Forgejo + → Forgejo webhook trigger 'transcript_reimport'-jobb i jobbkøen + → Rust-worker parser filen, splitter i segmenter + → DELETE + INSERT i én PG-transaksjon (idempotent reimport) + → Grafkoblinger bevares (segment-UUID deterministisk fra episode-UUID + tidsstempel) +``` + +**Deterministisk UUID for segmenter:** `UUID = uuid_v5(episode_uuid, start_time_ms)`. Dette sikrer at samme segment alltid får samme UUID, selv ved reimport. Grafkoblinger som peker på segmentet overlever dermed en full reimport. + +## 5. Arbeidsflyt: Hvordan grafen vokser +Grafen bygger seg opp organisk gjennom daglig bruk av Sidelinja-suiten: +1. **Chat & Notater:** En bruker skriver: *"Apropos #Hans_Petter_Sjøli, hva var greia med #Arbeiderpartiet?"* +2. **Parsing (Svelte/Rust):** Systemet fanger opp de to `#`-taggene (som allerede har UUIDs i `Aktør`-tabellen). +3. **Edge Creation:** SvelteKit server-side oppretter automatisk to nye oppføringer i `graph_edges`-tabellen: + * [Melding UUID] -> `MENTIONS` -> [Sjøli UUID] + * [Melding UUID] -> `MENTIONS` -> [Arbeiderpartiet UUID] +4. **Indirekte relasjon:** Fordi begge aktørene nå deler samme `context_id` (meldingen), vet Kunnskapsgrafen at det finnes en tematisk kobling mellom Sjøli og Ap. +5. **Publisering:** Når en episode publiseres, kobles segmentene automatisk til relevante Temaer og Aktører basert på AI-analyse av transkripsjonen. + +## 6. Instruks for Claude Code +* **Workspace-isolasjon:** Alle noder tilhører en workspace. Opprett alltid noder med riktig `workspace_id`. Spørringer mot detailtabeller (actors, topics, etc.) filtrerer alltid via JOIN med nodes. +* **`nodes`-tabellen er obligatorisk.** Opprett alltid en rad i `nodes` før du inserter i en detailtabell. Bruk en hjelpefunksjon som gjør begge i én transaksjon. +* **`graph_edges` krever `workspace_id`.** Ved opprettelse av edges, sett `workspace_id` fra kilde-noden. `UNIQUE(source_id, target_id, relation_type)` hindrer duplikater — bruk `ON CONFLICT` ved upsert. +* **Graf-spørringer:** Bruk `WITH RECURSIVE` i PostgreSQL når du bygger endepunkter som skal hente ut "Linked Mentions" eller nettverket rundt en spesifikk Aktør opp til 2-3 ledd ut. +* **Fremtidssikring for UI:** Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (som D3.js eller Vis.js) i Svelte-frontenden. Formatet bør være `{ "nodes": [...], "edges": [...] }`. +* **Transkripsjon-reimport:** Workeren må være idempotent. Bruk `uuid_v5(episode_uuid, start_time_ms)` for deterministiske segment-UUIDs. Slett og gjenopprett segmenter i én transaksjon, men **ikke** slett edges som peker til segmentene — de overlever fordi UUID-en er stabil. **Merk:** Hvis manuelle korrigeringer endrer segment-grenser (start_time), endres UUID-en. Løsning: automatisk flytt eksisterende edges til nærmeste nye segment basert på tidsintervall-overlapp. +* **Full-text search:** Bruk `to_tsvector('norwegian', transcript)` for norsk språkstøtte i søk. diff --git a/docs/features/live_ai.md b/docs/features/live_ai.md new file mode 100644 index 0000000..c3b8bce --- /dev/null +++ b/docs/features/live_ai.md @@ -0,0 +1,66 @@ +# Feature: Live AI (Faktoid-oppslag & Referent) +**Filsti:** `docs/features/live_ai.md` + +## 1. Konsept +AI-drevet analyse av sanntids transkripsjon. Opererer i to moduser med samme underliggende pipeline (Whisper → NER → handling), men ulik output. + +## 2. Studio-modus: Faktoid-oppslag +Brukes i Studioet (se `docs/concepts/studioet.md`). En "virtuell co-host" som dytter relevante faktoider til programlederne i sanntid. + +### 2.1 Dataflyt +1. Live transkripsjon (se `docs/features/live_transkripsjon.md`) leverer tekst-chunks. +2. Rust-tjenesten analyserer for egennavn (Named Entity Recognition). +3. Lynraskt oppslag i PostgreSQL: `SELECT * FROM factoids JOIN actors... WHERE actor.name = $1`. +4. Treff dytter `LiveFactoidEvent` inn i SpacetimeDB. +5. SvelteKit-studio viser faktoiden lydløst i en egen boks. + +### 2.2 Lagring +Live-transkripsjonsloggen er flyktig (TTL 30 dager): + +```sql +live_transcription_log ( + id SERIAL, + session_id UUID, + chunk_timestamp TIMESTAMPTZ, + chunk_text TEXT, + matched_entities TEXT[], + pushed_factoids UUID[], + created_at TIMESTAMPTZ DEFAULT now() +) +``` + +En nattlig jobbkø-jobb sletter rader eldre enn TTL. + +## 3. Møte-modus: AI-Referent +Brukes i Møterommet (se `docs/concepts/møterommet.md`). Genererer referat og action points. + +### 3.1 Dataflyt +1. **Under møtet:** Whisper transkriberer i chunks. Aha-markører fra deltakerne lagres med tidsstempler. +2. **Ved møteslutt:** En `meeting_summarize`-jobb opprettes i jobbkøen (se `docs/infra/jobbkø.md`). +3. Rust-worker sender transkripsjonen + Aha-markører til AI Gateway (`sidelinja/rutine`) med instruksjon om å generere: + * Referat (strukturert Markdown) + * Action points (foreslåtte Kanban-kort) + * Identifiserte `#Temaer` og `@Aktører` +4. Referatet lagres som melding i relevant Tema-chat. Foreslåtte Kanban-kort vises for godkjenning. +5. `graph_edges` opprettes automatisk mellom møtesegmenter og identifiserte Temaer/Aktører. + +### 3.2 Off-the-record +Når off-the-record er aktivt, stopper Whisper-strømmen og ingen data lagres. Visuell indikator i alle deltakeres grensesnitt. Transkripsjonen får et gap i tidslinja. + +## 4. Kill Switch +Studio-modus har en **kill switch** — en synlig "Stopp AI"-knapp i studio-grensesnittet som umiddelbart: +1. Stopper NER-analyse av nye chunks +2. Skjuler faktoid-boksen +3. Logger tidspunkt og grunn (manuelt felt, valgfritt) + +Nødvendig fordi AI-en kan dytte feil eller irrelevante faktoider under live innspilling. Programlederen må kunne slå den av uten å forlate studio-viewet. + +Kill switch-status (`ai_enabled: bool`) lagres på LiveKit-rommet i SpacetimeDB og synkes til alle klienter i rommet. + +## 5. Instruks for Claude Code +* Begge moduser deler samme Whisper-pipeline — ikke dupliser transkripsjonskode. +* Studio-modus krever lav latens — hold NER-oppslaget raskt (indekserte spørringer). +* Møte-modus er asynkron — prosessér via jobbkøen etter møteslutt. +* All AI-kode peker på `http://ai-gateway:4000/v1`, aldri direkte til leverandør. +* Kill switch skal alltid være tilgjengelig i studio-modus — default: AI aktivert. +* Alt er workspace-scopet. diff --git a/docs/features/live_transkripsjon.md b/docs/features/live_transkripsjon.md new file mode 100644 index 0000000..ca5c8f0 --- /dev/null +++ b/docs/features/live_transkripsjon.md @@ -0,0 +1,40 @@ +# Feature: Live Transkripsjon (Whisper-pipeline) +**Filsti:** `docs/features/live_transkripsjon.md` + +## 1. Konsept +Den felles Whisper-pipelinen som brukes av flere konsepter for å transkribere lyd. Abstraherer bort konfigurasjonsforskjeller (modellvalg, latenskrav) bak et felles grensesnitt. + +## 2. Moduser + +| Kontekst | Modell | Latenskrav | initial_prompt | Output | +|---|---|---|---|---| +| **Studioet** (live) | `small` | <1s per chunk | Nei (hastighet prioritert) | Flyktig tekst → NER-pipeline | +| **Møterommet** (live) | `small` | <1s per chunk | Nei | Flyktig tekst → AI-referent | +| **Podcastfabrikken** (batch) | `medium` + prompt | Ingen (asynkront) | Ja (navneliste) | SRT → Git → PG | + +## 3. Teknisk arkitektur +1. **Lydkilde:** LiveKit server-side hooks (live) eller filopplasting (batch). +2. **Whisper-server:** `fedirz/faster-whisper-server` (Docker, OpenAI-kompatibelt API). Endepunkt: `POST /v1/audio/transcriptions`. +3. **Chunking (live):** Rust-tjeneste mater lyd i ~5-sekunders chunks. `small`-modellen prosesserer ~5x raskere enn sanntid, noe som gir <1s forsinkelse per chunk. +4. **Output:** SRT (batch) eller ren tekst (live). + +## 4. Whisper-konfigurasjon +* **Språk:** Sett `language=no` eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk. +* **`large-v3`** KREVER `vad_filter=true` — uten hallusinerer modellen repeterende tekst. +* **Benchmark og modellvalg:** Se `docs/concepts/podcastfabrikken.md` seksjon 4 for ytelsestall og anbefalinger. + +### 4.1 initial_prompt (navneliste) +Brukes kun i batch-modus (Podcastfabrikken). Prompten bygges automatisk fra workspace-config (`workspaces.settings.whisper_prompt`) + aktører i workspace-ets kunnskapsgraf. + +Effekten er tydelig: +* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja" +* Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig) + +## 5. Lagring +* **Live-modus:** Transkripsjonen er flyktig (kategori 4, TTL 30 dager). Lagres i `live_transcription_log` for feilsøking. +* **Batch-modus:** SRT committes til Git (Forgejo, ett repo per workspace). Avledede formater (ren tekst, segmenter, søkeindeks) i PostgreSQL. + +## 6. Instruks for Claude Code +* Live transkripsjon blokkerer ALDRI web-requests — prosesseres i Rust-worker eller separat tjeneste. +* Batch-transkripsjon kjøres som `whisper_transcribe`-jobb i jobbkøen (se `docs/infra/jobbkø.md`). +* Workspace-spesifikk config (prompts) hentes fra `workspaces.settings`. diff --git a/docs/features/lydmeldinger.md b/docs/features/lydmeldinger.md new file mode 100644 index 0000000..6289716 --- /dev/null +++ b/docs/features/lydmeldinger.md @@ -0,0 +1,137 @@ +# 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 +1. Bruker åpner lydmelding-modus (via FAB-knapp, hurtigtast eller fra en channel). +2. Trykker record. Nettleseren fanger lyd via `MediaRecorder` API (WebM/Opus). +3. Kan valgfritt velge kontekst: et Tema, en Aktør, eller "Usortert". +4. Ved stopp lastes lydfilen opp til SvelteKit (streaming for store filer). +5. Filen lagres som `media_file` i workspace-mappen (`/srv/sidelinja/media/{workspace_slug}/voice/`). +6. En `whisper_transcribe`-jobb opprettes i jobbkøen for å gjøre opptaket søkbart. +7. Transkripsjonen lagres som metadata på noden — lydfilen forblir master. + +### 2.2 Datamodell +Lydmeldingen er en node i Kunnskapsgrafen (`node_type = 'melding'`, `message_type = 'voice_memo'`): + +``` +nodes (workspace_id, node_type = 'melding') + → messages (channel_id, message_type = 'voice_memo', body = transkripsjon, metadata = { duration, transcription_status }) + → message_attachments → media_files (lydfilen) +``` + +Lydmeldinger kan: +- Sendes i en channel (som vedlegg til en melding) +- Leve fristående i en personlig "Innboks"-channel +- Knyttes til Temaer/Aktører via `graph_edges` (manuelt eller AI-foreslått) + +### 2.3 Triagering +Redaksjonen trenger en flate for å gå gjennom nye lydmeldinger: +- Lytt, tagg med Temaer/Aktører +- Marker for bruk i episode (kobles til Kanban/Kjøreplan) +- Forkast / arkiver +- Denne flaten er en filtrert visning av "Innboks"-channelen, ikke et eget system + +### 2.4 Bruk i podcast +Lydmeldinger markert for bruk kan trekkes inn i Podcastfabrikkens pipeline. Lydfilen er allerede i media-mappen — den kan klippes inn i en episode direkte. + +## 3. Dikteringsmodus + +### 3.1 Brukerflyt +1. Bruker åpner dikteringsmodus (mobil-first, men fungerer på desktop). +2. Snakker fritt — kan være flere minutter. Visuell indikator viser at opptak pågår. +3. Ved stopp opprettes to jobber i jobbkøen (sekvensielt): + - `whisper_transcribe` — rå transkripsjon (`medium` + `initial_prompt` for kvalitet) + - `dictation_cleanup` — AI rydder i teksten +4. Brukeren ser resultatet: rå transkripsjon og AI-ryddet versjon side om side. +5. Kan redigere den ryddede versjonen før lagring. +6. Lagres som et notat (node i grafen). Lydfilen slettes etter vellykket transkripsjon. + +### 3.2 AI-opprydding (`dictation_cleanup`) +AI Gateway (`sidelinja/rutine`) prosesserer transkripsjonen med instruksjon om å: +- Fjerne fyllord, gjentakelser og ufullstendige setninger +- Strukturere i avsnitt med overskrifter der det gir mening +- Bevare talerens mening og tone — ikke omskrive til "AI-språk" +- Foreslå `#Tema`- og `@Aktør`-tags basert på innholdet + +### 3.3 Datamodell +Dikterte notater er meldinger i en channel: + +``` +nodes (workspace_id, node_type = 'melding') + → messages (channel_id, message_type = 'text', body = ryddet tekst, metadata = { raw_transcript, source: 'dictation' }) +``` + +Metadata bevarer rå-transkripsjonen slik at brukeren alltid kan se hva som faktisk ble sagt. + +### 3.4 Målkanal +Brukeren velger hvor notatet havner: +- **Personlig notat-channel** — default, kun synlig for brukeren selv +- **Tema-channel** — delt med redaksjonen som et innspill +- **Direkte til en annen bruker** — som en asynkron "talemelding i tekstform" + +## 4. Felles infrastruktur + +### 4.1 Opptak (klient) +Begge moduser bruker samme opptakskomponent i SvelteKit: +- `MediaRecorder` API med WebM/Opus +- Visuell feedback (lydnivå-indikator, varighet) +- Modus-velger: Lydmelding / Diktering +- Kontekst-velger: Tema, Aktør, eller Usortert + +### 4.2 Opplasting +SvelteKit håndterer filopplasting strømmende. For korte klipp (<2 min) kan opplasting starte umiddelbart etter stopp. For lengre opptak bør det vises en fremdriftsindikator. + +### 4.3 Whisper +Begge moduser bruker live transkripsjonspipelinen (se `docs/features/live_transkripsjon.md`) i batch-modus via jobbkøen. Modellvalg: +- Lydmelding: `small` (rask, transkripsjonen er sekundær — lyden er master) +- Diktering: `medium` + `initial_prompt` (kvalitet prioritert — teksten er master) + +### 4.4 Personlig "Innboks"-channel +Ved workspace-opprettelse opprettes en privat channel per bruker for usorterte lydmeldinger og notater. Config: + +```json +{ + "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/{workspace_slug}/voice/` — aldri i databasen. +* Diktering: slett lydfilen først *etter* at brukeren har godkjent den ryddede teksten. +* `voice_memo` er en ny `message_type` — utvid enum i migrasjonen. +* Personlig innboks-channel opprettes automatisk ved workspace-medlemskap (som del av `workspace_members`-inserten). +* Alt er workspace-scopet. diff --git a/docs/features/meldingsboks.md b/docs/features/meldingsboks.md new file mode 100644 index 0000000..5136206 --- /dev/null +++ b/docs/features/meldingsboks.md @@ -0,0 +1,319 @@ +# 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`) + +```sql +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 'workspace', -- 'workspace' | 'private' + edited_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_messages_channel ON messages(channel_id, created_at); +CREATE INDEX idx_messages_reply ON messages(reply_to) WHERE reply_to IS NOT NULL; + +CREATE TRIGGER trg_messages_updated_at BEFORE UPDATE ON messages + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +``` + +**Forskjeller fra gammel `messages`-tabell:** +- `id` er FK til `nodes(id)` — **alle meldinger er noder** i kunnskapsgrafen +- Ingen `workspace_id` — arves via `nodes.workspace_id` (RLS på nodes gjelder) +- `channel_id` er nullable — notater og standalone-bokser trenger ikke en channel +- `title` er førsteklasses felt — brukes av kanban-kort, notater, kalenderhendelser +- `pinned` for manuell fritak fra TTL-sletting +- `visibility` styrer synlighet — `'workspace'` (alle i workspacet) eller `'private'` (kun forfatter). Private meldingsbokser kan brukes som kladd for notater, kanban-kort og kalenderhendelser. Endre til `'workspace'` for å dele. + +### 3.1.1 Kontekst-navigering via `reply_to` + +Når en meldingsboks har roller i flere kontekster (f.eks. et svar som også er en kalenderoppføring), gir `reply_to`-kjeden alltid vei tilbake til opprinnelig diskusjon: + +``` +📅 Kalenderen: "Planleggingsmøte for konferanse" + ↩ Fra diskusjon i #Mediepolitikk → "Vi burde kanskje..." +``` + +UI-et følger `reply_to` → forelder → `channel_id` → parent node for å bygge brødsmulesti. Ingen ekstra data nødvendig — `reply_to` + view-config gir hele bildet i begge retninger: +- **Fra kalenderen:** "Hvor kom denne fra?" → følg `reply_to` oppover +- **Fra chatten:** "Hva ble dette?" → se at svaret har `calendar_event_view` → vis kalender-badge + +### 3.2 Kanban view-config (erstatter `kanban_cards`) + +```sql +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`) + +```sql +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`) + +```sql +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) + +```sql +CREATE TABLE message_revisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + body TEXT NOT NULL, + edited_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT revision_order UNIQUE (message_id, edited_at) +); +``` + +### 3.6 Nye relasjonstyper + +Eksisterende relasjonstyper dekker behovene — ingen nye nødvendig: +- `DISCUSSED_IN` — kobler typed nodes til meldingsbokser (diskusjon på en aktør/tema) +- `ABOUT` — kobler faktoide-meldinger til aktører/temaer +- `MENTIONS` — chat-mentions via `#`-tags + +### 3.7 node_type enum (opprydding) + +Aktive verdier etter migrering: +- `'entitet'` — person, organisasjon, sted, tema, konsept (erstatter `'aktør'` og `'tema'`) +- `'episode'`, `'segment'` — typed nodes +- `'melding'` — meldingsboks (chat, kanban-kort, kalenderhendelse, faktoide, notat) +- `'channel'` — gruppering av meldinger +- `'kanban_board'`, `'calendar'`, `'meeting'` — strukturelle + +Utfasede verdier (kan ikke fjernes fra PG ENUM, men skal aldri brukes i ny kode): +`'aktør'`, `'tema'`, `'faktoide'`, `'note'`, `'kanban_card'`, `'calendar_event'` + +## 4. Multi-rolle via view-config + +Konvertering = legg til view-config. Ingen data flyttes: + +``` +Meldingsboks (messages + nodes) + ├── kanban_card_view → vises på kanban-brettet + ├── calendar_event_view → vises i kalenderen + ├── edge: ABOUT → aktør/tema → vises som faktoide + ├── edge: DISCUSSED_IN ← typed node → diskusjon på aktør/tema/episode + ├── reply_to → parent melding → tråd-kontekst + └── reply_to ← svar → diskusjonstråd følger med i alle kontekster +``` + +En meldingsboks kan ha **flere roller samtidig**: et kanban-kort som også er en kalenderhendelse, med en diskusjonstråd under seg. Svar på meldingsboksen følger med uansett kontekst — diskusjonen er alltid tilgjengelig. + +## 5. Channels + +Channels forblir som grupperingsmekanisme. En channel samler meldingsbokser under ett scope — typisk knyttet til en typed node (tema, episode, møte). + +Channels **opprettes ved behov**, ikke automatisk for alle noder. Når en bruker starter en diskusjon på en aktør, opprettes en channel i samme transaksjon. + +Channel-config (`threads`, `mentions`, `attachments`, `ttl_days`) arves fra kontekst (se `docs/features/chat.md` §2.2). + +## 6. Slette-semantikk: "Fjern" vs "Slett" + +En meldingsboks kan ha flere roller (kanban-kort, kalenderhendelse, diskusjonstråd). UI-et må ha et skarpt, eksplisitt skille: + +| Handling | Hva skjer | Konsekvens | +|---|---|---| +| **Fjern fra brett/kalender** | `DELETE FROM kanban_card_view` / `calendar_event_view` | Meldingen lever videre i chatten og grafen. Kun visningen forsvinner. | +| **Slett innhold** | `DELETE FROM messages` → cascader til `nodes` | Alt borte: diskusjonstråd, view-configs, graf-edges. Irreversibelt. | + +**UI-regler:** +- "Fjern fra brett" / "Fjern fra kalender" — standardhandling i kontekstmeny på kanban/kalender. Trygt. +- "Slett permanent" — bak en bekreftelsesdialog ("Denne meldingen har 3 svar og er koblet til 2 entiteter. Slett alt?"). Viser konsekvensene eksplisitt. +- En melding med aktive roller i andre views bør vise en advarsel: "Denne meldingen er også et kanban-kort i [brett]. Fjern fra brettet først, eller slett alt?" + +## 7. Nesting og utskilling + +Maks 3 nivåer visuelt (via `reply_to`-kjeding): +1. Boks (trådstart) +2. Svar på boks +3. Svar på svar + +Ved nivå 3 tilbyr systemet: **"Skill ut som egen diskusjon?"** +- Svaret promoteres til boks (ny node) +- Originaltråden får en lenke-melding: "→ Diskusjonen fortsetter her" +- Den nye boksen lever sitt eget liv + +## 8. Eierskap, kurasjon og prominens + +### 8.1 Eierskap +Trådstarter og workspace-admin 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 (`COUNT` på `reply_to`) +- Stemmer (`SUM` fra `message_votes`) +- Antall roller (finnes i `kanban_card_view`, `calendar_event_view`?) +- Graf-koblinger (`COUNT` fra `graph_edges`) +- Alder på siste aktivitet (`MAX(created_at)` fra svar) + +Beregnes ved visning eller caches i materialized view ved behov. Algoritmen kan justeres uten migrasjoner eller skjemaendringer. + +## 9. TTL og livsløp + +### 9.1 To-trinns fading +1. **Skjult fra visning** — meldingen forsvinner fra default UI etter TTL (arvet fra channel/workspace). `messages.metadata.hidden_at` settes. +2. **Slettet** — etter tilleggsperiode (dobbel TTL) fjernes raden permanent. + +### 9.2 Alder som dynamisk faktor +Tid bidrar til utfasing — eldre meldinger uten aktivitet eller koblinger fader naturlig. Prominens-scoren (§8.3) synker med alder, og TTL-jobben bruker den til å avgjøre hva som skjules og slettes. + +### 9.3 Fritak-regler +En melding slettes **ikke** hvis: +- Den har **graf-edge(s)** (`ABOUT`, `MENTIONS`, `DISCUSSED_IN`, etc.) — koblet til noe varig i kunnskapsgrafen. Dette er det som gjør faktoider immune: en `ABOUT`-edge til en aktør/tema betyr at informasjonen har verdi utover konteksten den ble skrevet i. +- Den har `kanban_card_view`-rad i en aktiv kolonne +- Den har `calendar_event_view`-rad med fremtidig tidspunkt +- Den har aktive svar (siste svar innenfor TTL) +- `pinned = true` + +**Prinsippet:** Grafen bestemmer hva som er varig. Ingen spesialhåndtering for faktoider — enhver melding med en graf-kobling overlever. Meldinger uten koblinger fader med tid. + +Lever boksen, lever alt under den — svar beholdes uansett alder. + +### 9.4 Konfigurerbarhet +``` +Workspace-default TTL: 30 dager (workspaces.settings.default_ttl_days) + └── Channel kan overstyre: config.ttl_days + └── Individuelle meldinger frittes via reglene over +``` + +## 10. `` Svelte-komponent + +Én komponent som rendrer en meldingsboks i alle kontekster: +- **Kompakt modus** — kanban: tittel + "3 svar" + fargekode +- **Kalender-modus** — tittel + tidspunkt + fargekode +- **Utvidet modus** — full diskusjon med innrykk (maks 3 nivåer) +- Leser kontekst og tilpasser capabilities (stemmer, mentions, vedlegg) +- Lazy-loader tråd ved expand (ytelse) + +## 11. Konsekvenser for eksisterende kode + +### 11.1 Tabeller som fjernes +- `kanban_cards` → erstattes av `kanban_card_view` +- `calendar_events` → erstattes av `calendar_event_view` +- `factoids` + `factoid_votes` → erstattes av `messages` + `message_reactions` +- `message_votes` → erstattes av `message_reactions` +- `notes` → erstattes av `messages` med tittel + +### 11.2 Tabeller som forblir uendret +- `kanban_boards`, `kanban_columns` — strukturelle +- `calendars` — strukturell +- `channels` — gruppering +- `message_revisions` — revisjonshistorikk +- `message_attachments`, `media_files` — vedlegg + +### 11.3 API-endringer +Eksisterende API-ruter (`/api/kanban/`, `/api/calendar/`, `/api/notes/`) refaktoreres til å bruke den nye datamodellen. Grensesnittet mot frontend kan holdes stabilt — endringene er i datalaget. + +## 12. Migrering (0005_meldingsboks.sql) + +Migrasjonen konverterer eksisterende data: + +1. **Endre `messages`-tabellen:** Legg til `title`, `pinned`. `id → nodes(id)` beholdes (alle meldinger er allerede noder). +2. **Migrer kanban-kort:** For hver `kanban_cards`-rad: opprett `messages`-rad (med `title`, `body` fra description, gjenbruk eksisterende node-id) + `kanban_card_view`-rad. +3. **Migrer kalenderhendelser:** For hver `calendar_events`-rad: opprett `messages`-rad + `calendar_event_view`-rad. +4. **Migrer faktoider:** For hver `factoids`-rad: opprett `messages`-rad (med `body`, `message_type = 'factoid'`). Flytt `factoid_votes` til `message_votes`. Opprett `ABOUT`-edges i `graph_edges`. +5. **Migrer notater:** For hver `notes`-rad: opprett `messages`-rad (med `title`, `body` fra content). +6. **Drop gamle tabeller:** `kanban_cards`, `calendar_events`, `factoids`, `factoid_votes`, `notes`. + +**Merk:** Migrasjonen bør ha en tilhørende down-migrering som gjenskaper de gamle tabellene og flytter data tilbake. + +## 13. Instruks for Claude Code +- **Opprettelse av meldingsboks:** Alltid INSERT i `nodes` (type 'melding') + INSERT i `messages` med samme id. Alt i én transaksjon. +- **Kanban-kort:** INSERT i `nodes` + `messages` (med tittel) + `kanban_card_view`. Én transaksjon. +- **Kalenderhendelse:** INSERT i `nodes` + `messages` (med tittel) + `calendar_event_view`. Én transaksjon. +- **Faktoide:** INSERT i `nodes` + `messages` (`message_type = 'factoid'`) + `graph_edges` med `ABOUT`-relasjon til aktør/tema. Én transaksjon. +- **Notat:** INSERT i `nodes` + `messages` (med tittel + body). `channel_id` peker på en personal/workspace channel eller er NULL. +- **Konvertering mellom roller:** Legg til view-config-rad (kanban/kalender). Aldri kopier data. +- **Kontekst-navigering:** Bruk `reply_to`-kjeden for å bygge brødsmulesti tilbake til opprinnelig diskusjonskontekst. +- **TTL:** Implementér som nattlig jobbkø-jobb (`message_ttl_cleanup`). Sjekk fritak-regler før sletting. Ved sletting: slett fra `messages` → cascade til `nodes` via FK. +- **RLS:** Workspace-isolasjon arves fra `nodes.workspace_id`. Visibility håndteres i applikasjonskode (SvelteKit): `WHERE visibility = 'workspace' OR author_id = $current_user`. Ikke i RLS — RLS håndterer kun workspace-grenser. +- **Visibility:** Default `'workspace'`. Sett `'private'` for personlige kladder. Endre til `'workspace'` for å dele — ingen kopiering nødvendig. diff --git a/docs/features/notater.md b/docs/features/notater.md new file mode 100644 index 0000000..d926b34 --- /dev/null +++ b/docs/features/notater.md @@ -0,0 +1,68 @@ +# 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 — arver workspace-isolasjon automatisk +- 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) + +```sql +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 +* Notater er workspace-scopet via node-modellen +* Auto-save bruker debounce — ikke send PATCH ved hvert tastetrykk +* `updated_at` brukes til UI-feedback, ikke til conflict resolution (ennå) +* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi diff --git a/docs/features/podcast_statistikk.md b/docs/features/podcast_statistikk.md new file mode 100644 index 0000000..b3823a4 --- /dev/null +++ b/docs/features/podcast_statistikk.md @@ -0,0 +1,30 @@ +# Feature Spec: Podcast-Statistikk +**Filsti:** `docs/features/podcast_statistikk.md` + +## 1. Konsept +IAB-kompatibel lytterstatistikk bygget fra bunnen av. Vi fanger all rådata via Caddy, og bruker asynkron batch-prosessering for å bygge grafer og tall uten å belaste webserveren eller databasen med sanntids-skriving. + +## 2. Arkitektur & Dataflyt +1. **Rådata (Caddy):** Caddy konfigureres til å skrive access-logs for stien `/media/podcast/*.mp3` til en formatert JSON-fil (f.eks. `/srv/sidelinja/logs/caddy/podcast_access.log`). +2. **Logrotate:** Standard Linux logrotate arkiverer loggene nattlig. +3. **Rust Batch Processor (Jobbkø):** Statistikkparseren kjøres som en `stats_parse`-jobb i den felles jobbkøen (se `docs/infra/jobbkø.md`), med `scheduled_for` satt 1 time frem for periodisk kjøring. Workeren re-enqueuer seg selv ved fullføring. + * **Steg A (Filtrering):** Leser JSON-loggen. Fjerner treff fra kjente bots ved å krysjekke `User-Agent` mot OPAWG (Open Podcast Analytics Working Group) sine åpne bot-lister. + * **Steg B (Deduplisering):** Slår sammen byte-range forespørsler. Hvis samme IP og User-Agent har lastet ned deler av samme fil innenfor et 24-timers vindu, telles det som KUN én (1) nedlasting. + * **Steg C (Geografi/Klient):** Mapper User-Agent til Podcast-klient (Spotify, Apple) basert på OPAWG-regler. +4. **Lagring (PostgreSQL):** Rust-programmet skriver det aggregerte resultatet inn i PostgreSQL (`episode_stats` tabell med felter for `workspace_id`, `date`, `episode_id`, `client_name`, `unique_downloads`). Workspace-tilhørighet utledes fra filstien i loggen (`/media/{workspace_slug}/...`). + +## 3. Personvern og GDPR + +Caddy access-logger inneholder IP-adresser og User-Agent — dette er personopplysninger under GDPR. + +**Tiltak:** +* **IP-anonymisering:** Rust-workeren hasher IP-adresser (SHA-256 med daglig roterende salt) *før* de lagres i `episode_stats`. Rå IP-er lagres aldri i PostgreSQL. +* **Loggretensjon:** Caddy-logger roteres og slettes etter 90 dager (kategori 4, flyktig). Etter at statistikk-workeren har prosessert en loggfil, inneholder PG kun aggregerte tall — ingen identifiserbar data. +* **Ingen sporing på tvers:** Vi bruker ikke cookies, fingerprinting eller tredjepartstracking. Deduplisering basert på IP + User-Agent i 24-timers vindu er IAB-standard og minimumsdata. +* **Dokumentasjon:** Podcastens RSS-feed bør lenke til en personvernerklæring som forklarer at anonymisert nedlastingsstatistikk samles inn. + +## 4. Instruks for Claude Code +* Bruk Rust-biblioteket `serde_json` for rask parsing av Caddy-loggene. +* Dette programmet må skrives robust med tanke på at filer kan være låst av Caddy. Det bør tåle å avbrytes, og må holde styr på hvilken linje i loggfilen det prosesserte sist (f.eks. via en liten cursor-fil). +* Rålogger skal ALDRI lagres i PostgreSQL. +* Statistikk er workspace-scopet. `episode_stats` merkes med `workspace_id`. Admin-visningen filtrerer per workspace. \ No newline at end of file diff --git a/docs/features/prompt_lab.md b/docs/features/prompt_lab.md new file mode 100644 index 0000000..213ef4e --- /dev/null +++ b/docs/features/prompt_lab.md @@ -0,0 +1,101 @@ +# Feature: Prompt-Laboratorium +**Filsti:** `docs/features/prompt_lab.md` + +## 1. Konsept +Et internt kvalitetssikringsverktøy der redaksjonen kan teste, sammenligne og godkjenne LLM-prompts mot faktiske data fra eget workspace — før de ruller ut i produksjon. Integrert med AI Gateway (LiteLLM) og Promptfoo-testsettene. + +## 2. Problemet det løser +- Modellkvalitet på norsk varierer kraftig mellom leverandører og versjoner. +- Leverandører oppdaterer modeller uten varsel — kvaliteten kan degraderes over natten. +- Redaksjonen må kunne verifisere at en prompt fungerer *med deres data* (transkripsjoner, artikler, aktørnavn) før den settes i produksjon. +- I dag krever prompt-testing kommandolinje og Promptfoo — det bør være tilgjengelig i nettleseren. + +## 3. Brukeropplevelse + +### 3.1 Playground (Ad-hoc testing) +1. Bruker velger en **jobbtype** (research_clip, whisper_postprocess, metadata_extract, dictation_cleanup, etc.). +2. Systemet laster inn gjeldende system-prompt fra `workspaces.settings`. +3. Bruker kan redigere prompten i et tekstfelt. +4. Velger **testdata**: enten paste inn tekst manuelt, eller velg fra faktiske transkripsjoner/artikler i workspace-et. +5. Velger **modeller** å teste mot (f.eks. `sidelinja/rutine` + `sidelinja/resonering`). +6. Kjører testen — ser resultatene side-om-side. +7. Kan iterere: endre prompten, kjør igjen, sammenlign. + +### 3.2 Batch-evaluering (Promptfoo-integrasjon) +1. Bruker velger et lagret **testsett** (fra `tests/prompts/` i Git). +2. Kjører testsettene mot valgte modeller via AI Gateway. +3. Ser en matrise: testcase × modell × resultat, med pass/fail-markering. +4. Kan sammenligne mot tidligere kjøringer for å oppdage regresjoner. + +### 3.3 Deploy +Når en prompt er verifisert: +1. Bruker klikker "Deploy til workspace". +2. Prompten skrives til `workspaces.settings.llm_prompts[jobbtype]`. +3. Alle fremtidige jobber av den typen bruker den nye prompten. +4. Tidligere prompt lagres i en `prompt_history`-logg for rollback. + +## 4. Teknisk arkitektur + +### 4.1 Datamodell + +```sql +prompt_test_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + 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_workspace ON prompt_test_runs(workspace_id, 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 + +```sql +prompt_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + 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` eller `owner`-rolle i workspace. +* 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 fra workspace-et (transkripsjoner, artikler) lastes via SvelteKit server-side fra PG. Gjesten-UX vises aldri her. +* `prompt_test_runs` og `prompt_history` er workspace-scopet men trenger ikke RLS — de er kun tilgjengelige for admins via applikasjonslogikk. +* Alt er workspace-scopet. diff --git a/docs/features/universell_overfoering.md b/docs/features/universell_overfoering.md new file mode 100644 index 0000000..14a5cbb --- /dev/null +++ b/docs/features/universell_overfoering.md @@ -0,0 +1,202 @@ +# 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 + +```sql +CREATE TABLE message_placements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + context_type TEXT NOT NULL, -- 'chat', 'kanban', 'storyboard', 'calendar', 'notes' + context_id UUID NOT NULL, -- channel_id, board_id, episode_id, calendar_id, note_id + entered_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- når objektet ankom denne konteksten + position JSONB, -- kontekst-spesifikk posisjon (se §2.2) + UNIQUE (message_id, context_type, context_id) +); + +CREATE INDEX idx_placements_context ON message_placements(context_type, context_id, entered_at); +CREATE INDEX idx_placements_message ON message_placements(message_id); +``` + +### 2.2 Posisjonsdata per kontekst + +`position`-feltet er JSONB og inneholder kontekst-spesifikk plassering: + +| Kontekst | position-innhold | Eksempel | +|----------|-----------------|----------| +| Chat | `null` (sorteres etter `entered_at`) | `null` | +| Kanban | `{ "column_id": "...", "position": 1.5 }` | Kolonne + rekkefølge | +| Storyboard | `{ "x": 340, "y": 120 }` | Fritt canvas-posisjon | +| Kalender | `{ "date": "2026-03-20", "all_day": true }` | Dato + tidspunkt | +| Notes | `{ "position": 3 }` | Rekkefølge i notatet | + +### 2.3 Forhold til eksisterende view-configs + +`message_placements` erstatter *ikke* de eksisterende view-config-tabellene (`kanban_card_view`, `calendar_event_view`) umiddelbart. Strategien er: + +1. **Fase 1:** `message_placements` brukes for nye kontekster (storyboard, notes) og for overføringsmekanikken +2. **Fase 2:** Eksisterende view-configs migreres gradvis til `message_placements` (kanban-posisjon, kalender-dato) +3. **Fase 3:** `kanban_card_view` og `calendar_event_view` kan fjernes når all logikk bruker placements + +### 2.4 `entered_at` vs `created_at` + +- `messages.created_at` = når meldingen ble skrevet +- `message_placements.entered_at` = når meldingen ankom *denne konteksten* + +En melding opprettet mandag i chatten som dras til storyboardet onsdag har `created_at = mandag` og storyboard-plassering med `entered_at = onsdag`. I chatten sorteres den etter sin chat-plasserings `entered_at` — som er mandag (opprinnelig plassering). I storyboardet vises den på canvas-posisjonen, uavhengig av tid. + +## 3. Sende-mekanikk (source) + +### 3.1 Drag-and-drop + +Brukeren drar et objekt ut av en blokk. Når objektet forlater blokk-grensen: + +1. Objektet blir en "ghost" (semi-transparent drag-representasjon) +2. Andre blokker som kan motta objektet highlighter sin mottakssone +3. Slipp på en mottakssone → overføring + +### 3.2 Kontekstmeny: "Send til..." + +For situasjoner der drag-and-drop er upraktisk (mobil, lang avstand): + +``` +Høyreklikk kort → "Send til..." → + ├── 📋 Kanban: Episodeplanlegging + ├── 💬 Chat: #studio-diskusjon + ├── 📅 Kalender: Redaksjonskalender + └── 🎬 Storyboard: Episode 47 +``` + +Listen viser alle blokker i det aktive workspacet 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 + +```typescript +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: + +```rust +#[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 + pub workspace_id: String, +} + +#[reducer] +pub fn place_message(ctx: &ReducerContext, placement: MessagePlacement) { ... } + +#[reducer] +pub fn remove_placement(ctx: &ReducerContext, message_id: String, context_type: String, context_id: String) { ... } + +#[reducer] +pub fn move_on_canvas(ctx: &ReducerContext, placement_id: String, new_position_json: String) { ... } +``` + +Sync-workeren persisterer til PG `message_placements`-tabellen. + +## 6. Responsivt design + +- **Desktop:** Drag-and-drop mellom blokker fungerer naturlig +- **Tablet:** Drag-and-drop fungerer med touch, men "Send til..."-meny er primær +- **Mobil:** Kun "Send til..."-meny (blokker er stacked i én kolonne, drag mellom dem er upraktisk) + +## 7. Bygger på + +- **Meldingsboks** (`meldingsboks.md`): Alle overførte objekter er meldingsbokser +- **Kunnskapsgraf** (`kunnskapsgraf_og_relasjoner.md`): Plasseringer er relasjoner i grafen +- **BlockShell** / PageGrid: Blokk-rammen som rendrer mottakssoner +- **SpacetimeDB** (`synkronisering.md`): Sanntidssynk av plasseringer + +## 8. Konsekvenser for eksisterende kode + +### 8.1 BlockShell utvidelse + +`BlockShell` trenger: +- `onDragEnter`/`onDragLeave`/`onDrop` handlers for visuell feedback +- Prop for `receiver: BlockReceiver` fra innholdsblokken +- Fullskjerm-toggle (knapp i header + Esc for å lukke) + +### 8.2 Ny plasserings-tabell + +`message_placements` er ny. Eksisterende `kanban_card_view` og `calendar_event_view` lever parallelt inntil migrering. + +### 8.3 SpacetimeDB-modul + +Ny tabell `message_placement` med reducers for place/remove/move. + +## 9. Instruks for Claude Code +- Overføring oppretter aldri en kopi av meldingen — kun en ny plassering (view-config) +- `entered_at` er alltid `now()` ved overføring, aldri kopiert fra kilden +- En melding uten noen plasseringer er "løs" — den eksisterer i grafen men vises ikke noe sted. UI skal advare om dette ved siste fjerning +- Drag-and-drop bruker HTML5 Drag and Drop API for blokk-til-blokk, og pointer events for intra-canvas (storyboard/whiteboard) +- Hold overføringslogikken i en sentral `transferService` — ikke spread ut i hver blokk-type +- Mottaker-interfacet er obligatorisk for alle blokk-typer diff --git a/docs/features/visuell_graf.md b/docs/features/visuell_graf.md new file mode 100644 index 0000000..53671f8 --- /dev/null +++ b/docs/features/visuell_graf.md @@ -0,0 +1,20 @@ +# Feature: Visuell Kunnskapsgraf (Graph View) +**Filsti:** `docs/features/visuell_graf.md` + +## 1. Konsept +En interaktiv graf-visning i SvelteKit som gjør Kunnskapsgrafen visuelt navigerbar og redigerbar. Brukes i Kunnskapsgrafen-konseptet (se `docs/concepts/kunnskapsgrafen.md`). + +## 2. Funksjonalitet +* **Visualisering:** Viser noder (Temaer, Aktører, Faktoider, Segmenter) og relasjoner som et interaktivt nettverkskart. Bygges med D3.js eller Vis.js i Svelte-frontenden. +* **Visuell redigering:** Redaksjonen kan dra streker mellom noder for å opprette nye relasjoner i `graph_edges`-tabellen. Velg relasjonstype (`PART_OF`, `CONTRADICTS`, `MENTIONS`, etc.) via en kontekstmeny. +* **Hierarkier uten mapper:** `PART_OF`-relasjoner muliggjør fleksible prosjekthierarkier uten stive mappestrukturer. Et Tema kan være `PART_OF` et annet Tema, en Aktør kan være `PART_OF` en organisasjon, osv. +* **Filtrering:** Brukeren kan filtrere grafen etter nodetype, relasjonstype, tidsperiode eller fritekst. + +## 3. Datakilde +* **SvelteKit server-side:** Henter grafdata via Recursive CTE-spørringer mot PostgreSQL og returnerer `{ "nodes": [...], "edges": [...] }` til klienten. +* **SpacetimeDB:** Brukes ikke for graf-visualisering — dette er historiske data som lever i PostgreSQL. + +## 4. Instruks for Claude Code +* Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (D3.js, Vis.js). +* Begrens traversering til 2-3 ledd ut fra startnode for å unngå eksplosjoner i store grafer. +* Workspace-scopet: alle noder filtreres via `nodes.workspace_id`. diff --git a/docs/features/whiteboard.md b/docs/features/whiteboard.md new file mode 100644 index 0000000..72c8b69 --- /dev/null +++ b/docs/features/whiteboard.md @@ -0,0 +1,34 @@ +# 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 tilhører en workspace. +* 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` i workspace-mappen. Referansen knyttes til konteksten (melding, møte) via `message_attachments` eller tilsvarende. +* **Dataklassifisering:** Strøk-data i SpacetimeDB er flyktig (kategori 4). Eksporterte bilder er avledet (kategori 3) — kan gjenskapes fra strøk-data så lenge tavlen er aktiv, men etter lukking er bildet den permanente kopien. + +## 6. Instruks for Claude Code +* Whiteboard-komponenten skal være en gjenbrukbar Svelte-komponent som kan mountes i møterom, chat og som frittstående side. +* SpacetimeDB-tabellen for strøk bør være enkel: `whiteboard_id`, `stroke_data` (JSON), `user_id`, `timestamp`. +* Ikke bygg et fullverdig tegneprogram — start med frihåndstegning, viskelær og farger. Utvid ved behov. +* Alle whiteboards er workspace-scopet. diff --git a/docs/infra/ai_gateway.md b/docs/infra/ai_gateway.md new file mode 100644 index 0000000..c8e046d --- /dev/null +++ b/docs/infra/ai_gateway.md @@ -0,0 +1,249 @@ +# Infrastruktur: AI Gateway (LiteLLM) +**Filsti:** `docs/infra/ai_gateway.md` + +## 1. Konsept +Sidelinja 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 + +```sql +-- Globale modellaliaser (server-nivå, ikke per workspace) +CREATE TABLE ai_model_aliases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + alias TEXT NOT NULL, -- 'sidelinja/rutine', 'sidelinja/resonering' + description TEXT, -- 'Billig, høyt volum' + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(alias) +); + +-- Leverandør-modeller med prioritert fallback per alias +CREATE TABLE ai_model_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + alias_id UUID NOT NULL REFERENCES ai_model_aliases(id) ON DELETE CASCADE, + provider TEXT NOT NULL, -- 'gemini', 'openrouter', 'anthropic' + model TEXT NOT NULL, -- 'gemini/gemini-2.5-flash', 'openrouter/anthropic/claude-sonnet-4' + api_key_env TEXT NOT NULL, -- 'GEMINI_API_KEY', 'OPENROUTER_API_KEY' + priority SMALLINT NOT NULL, -- lavere = prøves først + is_active BOOLEAN NOT NULL DEFAULT true, + UNIQUE(alias_id, model) +); + +-- Jobbtype → modellalias mapping +CREATE TABLE ai_job_routing ( + job_type TEXT PRIMARY KEY, -- 'ai_text_process', 'whisper_postprocess', etc. + alias TEXT NOT NULL, -- 'sidelinja/rutine' + description TEXT +); +``` + +### 3.3 Config-generering + +SvelteKit-serveren genererer `config.yaml` fra PG ved oppstart og ved endringer i admin-panelet: + +1. Les aktive aliaser og deres providers (sortert etter priority) +2. Skriv `config.yaml` til volum delt med LiteLLM-containeren +3. Restart LiteLLM (`docker restart ai-gateway`) eller send `SIGHUP` + +Generert config inkluderer alltid `router_settings` og `general_settings` fra faste verdier — kun `model_list` er dynamisk. + +### 3.4 Jobbkø-styrt modellvalg + +Jobbkøen bruker `ai_job_routing` for å bestemme modellalias per jobbtype: + +| Jobbtype | Standard alias | Begrunnelse | +|---|---|---| +| `ai_text_process` (✨-behandling) | `sidelinja/rutine` | Tekstvasking, høyt volum | +| `whisper_postprocess` | `sidelinja/rutine` | Transkripsjonsvasking, høyt volum | +| `research_clip` | `sidelinja/rutine` | Research-oppsummering, høyt volum | +| `live_factoid_eval` | `sidelinja/resonering` | Krever presis vurdering under tidspress | + +Modellalias lagres som felt på jobben i PG — kan overstyres manuelt per jobb ved behov. + +### 3.5 Admin-panel (`/admin/ai`) + +Admin-panelet lar administrator: +* Se og redigere modellaliaser og deres fallback-liste (drag-and-drop prioritering) +* Aktivere/deaktivere individuelle leverandør-modeller +* Endre jobbtype → alias mapping +* Se live-status: hvilke leverandører som svarer, responstider +* Trigge config-regenerering og LiteLLM-restart + +## 4. Docker-oppsett + +```yaml +# 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](https://promptfoo.dev) 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: + +```yaml +# promptfoo-config.yaml +providers: + - id: "openai:chat:sidelinja/rutine" + config: + apiBaseUrl: "http://localhost:4000/v1" + apiKey: "${LITELLM_MASTER_KEY}" + - id: "openai:chat:sidelinja/resonering" + config: + apiBaseUrl: "http://localhost:4000/v1" + apiKey: "${LITELLM_MASTER_KEY}" +``` + +Dette lar oss svare på: +* Klarer Gemini (gratis) denne oppgaven like bra som Claude (betalt)? +* Fungerer prompten på norsk, eller trenger vi en annen formulering? +* Har en modelloppgradering hos leverandøren degradert kvaliteten? + +### 5.3 Når vi kjører tester +* **Ved ny prompt:** Før den tas i bruk i produksjon +* **Ved modellbytte:** Før en leverandør/modell settes som primær for en jobbtype +* **Periodisk (CI):** Månedlig cron-jobb i Forgejo Actions kjører `promptfoo eval` mot alle testsett. Resultater postes som issue ved regresjoner. Leverandører oppdaterer modeller uten varsel — automatisk regresjonssjekk fanger dette opp. +* **Ved kvalitetsklager:** Når redaksjonen rapporterer dårlig output + +### 5.4 Lagring av testsett +Testsett og promptfoo-config versjonskontrolleres i Git under `tests/prompts/`. Testdata er norske eksempler fra faktiske episoder og artikler. + +``` +tests/prompts/ +├── promptfooconfig.yaml +├── whisper_postprocess/ +│ ├── prompt.txt +│ └── dataset.json +├── metadata_extract/ +│ ├── prompt.txt +│ └── dataset.json +└── research_clip/ + ├── prompt.txt + └── dataset.json +``` + +## 6. Tokenregnskap og kostnadskontroll + +### 6.1 Token-logging per workspace + +Rust-workeren logger tokenforbruk etter hvert AI-kall. Dataen lagres i PG: + +```sql +CREATE TABLE ai_usage_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(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_workspace_month ON ai_usage_log (workspace_id, created_at); +``` + +**Flyten:** +1. Rust-worker sender AI-kall via gateway, får tilbake `usage` i responsen +2. Worker skriver rad til `ai_usage_log` med workspace_id, tokens og modellinfo +3. Estimert kostnad beregnes fra en enkel prisliste i config (oppdateres manuelt) + +### 6.2 Visning — to nivåer + +**Admin (`/admin/ai`):** +Aggregert oversikt over alle workspaces. Tabell med totaler per workspace/modell/periode. Identifiserer kostnadsdrivere. + +**Workspace (sidebar-widget):** +Enkel tekst-indikator i workspace-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 Workspace-budsjett (fase 2) + +Når token-logging er på plass, kan budsjett-tak legges til: + +- Budsjett lagres i `workspaces.settings` (JSONB): `{ "ai_budget": { "monthly_limit_usd": 50 } }` +- Rust-worker sjekker aggregert forbruk før AI-kall +- Ved budsjett nær: fall tilbake til `sidelinja/rutine` (billigste) +- Ved budsjett nådd: sett jobb i `paused` med varsel i workspace-chat + +### 6.4 Per-episode maks-kostnad +Podcastfabrikken-jobber (whisper + metadata + oppsummering) kan estimere totalkostnad basert på lydlengde. Jobben avbrytes med varsel hvis estimert kostnad overstiger `max_cost_per_episode` (default: $5). + +## 7. Dataklassifisering (ref. docs/arkitektur.md 2.2) + +| Data | Kategori | Detaljer | +|---|---|---| +| LiteLLM config.yaml | Gjenskapbar (Git) | Versjonskontrollert | +| API-nøkler | Kritisk (.env) | Aldri i Git | +| Token-bruk-logger | Flyktig (TTL 90 dager) | For kostnadsoversikt, ryddes automatisk | +| Promptfoo testsett | Gjenskapbar (Git) | `tests/prompts/` — versjonskontrollert | +| Promptfoo testresultater | Flyktig (lokal) | Kjøres on-demand, ikke lagret permanent | + +## 8. Kildevern-modus (proposal) +For sensitive redaksjonelle diskusjoner kan en lokal LLM-leverandør (Ollama/vLLM) registreres som `sidelinja/lokal` i config. Channels/møter med `kildevern: true` ruter all AI-prosessering til denne modellen — data forlater aldri serveren. Se `docs/proposals/kildevern_modus.md`. + +## 9. Instruks for Claude Code +* All AI-kode skal peke på `http://ai-gateway:4000/v1` — aldri direkte til leverandør +* Bruk modellaliaser (`sidelinja/rutine`, `sidelinja/resonering`) — aldri hardkod leverandør-spesifikke modellnavn i applikasjonskode +* API-nøkler i `.env`, aldri i config-filer eller kode +* Test alltid med norsk innhold før en ny modell/leverandør tas i bruk for en produksjonsoppgave +* Kjør `promptfoo eval` før du endrer prompts eller bytter modell for en jobbtype +* Nye jobbtyper som bruker LLM skal ha et tilhørende testsett i `tests/prompts/` før de merges diff --git a/docs/infra/api_grensesnitt.md b/docs/infra/api_grensesnitt.md new file mode 100644 index 0000000..8f25c72 --- /dev/null +++ b/docs/infra/api_grensesnitt.md @@ -0,0 +1,73 @@ +# Infrastruktur: API-grensesnitt og Tjenesteansvar +**Filsti:** `docs/infra/api_grensesnitt.md` + +## 1. Konsept +Definerer hvordan SvelteKit-frontenden kommuniserer med backend-tjenestene. Prinsippet er: **SvelteKit er web-serveren, Rust er workeren.** Ingen separat Rust HTTP API. + +## 2. Kommunikasjonskart + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Brukerens nettleser (SvelteKit klient) │ +└──────────┬──────────────────────┬───────────────────────────┘ + │ │ + │ WebSocket │ HTTP (forms, fetch) + ▼ ▼ +┌──────────────────┐ ┌─────────────────────────────────────┐ +│ SpacetimeDB │ │ SvelteKit Server │ +│ │ │ (load functions, form actions, │ +│ - Chat │ │ API routes) │ +│ - Kanban │ │ │ +│ - Live events │ │ Ansvar: │ +│ - Autocomplete │ │ - Les/skriv PostgreSQL direkte │ +│ - Studio- │ │ - Opprett jobber i job_queue │ +│ markører │ │ - Filopplasting (streaming) │ +│ │ │ - RSS-generering │ +│ │ │ - Kunnskapsgraf-spørringer │ +└──────────────────┘ └──────────────┬───────────────────────┘ + │ + │ SQL + ▼ + ┌──────────────────────────┐ + │ PostgreSQL │ + │ │ + │ - Kunnskapsgraf │ + │ - Episodemetadata │ + │ - Statistikk │ + │ - Jobbkø (job_queue) │ + │ - Brukerdata │ + └──────────────┬────────────┘ + │ + │ Poll (SELECT FOR UPDATE) + ▼ + ┌──────────────────────────┐ + │ Rust Workers │ + │ │ + │ - whisper_transcribe │ + │ - openrouter_analyze │ + │ - research_clip │ + │ - stats_parse │ + │ - sync_to_pg (SpaceDB→PG)│ + └──────────────────────────┘ +``` + +## 3. Ansvarsfordeling + +| Komponent | Rolle | Snakker med | +|---|---|---| +| **SvelteKit (klient)** | UI, brukerinteraksjon | SpacetimeDB (WS), SvelteKit server (HTTP) | +| **SvelteKit (server)** | Web-API, PG-tilgang, jobb-trigger | PostgreSQL (SQL) | +| **SpacetimeDB** | Sanntids state, push til klienter | Klienter (WS), sync-worker (intern) | +| **Rust Workers** | Tunge bakgrunnsjobber, synk | PostgreSQL (SQL), SpacetimeDB, OpenRouter, faster-whisper | + +## 4. Viktige avklaringer +- **Rust er ikke en API-server.** Rust kjører kun som workers/prosessorer som poller jobbkøen +- **SvelteKit server-side er trygt.** Load functions og form actions kjører på serveren og kan snakke direkte med PG uten sikkerhetsproblemer +- **Filopplasting** håndteres av SvelteKit (streaming for store filer), som lagrer filen på disk og oppretter en jobb i køen +- **SpacetimeDB nås aldri via SvelteKit server** — kun direkte fra klienten via WebSocket + +## 5. Instruks for Claude Code +- Ikke opprett et separat Rust HTTP API/webserver-prosjekt +- Bruk SvelteKit `+server.ts` (API routes) eller `+page.server.ts` (form actions/load) for all HTTP-kommunikasjon +- Rust-kode skal struktureres som worker-binærer som konsumerer fra `job_queue` +- For PG-tilgang i SvelteKit, bruk et bibliotek som `postgres.js` eller `drizzle-orm` diff --git a/docs/infra/jobbkø.md b/docs/infra/jobbkø.md new file mode 100644 index 0000000..498e2c6 --- /dev/null +++ b/docs/infra/jobbkø.md @@ -0,0 +1,140 @@ +# Infrastruktur: Jobbkø (PostgreSQL-basert) +**Filsti:** `docs/infra/jobbkø.md` + +## 1. Konsept +Et felles, sentralisert køsystem for alle asynkrone bakgrunnsjobber i Sidelinja. Bygget som en enkel tabell i PostgreSQL med Rust-workers som konsumerer jobber. Ingen ekstern message broker — PostgreSQL er køen. + +## 2. Hvorfor PostgreSQL? +- Allerede i stacken, ingen ny infrastruktur å drifte +- Transaksjonell garanti: jobben og resultatet kan committes sammen med dataendringer +- Lavt volum (titalls jobber/time) gjør polling neglisjerbart +- Enkel feilsøking via SQL (`SELECT * FROM job_queue WHERE status = 'error'`) +- `SELECT ... FOR UPDATE SKIP LOCKED` gir trygg concurrent polling uten låsekonflikt + +## 3. Datastruktur + +```sql +CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'error', 'retry'); + +CREATE TABLE job_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + job_type TEXT NOT NULL, -- 'whisper_transcribe', 'openrouter_analyze', 'stats_parse', 'research_clip' + payload JSONB NOT NULL, -- Inputdata (filsti, tekst, tema_id, etc.) + status job_status NOT NULL DEFAULT 'pending', + priority SMALLINT NOT NULL DEFAULT 0, -- Høyere = viktigere + result JSONB, -- Resultatet ved fullført jobb + error_msg TEXT, -- Feilmelding ved error + attempts SMALLINT NOT NULL DEFAULT 0, + max_attempts SMALLINT NOT NULL DEFAULT 3, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now() -- For utsatte jobber / retry med backoff +); + +CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for ASC) + WHERE status IN ('pending', 'retry'); +``` + +## 4. Worker-arkitektur (Rust) + +### 4.1 Designprinsipp: Orkestrator, ikke prosesseringsmotor +Workeren gjør lite tung prosessering selv. Den er en **orkestrator** som koordinerer eksterne tjenester: + +| Jobbtype | Hva workeren gjør | Tung logikk i workeren? | +|---|---|---| +| `whisper_transcribe` | HTTP-kall til faster-whisper-server, commit SRT til Forgejo | Nei — venter på svar | +| `openrouter_analyze` | HTTP-kall til AI Gateway | Nei — venter på svar | +| `srt_parse` | Parser SRT-tekst, skriver avledede formater til PG | Lett strengparsing | +| `stats_parse` | Parser Caddy-loggfiler, skriver til PG | Lett I/O | +| `research_clip` | HTTP-kall til AI Gateway | Nei — venter på svar | +| `generate_embeddings` | HTTP-kall til AI Gateway | Nei — venter på svar | + +Ny jobbtype = ny handler-funksjon (bygg request, håndter respons, feilhåndtering). Tynt glue-code. Rekompilering er triviell og inkrementell. + +### 4.2 Én worker, prioritetsstyrt +Én enkelt worker-prosess håndterer **alle jobbtyper**. Prioritering skjer via `priority`-kolonnen i køen — SQL-spørringen plukker alltid viktigste jobb først. Ingen behov for separate prosesser per jobbtype. + +``` +┌──────────────────────────────────────────────────┐ +│ Rust Worker (sidelinja-worker) │ +│ │ +│ Konfigurasjon: │ +│ --max-concurrent 3 (samtidige jobber) │ +│ --poll-interval 1s │ +│ │ +│ Loop (per ledig slot): │ +│ 1. SELECT ... FOR UPDATE SKIP LOCKED │ +│ WHERE status IN ('pending','retry') │ +│ AND scheduled_for <= now() │ +│ ORDER BY priority DESC, scheduled_for │ +│ LIMIT 1 │ +│ │ +│ 2. UPDATE status = 'running' │ +│ 3. Dispatch til handler basert på job_type │ +│ 4a. OK: UPDATE status = 'completed' │ +│ 4b. Feil: attempts += 1 │ +│ Hvis attempts < max_attempts: │ +│ status = 'retry' │ +│ scheduled_for = now() │ +│ + backoff(attempts) │ +│ Ellers: status = 'error' │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +### 4.3 Prioritetsmodell + +| Prioritet | Kategori | Eksempler | +|---|---|---| +| 10 | Brukerrettet / sanntid | `dictation_cleanup`, `research_clip` | +| 5 | Normal | `whisper_transcribe`, `openrouter_analyze`, `srt_parse` | +| 1 | Bakgrunn | `stats_parse`, `generate_embeddings`, `prompt_eval` | + +Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på kontekst. En manuelt trigget transkripsjon kan få høyere prioritet enn en automatisk nattjobb. + +### 4.4 Ressursstyring +* **Concurrency:** `--max-concurrent` begrenser antall samtidige jobber. Default 3 — passer for 8 vCPU der noen slots er Whisper (CPU-tung) og resten er HTTP-kall (ventetid). +* **Resource Governor (Whisper):** Når et LiveKit-rom er aktivt, reduserer workeren Whisper-tråder (`--threads 2` i HTTP-kall til faster-whisper) for å beskytte lydkvaliteten. Sjekkes via LiveKit room-status før Whisper-kall. +* **Skalering senere:** To nivåer: + 1. **Worker-splitting:** Workeren splittes til to binærer fra samme crate (`worker-heavy`, `worker-light`) via CLI-argument (`--types whisper_transcribe,openrouter_analyze`). Ingen kodeendring nødvendig — kun deploy-konfigurasjon. + 2. **Compute-separasjon:** Flytt Rust-worker + faster-whisper til en separat Hetzner-node (evt. ARM/Ampere for pris/ytelse). LiveKit er ekstremt sensitivt for CPU-stotring — ved samtidig WebRTC og Whisper på samme maskin risikerer vi audio glitches uansett cgroups. Worker-noden poller jobbkøen i PostgreSQL over internt nettverk — arkitekturen støtter dette uten kodeendring. + +**Backoff-strategi:** Eksponentiell: `30s × 2^(attempts-1)` (30s, 60s, 120s). + +## 5. Jobbtyper + +| `job_type` | Konsument | Beskrivelse | +|---|---|---| +| `whisper_transcribe` | Podcastfabrikken | Transkriber MP3 via faster-whisper | +| `openrouter_analyze` | Podcastfabrikken | Metadata-uttrekk fra transkripsjon | +| `ai_text_process` | Editor (AI-knapp) | Rens, oppsummer, trekk ut fakta, skriv om (se `docs/proposals/editor.md`) | +| `stats_parse` | Podcast-Statistikk | Batch-prosesser Caddy-logger | +| `meeting_summarize` | Møterommet | Generer møtereferat og action points fra transkripsjon | +| `valgomat_generate_profile` | Valgomat | Generer syntetiske kandidatprofiler fra partiprogrammer | +| `valgomat_moderation` | Valgomat | Semantisk deduplisering og nøytralitetsvask av brukerspørsmål | +| `dictation_cleanup` | Lydmeldinger | AI-opprydding av diktert transkripsjon til strukturert notat | +| `generate_embeddings` | Kunnskaps-Bridge | Generer vector embeddings for noder (pgvector) | +| `prompt_eval` | Prompt-Laboratorium | Batch-evaluering av testsett mot valgte modeller | +| `url_ingest` | Web Clipper (proposal) | Hent URL, oppsummer via AI, opprett research-klipp med graf-koblinger | +| `generate_waveform` | Waveforms (proposal) | Generer audio-peaks fra lydfil for visuell bølgeform | + +## 6. Workspace-isolasjon +Alle jobber merkes med `workspace_id`. Rust-workers kjører som superuser (bypasser RLS) og sikrer isolasjon i applikasjonskode: +* Worker leser `workspace_id` fra jobben og bruker det til å lagre resultater tilbake i riktig silo +* Workspace-spesifikk config (AI-prompts, navnelister) hentes fra `workspaces.settings` +* Feilede jobber vises kun for brukere i riktig workspace 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` +- Bruk `tokio` med semaphore for concurrency-kontroll (`--max-concurrent`) +- Aldri lagre lydfiler i `payload` — bruk filstier +- Opprett alltid jobber med riktig `workspace_id` — hent fra konteksten (innlogget bruker, webhook, etc.) +- Ved `stats_parse`: denne erstatter den frittstående cronjobben beskrevet i podcast_statistikk.md — bruk jobbkøen med `scheduled_for` for periodisk kjøring +- Splitt til flere binærer kun hvis det blir eksplisitt bedt om — start med én diff --git a/docs/infra/synkronisering.md b/docs/infra/synkronisering.md new file mode 100644 index 0000000..aef0fd5 --- /dev/null +++ b/docs/infra/synkronisering.md @@ -0,0 +1,176 @@ +# Infrastruktur: PostgreSQL ↔ SpacetimeDB Synkronisering +**Filsti:** `docs/infra/synkronisering.md` + +## 1. Konsept +SpacetimeDB gir sanntidsopplevelsen, PostgreSQL er langtidsminnet. Denne spec-en definerer hvordan data flyter mellom dem, hvem som eier sannheten, og hva som skjer ved feil. + +### 1.1 Grunnregel: SpacetimeDB er en ren sanntidsbuffer +SpacetimeDB skal **aldri** være eneste lagringssted for data med varig verdi. All data som SpacetimeDB holder skal enten: +1. Allerede eksistere i PostgreSQL (read-only cache, f.eks. aktør-navn for autocomplete), eller +2. Synkes til PostgreSQL innen ~1 sekund (chat, kanban, markører). + +**Konsekvens for design:** Ethvert SpacetimeDB-modul-skjema skal ha en tilsvarende PostgreSQL-tabell som kan fungere som drop-in erstatning dersom SpacetimeDB fjernes. Frontend-kode som leser fra SpacetimeDB skal kunne peke om til et SvelteKit SSE-endepunkt + REST uten arkitekturendring. + +### 1.2 PostgreSQL-fallback-prinsippet +Dersom SpacetimeDB fjernes fra stacken, skal systemet fungere med følgende erstatning: +- **Sanntidsoppdateringer:** PostgreSQL `LISTEN/NOTIFY` → SvelteKit Server-Sent Events (SSE) +- **Skriving:** Direkte til PostgreSQL via SvelteKit server-side +- **Autocomplete/cache:** PostgreSQL-spørringer med cursor-basert paginering + +Denne fallbacken trenger ikke implementeres på forhånd, men SpacetimeDB-moduler skal designes slik at fallbacken forblir triviell. + +## 2. Strategi: Event-drevet med kort forsinkelse +SpacetimeDB-modulene (Rust) produserer persisterings-events ved dataendringer. En Rust-worker konsumerer disse og skriver til PostgreSQL med ~1 sekunds intervall. + +**Akseptabelt datatap:** Maks 1 sekund ved hard krasj av SpacetimeDB. Dette er akseptabelt for chat, kanban og show notes. + +**Unntak — kritiske events:** Aha-markører fra studioet (live-innspilling) er tidssensitive og vanskelige å gjenskape. Disse bør flushes til PG umiddelbart (ikke batched) via en dedikert `sync_critical()`-funksjon som skriver direkte til PG i stedet for via `sync_outbox`. Alternativt kan SpacetimeDB-modulen skrive kritiske events til sin egen WAL/disk umiddelbart. Hvilke event-typer som er "kritiske" defineres per workspace i `workspaces.settings`. + +## 3. Dataflyt + +``` +┌──────────────┐ events ┌──────────────┐ batch write ┌──────────────┐ +│ SpacetimeDB │ ──────────────► │ Rust Worker │ ────────────────► │ PostgreSQL │ +│ (sanntid) │ │ (sync_to_pg) │ │ (persistent)│ +└──────────────┘ └──────────────┘ └──────────────┘ + +┌──────────────┐ oppvarming (oppstart / reconnect) ┌──────────────┐ +│ PostgreSQL │ ──────────────────────────────────────► │ SpacetimeDB │ +└──────────────┘ └──────────────┘ +``` + +## 4. Eierskapsmodell + +| Data | Autoritativ kilde | Synkretning | Merknad | +|---|---|---|---| +| Chatmeldinger | SpacetimeDB | → PG (event, batched) | | +| Kanban-posisjon | SpacetimeDB | → PG (event) | | +| Show notes | SpacetimeDB | → PG (event) | | +| Live studio-markører | SpacetimeDB | → PG (event) | | +| Kunnskapsgraf | PostgreSQL | → SpacetimeDB (oppvarming) | Read-only i SpacetimeDB | +| Episodemetadata | PostgreSQL | Ingen synk | | +| Brukerkontoer | PostgreSQL (Authentik) | Ingen synk | | +| Statistikk | PostgreSQL | Ingen synk | | +| Valgomat | TBD | TBD | Konseptet må modnes. Mulig PG-autoritativ med SpacetimeDB som serveringslag | + +## 5. Mekanisme + +### 5.1 SpacetimeDB → PostgreSQL (persistering) +- SpacetimeDB-reducerne legger sync-events i `sync_outbox`-tabellen med tidsstempel og payload +- Rust-workeren poller `sync_outbox` hvert sekund, leser alle usynkede events, skriver til PostgreSQL, og markerer dem som synket via `mark_synced`-reducer +- Ved PG-nedetid: events akkumuleres i `sync_outbox`. Workeren prøver igjen ved neste poll. Ingen data tapes så lenge SpacetimeDB kjører + +### 5.2 PostgreSQL → SpacetimeDB (oppvarming) +- Ved oppstart laster Rust-workeren data fra PG per kanal, basert på `channels.config`: + - `warmup_mode: "all"` — alle meldinger i kanalen + - `warmup_mode: "messages"` — de N nyeste *trådene* (komplett med svar) + - `warmup_mode: "days"` — alle tråder med aktivitet siste N dager (komplett) + - `warmup_mode: "none"` — kanalen hoppes over (arkivert/inaktiv) +- Trådbasert henting: et svar som kvalifiserer tar med hele tråden (inkludert eldre trådstarter) +- Konfigureres per kanal via admin-UI (`/admin/channels`) eller `channels.config` JSONB +- Kanalen ryddes (`clear_channel`) før lasting for å unngå duplikater ved restart +- Reaksjoner lastes også inn via `load_reactions`-reducer + +## 6. Feilhåndtering +- **SpacetimeDB krasjer:** Data siden siste synk (~1 sek) tapes. Ved restart oppvarmes fra PG +- **PostgreSQL nede:** Sanntidsfunksjoner fortsetter å fungere. `sync_outbox` vokser. Workeren logger advarsler. Ved PG-recovery synkes backloggen automatisk +- **Rust-worker krasjer:** `sync_outbox` akkumuleres. Ved restart plukker workeren opp der den slapp (usynkede events har ingen markering) + +## 7. Workspace-isolasjon +SpacetimeDB-synkronisering er fullstendig workspace-scopet: +* SpacetimeDB-tilkoblinger bærer `workspace_id` som context-token. Modulen partisjonerer minnet og kringkaster kun til klienter i samme workspace. +* `sync_outbox`-events inkluderer `workspace_id` i payloaden, slik at Rust-workeren skriver til riktig silo i PostgreSQL. +* Ved oppvarming (PG → SpacetimeDB) laster workeren kun data for den aktuelle workspace-en. + +## 8. Konflikthåndtering + +### 8.1 Strategi: Last-Write-Wins (LWW) med reservasjoner +SpacetimeDB er autoritativ for sanntidsdata. Konflikter kan oppstå i to scenarioer: + +**Scenario A — Samtidig redigering i SpacetimeDB:** +SpacetimeDB er single-threaded per modul. Reducer-funksjoner serialiseres automatisk. Ingen klassiske race conditions. + +**Scenario B — PG-oppvarming kolliderer med fersk SpacetimeDB-state:** +Ved restart av SpacetimeDB lastes data fra PG (oppvarming). Hvis en klient skriver til SpacetimeDB *under* oppvarming, kan PG-dataen overskrive ferske endringer. + +**Løsning:** Oppvarming setter et `warming_up`-flagg i SpacetimeDB-modulen. Klienttilkoblinger aksepteres, men skrivinger bufres til oppvarmingen er fullført. Buffrede skrivinger appliseres deretter i rekkefølge. + +### 8.2 Kanban: Posisjonskonflikter +Kanban-kort har en `position`-kolonne (float). To brukere som drar kort samtidig kan skape overlappende posisjoner. Løsning: SpacetimeDB-modulen re-beregner posisjoner (midtpunkts-strategi) og kringkaster oppdatert rekkefølge til alle klienter. + +### 8.3 Chat: Ingen konflikter +Meldinger er append-only. Redigering av egne meldinger er last-write-wins — akseptabelt fordi kun én bruker eier meldingen. + +## 9. Workers som endrer data frontend ser + +**Kritisk regel:** En worker (Rust) som transformerer data som frontend viser, MÅ skrive resultatet til SpacetimeDB — ikke direkte til PG. PG-oppdatering skjer via sync-worker i bakgrunnen. + +### Eksempel: AI-vask av chatmelding + +**Riktig flyt:** +``` +Frontend viser melding fra SpacetimeDB + → Bruker trykker ✨ + → SvelteKit oppretter jobb i PG job_queue + → Worker plukker opp jobb + → Worker leser prompt + routing fra PG (det er infrastrukturdata, ikke frontend-data) + → Worker kaller AI Gateway + → Worker skriver resultat til SpacetimeDB via edit_message reducer + → SpacetimeDB pusher oppdatering til frontend via onUpdate + → Sync-worker persisterer til PG i bakgrunnen +``` + +**Feil flyt (anti-pattern som har blitt implementert gjentatte ganger):** +``` +Worker skriver til PG direkte + → Frontend henter metadata fra PG via enrichFromPg() ← BRYTER ABSTRAKSJONEN + → SpacetimeDB-oppdatering er "best-effort" ← FEIL PRIORITERING +``` + +### Hva betyr dette for nye felter? + +Når frontend trenger å vise noe nytt (metadata, revisjoner, edited_at), er prosedyren: + +1. **Utvid SpacetimeDB-modulen** — legg til feltet i Rust-strukturen +2. **Utvid warmup** — last feltet fra PG ved oppstart +3. **Utvid sync** — persist feltet til PG i bakgrunnen +4. **Worker skriver til SpacetimeDB** — via reducer, aldri direkte PG for synlig data +5. **Frontend leser fra SpacetimeDB** — ingen enrichFromPg, ingen PG API-kall + +### Hva kan workers lese fra PG? + +Workers kan og bør lese infrastrukturdata direkte fra PG: +- Jobbkø (`job_queue`) — det er jobbens opphavssted +- AI-prompts, modellkonfigurasjon, routing — det er infrastruktur, ikke brukerdata +- Workspace-konfigurasjon + +Skillet er: **data frontend viser** → SpacetimeDB. **Data kun worker trenger** → PG direkte. + +## 10. Implementeringsstatus (mars 2026) + +### Ferdig +- **SpacetimeDB som cache foran PG:** PG er autoritativ, SpacetimeDB er varm cache. Frontend snakker kun med SpacetimeDB. +- **SpacetimeDB Rust-modul** (`spacetimedb/src/lib.rs`): `ChatMessage` (med `metadata`, `edited_at`), `MessageReaction`, `MessageRevision` og `SyncOutbox`-tabeller. Reducers: `send_message`, `delete_message`, `edit_message`, `add_reaction`, `remove_reaction`, `load_messages`, `load_reactions`, `load_revisions`, `clear_channel`, `mark_synced`, `set_ai_processing`, `clear_ai_processing`, `ai_update_message`. +- **Worker warmup** (`worker/src/warmup.rs`): Ved oppstart lastes meldinger (med metadata/edited_at), reaksjoner og revisjoner per kanal fra PG → SpacetimeDB. Originale timestamps parses korrekt. Kanaler ryddes først med `clear_channel` for å unngå duplikater. +- **Worker sync** (`worker/src/sync.rs`): Poller `sync_outbox` hvert 1. sekund. Håndterer insert/delete/update/ai_update for meldinger og insert/delete for reaksjoner. +- **SpacetimeDB-adapter** (`web/src/lib/chat/spacetime.svelte.ts`): Ren SpacetimeDB-adapter. Null PG API-kall (`fetch()`). Bruker `onInsert`/`onUpdate`/`onDelete` callbacks for sanntid. Reaksjoner bygges fra `message_reaction`-tabellen. Revisjoner leses fra `message_revision`-tabellen. +- **PG-fallback** (`web/src/lib/chat/pg.svelte.ts`): Brukes kun når SpacetimeDB ikke er konfigurert. Markert som `readonly: true`. +- **Adapter-mønster:** `ChatConnection`-interface med `send`, `edit`, `delete`, `react` metoder. Factory velger basert på env-variabel. + +### Fikset (mars 2026) +- **`enrichFromPg` fjernet** — SpacetimeDB-modulen har nå `metadata` og `edited_at` på `ChatMessage`. Frontend leser alt fra SpacetimeDB, null `fetch()`-kall i adapteren. +- **Worker AI-vask via reducers** — `set_ai_processing`, `ai_update_message` og `clear_ai_processing` reducers erstatter direkte PG-skriving. Sync-worker persisterer til PG via `ai_update` outbox-action. +- **Revisjoner i SpacetimeDB** — `MessageRevision`-tabell med `load_revisions` warmup-reducer. Frontend leser revisjoner via `getRevisions()` fra `conn.db.message_revision`. +- **Warmup med metadata** — `load_messages` inkluderer `metadata`, `edited_at` og parser originale timestamps korrekt. + +### Gjenstår +- **Workspace-partisjonering (§7):** SpacetimeDB-modulen har `workspace_id`-felt men bruker ikke workspace-token på tilkobling ennå. +- **Pin/konvertering via SpacetimeDB:** Pin og kanban/kalender-konvertering går fortsatt direkte til PG API. +- **Lazy warmup per kanal:** Alle aktive kanaler oppvarmes ved oppstart. Kan optimaliseres til per-kanal ved tilkobling. + +## 11. Instruks for Claude Code +- `sync_outbox`-tabellen i SpacetimeDB bør ha et `synced`-flagg og `created_at`-tidsstempel +- Workeren skal bruke jobbkø-infrastrukturen (se `docs/infra/jobbkø.md`) for sin egen helse/observabilitet, men selve pollingen er en egen loop — ikke en vanlig jobb i køen +- Hold sync-payloaden enkel: `{ "table": "chat_messages", "action": "insert", "data": {...} }` — workeren mapper dette til riktig PG-tabell +- Ikke optimaliser for store datamengder ennå. Enkle INSERTs er bra nok til volumet stabiliserer seg +- Alle sync-payloads må inkludere `workspace_id`. Workeren bruker dette til å sette RLS-kontekst (`SET app.current_workspace_id`) før PG-skriving, eller kjører som superuser og filtrerer eksplisitt diff --git a/docs/proposals/README.md b/docs/proposals/README.md new file mode 100644 index 0000000..97de915 --- /dev/null +++ b/docs/proposals/README.md @@ -0,0 +1,64 @@ +# 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](auto_clipper.md) | Middels | Høy | Live transkripsjon, jobbkø, Caddy byte-range | +| [Graph Health Monitor](graph_health_monitor.md) | Lav | Middels | Kunnskapsgraf, pgvector, jobbkø | +| [Serendipity Roulette](serendipity_roulette.md) | Lav | Høy | Kunnskapsgraf, Live AI | +| [Podcast Time Machine](podcast_time_machine.md) | Lav | Høy | Segmenter, Caddy byte-range, Live AI | +| [Meme Generator](meme_generator.md) | Lav | Høy | Whiteboard, transkripsjon, AI Gateway | +| [Valgomat Roast](valgomat_roast.md) | Lav | Middels | Valgomat, kunnskapsgraf | +| [Live Audience Q&A](live_audience_qa.md) | Middels | Høy | Valgomat, LiveKit, SpacetimeDB | +| [Guest Prep Simulator](guest_prep_simulator.md) | Middels | Høy | Kunnskapsgraf, AI Gateway | +| [Debate Club](debate_club.md) | Middels | Middels | Kunnskapsgraf, AI Gateway, jobbkø | +| [Ghost Host TTS](ghost_host_tts.md) | Stor | Høy | LiveKit, AI Gateway, ny TTS-infra | +| [Tekst-primitiv](tekst_primitiv.md) | Lav–Middels | Middels–Høy | Meldingsboks, view-configs | +| [Editor](editor.md) | Middels–Stor | Høy | Tekst-primitiv, Tiptap/ProseMirror, KaTeX | +| [Artikkel-publisering](artikkel_publisering.md) | Middels–Stor | Høy | Tekst-primitiv, kunnskapsgraf, Caddy, jobbkø | +| [Sosial publisering](social_posting.md) | Lav–Middels | Høy | Chat, jobbkø, workspace settings | +| [Komponerbare sider](komponerbare_sider.md) | Lav (Fase 1) | Middels–Høy | Workspace-modell, SvelteKit, alle feature-komponenter | +| [Contradiction Detector](contradiction_detector.md) | Middels | Høy | Live AI, kunnskapsgraf, pgvector, segmenter | +| [Auto-Highlight Reel](auto_highlight_reel.md) | Middels | Høy | Podcastfabrikken, jobbkø, AI Gateway, Caddy byte-range | +| [Audience Voice Memo](audience_voice_memo.md) | Lav | Høy | Den Asynkrone Gjesten, Live transkripsjon, Live AI | +| [Avisvisning](avisvisning.md) | Lav–Middels | Høy | Meldingsboks, kunnskapsgraf, prominens-score | +| [Personlig workspace](personlig_workspace.md) | Lav–Middels | Middels–Høy | Workspace-modell, meldingsboks, tekst-primitiv | +| [Kildevern-modus](kildevern_modus.md) | Lav–Middels | Høy | AI Gateway, Ollama/vLLM, Møterommet | +| [Podcasting 2.0](podcasting_2_0.md) | Lav | Høy | Podcastfabrikken, kunnskapsgraf, RSS | +| [Web Clipper](web_clipper.md) | Lav–Middels | Høy | Jobbkø, AI Gateway, meldingsboks, kunnskapsgraf | +| [Visuelle Waveforms](waveforms.md) | Lav–Middels | Høy | Podcastfabrikken, jobbkø, editor | +| **Innspilling & Storyboard** | | | | +| [Storyboard](storyboard.md) | Middels–Stor | Høy | Canvas-primitiv, meldingsboks, universell overføring, Studioet, Podcastfabrikken | +| [Card Chaining](card_chaining.md) | Lav | Middels | Kunnskapsgraf, Storyboard, AI Gateway | +| [Ghost Cards](ghost_cards.md) | Lav–Middels | Høy | Storyboard, meldingsboks, kunnskapsgraf | +| [Pinboard Mode](pinboard_mode.md) | Lav | Høy | Storyboard, kanban | +| [Flow Meter](flow_meter.md) | Lav | Middels | Storyboard | +| [Emotion Tags](emotion_tags.md) | Lav | Middels | Meldingsboks, kanban, storyboard | +| **Samarbeid** | | | | +| [Collaborative Cursors](collaborative_cursors.md) | Lav | Middels | SpacetimeDB, Svelte | +| [Card Heat Map](card_heat_map.md) | Lav | Middels | Meldingsboks, kanban/storyboard | + +**Forfremmet til feature:** [Meldingsboks](../features/meldingsboks.md) — 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 diff --git a/docs/proposals/artikkel_publisering.md b/docs/proposals/artikkel_publisering.md new file mode 100644 index 0000000..03c16a1 --- /dev/null +++ b/docs/proposals/artikkel_publisering.md @@ -0,0 +1,194 @@ +# 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: + +```sql +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: + +```sql +-- 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: + +```sql +-- 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_html` i `article_view` inneholder ferdig-rendret HTML med KaTeX. Ingen JavaScript for lesere. +- **Sidemerknad-fotnoter** (Tufte-stil) — vises i margen på brede skjermer (>1200px), popup på mobil. +- **Podcast-embeds** — `{{segment:uuid}}` rendrer innebygd lydspiller med transkripsjon. +- **OG-tags** — automatisk generert Open Graph-metadata per artikkel. + +### Typografi-stack (CSS) +``` +Brødtekst: Literata / Georgia / serif (1.6 linjehøyde, 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 egen `article_collaborators`-tabell med rolleinfo (forfatter, redaktør, korrekturleser)? +- Sanntids samredigering (Yjs) fra dag 1, eller auto-save + manuell koordinering? + +### Kommentarer: to kanaler, én node +En publisert artikkel har to separate diskusjonsrom: + +1. **Intern kontekst** — den opprinnelige `reply_to`-kjeden og workspace-channelen artikkelen tilhører. Usynlig utenfra. En artikkel som startet som et svar i #Mediepolitikk beholder hele den interne tråden — men den lekker aldri ut. + +2. **Offentlig kommentarkanal** — en separat channel med `visibility: 'public'`, knyttet til artikkelen via `article_view.comment_channel_id`. Workspace-medlemmer ser begge kanaler. Publikum ser bare den offentlige. + +Åpne spørsmål rundt offentlige kommentarer: +- Hvem kan kommentere? Anonymt, autentisert (Authentik), kun inviterte? +- Moderasjon: forfatter, workspace-admin, eller begge? +- Enkleste start: ingen offentlige kommentarer (read-only for publikum). Arkitekturen tillater det via nullable `comment_channel_id`, men det bygges først når behovet er reelt. + +### Tematiske feeds +- Automatisk feed basert på graf-spørringer ("alle artikler med MENTIONS-edge til #Skolepolitikk") — er dette en publikasjon, eller et separat konsept? +- Trolig: en publikasjon med `type: 'auto'` og en lagret graf-spørring. Men det er et steg videre. + +### Versjonering +- Bør publiserte artikler ha synlig versjonering (à la Wikipedia)? `message_revisions` gir historikk allerede, men det er et spørsmål om det eksponeres til lesere. + +### Bilder og media +- Content-addressable via `media_files` (eksisterende), eller CDN? +- Bildeoptimalisering (responsive `srcset`) for publiserte artikler? + +## Innsats: Middels-Stor +Datamodellen (publikasjoner, `PUBLISHED_IN`-edges) er overkommelig. Typografi og leseopplevelse krever CSS-arbeid. Kurateringsflyten er det mest komplekse — men kan bygges inkrementelt. + +## Wow-faktor: Høy +Kombinasjonen av vakker typografi, individuelle forfattere, kollaborativt forfatterskap og kuratoriske publikasjoner — integrert med kunnskapsgraf og podcast-embeds — er noe som ikke finnes andre steder. En publiseringsplattform der en politisk analyse kan ha formler, lydklipp fra intervjuer, og koblinger til kunnskapsgrafen. + +## Relasjon til andre proposals +- **Tekst-primitiv** — fundament: editor, `article_view`, lagringsformat +- **Personlig workspace** — kontekst: der individet skriver og publiserer fra +- Denne proposalen handler om *hva som skjer etter at teksten er skrevet* — publisering, kurasjon, distribusjon, leseopplevelse diff --git a/docs/proposals/audience_voice_memo.md b/docs/proposals/audience_voice_memo.md new file mode 100644 index 0000000..5e94a33 --- /dev/null +++ b/docs/proposals/audience_voice_memo.md @@ -0,0 +1,41 @@ +# Forslag: Audience Voice Memo (Live publikums-innspill) +**Innsats:** Lav | **Wow-faktor:** Høy + +## Idé +Under live-innspilling vises en QR-kode (eller kort-URL) som publikum kan skanne. Den åpner en minimal nettside (gjenbruker Den Asynkrone Gjestens tech) der de kan sende voice memos. Memoene dukker opp i studio-chatten som `voice_memo`-meldinger, transkriberes live, og AI matcher innholdet til kunnskapsgrafen: + +*"Lytter 'Kari fra Bergen' spør om vindkraft — du har 3 faktoider om dette fra Episode 12 og 17."* + +## Hvorfor +- Gjør live-innspilling interaktiv uten at publikum trenger app eller konto +- Gjenbruker nesten alt fra Den Asynkrone Gjesten (guest_tokens, lydopplasting, Whisper) +- Kombinert med Live AI gir det programlederen kontekst på publikums-spørsmål i sanntid +- Viralt: "Send oss en voice memo LIVE mens vi spiller inn" + +## Bygger på +- **Den Asynkrone Gjesten** (guest_tokens, `/guest/[token]`-rute, lydopplasting) +- **Live transkripsjon** (Whisper transkriberer voice memos via jobbkø) +- **Live AI** (matcher transkriberte memos mot kunnskapsgraf) +- **SpacetimeDB / PG-polling** (memos dukker opp i studio-chat i sanntid) + +## Forskjell fra Den Asynkrone Gjesten +- **Asynkron gjest:** Én person, navngitt, forberedte spørsmål, tidsbegrenset +- **Audience Voice Memo:** Mange anonyme/pseudonyme lyttere, fritt innhold, kun aktivt under innspilling + +## Teknisk skisse +1. Redaksjonen oppretter en "Live Q&A-sesjon" (spesiell guest_token med `type: 'audience'`) +2. QR-kode genereres med kort-URL → `/live/[token]` +3. Publikum åpner, skriver inn kallenavn, tar opp voice memo (maks 30 sek) +4. Voice memo lastes opp, Whisper transkriberer, AI matcher mot graf +5. Studio-chatten viser: "[Kari fra Bergen]: " + AI-kontekst + +## Dataklassifisering +- Audience voice memos: Flyktig (TTL 7 dager) — kun relevant rundt innspilling +- Transkripsjoner av memos: Flyktig (TTL 7 dager) +- Kuraterte memos (valgt ut av redaksjonen): Kritisk (flyttes til workspace media/) + +## Åpne spørsmål +- Moderering: skal alle memos dukke opp automatisk, eller må en produsent godkjenne først? +- Skalering: hva om 100+ lyttere sender memos samtidig? Whisper-kø kan bli overbelastet +- Kan dette kombineres med Live Audience Q&A-forslaget (stemmegiving på spørsmål)? +- Personvern: skal lytterne akseptere at memoet kan brukes i podcasten? diff --git a/docs/proposals/auto_clipper.md b/docs/proposals/auto_clipper.md new file mode 100644 index 0000000..16056b1 --- /dev/null +++ b/docs/proposals/auto_clipper.md @@ -0,0 +1,26 @@ +# 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 `#Tema` og `@Aktør`) + +## Dataklassifisering +- Lydklipp: Avledet (kategori 3) — kan regenereres fra original MP3 + tidsstempler +- Klipp-metadata (tidsstempler, score, thumbnail-tekst): Kritisk (PG) + +## Åpne spørsmål +- Scoring-modell: hva gjør et øyeblikk "klippverdig"? Humor, nyhet, kontrovers, emosjonell intensitet? +- Skal klippene genereres som faktiske MP3-filer umiddelbart, eller lagre kun tidsstempler og generere on-demand ved eksport? +- Eksportmål: TikTok/Instagram/YouTube Shorts — trenger vi video med waveform/teksting, eller holder lyd? +- Kan dette kombineres med Podcast Time Machine for "throwback-klipp"? diff --git a/docs/proposals/auto_highlight_reel.md b/docs/proposals/auto_highlight_reel.md new file mode 100644 index 0000000..a76bea2 --- /dev/null +++ b/docs/proposals/auto_highlight_reel.md @@ -0,0 +1,39 @@ +# Forslag: Auto-Highlight Reel (Post-innspilling) +**Innsats:** Middels | **Wow-faktor:** Høy + +## Idé +Etter innspilling analyserer Podcastfabrikken transkripsjonen for humor, emosjonelle topper, sterke meninger og "punchlines". AI genererer automatisk 5-10 klipp (15-45 sek) med: +- Tidsstempler (start/slutt) i originalt opptak +- Foreslått teksting (fra transkripsjon, formatert for sosiale medier) +- Auto-generert thumbnail-tekst (det sterkeste sitatet) +- Foreslått hashtags basert på kunnskapsgraf-tags + +Klippene havner i en "Highlights"-channel i workspace-chatten for review, med ett-klikk godkjenning og auto-posting via sosial publisering. + +## Hvorfor +- Podcast-klipp er den viktigste vekstmotoren, men manuell klipping er tidkrevende +- Bygger på eksisterende Whisper-transkripsjon + jobbkø + AI Gateway +- Kombinert med sosial publisering-forslaget gir dette en komplett "innspilling → distribusjon"-pipeline +- Differensiator: ingen annen podcast-plattform gjør dette automatisk med kvalitetskontroll + +## Bygger på +- **Podcastfabrikken** (Whisper SRT + AI-metadata — allerede spesifisert) +- **Auto-Clipper** (eksisterende forslag — dette er post-innspilling-versjonen) +- **Jobbkø** (`highlight_extract`-jobb, kjøres etter `whisper_postprocess`) +- **AI Gateway** (`sidelinja/resonering` for klipp-vurdering) +- **Caddy byte-range** (klipp serveres som range-requests mot original MP3) +- **Sosial publisering** (eksisterende forslag — ett-klikk posting) + +## Forskjell fra Auto-Clipper +Auto-Clipper kjører *live* under innspilling og fanger øyeblikk i sanntid. Auto-Highlight Reel kjører *etter* innspilling og har tilgang til hele transkripsjonen — kan dermed finne narrative buer og tematiske høydepunkter som bare er synlige i kontekst. + +## Dataklassifisering +- Klipp-metadata (tidsstempler, teksting, score): Kritisk (PG) +- Klipp-lydfiler: Avledet (kategori 3) — genereres on-demand fra original MP3 + tidsstempler +- Highlight-forslag (før godkjenning): Flyktig (TTL 30 dager) + +## Åpne spørsmål +- Scoring: hva gjør et øyeblikk "klippverdig"? Humor, nyhet, kontrovers, emosjon? +- Videostøtte: trenger vi waveform-video med teksting for TikTok/Shorts, eller holder lyd + bilde? +- Skal AI-en foreslå rekkefølge/gruppering av klipp til en "highlight reel" (2-3 min sammenklipp)? +- Kan den lære av hvilke klipp redaksjonen godkjenner over tid (feedback loop)? diff --git a/docs/proposals/avisvisning.md b/docs/proposals/avisvisning.md new file mode 100644 index 0000000..8d7b569 --- /dev/null +++ b/docs/proposals/avisvisning.md @@ -0,0 +1,230 @@ +# 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:** + +```sql +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 (``) som kan plasseres på en komponerbar side: + +```jsonc +{ + "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 ``-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 diff --git a/docs/proposals/card_chaining.md b/docs/proposals/card_chaining.md new file mode 100644 index 0000000..d48e481 --- /dev/null +++ b/docs/proposals/card_chaining.md @@ -0,0 +1,26 @@ +# Card Chaining — Automatisk kobling av relaterte kort + +## Idé + +Når to kort plasseres ved siden av hverandre (i kanban, storyboard eller kalender), opprettes automatisk en graf-edge mellom dem. Systemet kan også foreslå overganger: "Og apropos drømmer..." + +## Hvorfor interessant? + +Podcast-segmenter henger ofte sammen tematisk, men koblingen er implisitt. Card chaining gjør den eksplisitt — uten manuelt arbeid. Gir bedre flyt under innspilling og bedre metadata for kunnskapsgrafen. + +## Fungerer slik +1. Dra kort A ved siden av kort B i storyboard +2. System oppretter `graph_edge` med `relation_type: 'sequence'` og `origin: 'proximity'` +3. Valgfritt: AI foreslår overgangssetning basert på begge kortenes innhold +4. Ved eksport/arkivering: sekvensen bevares som episode-struktur + +## Bygger på +- Kunnskapsgraf (graph_edges) +- Storyboard (proximity detection) +- AI Gateway (overgangsforslag) + +## Innsats +Lav — graph_edges finnes, bare UI for proximity + auto-edge. + +## Wow-faktor +Middels — subtilt, men forbedrer metadata-kvaliteten dramatisk over tid. diff --git a/docs/proposals/card_heat_map.md b/docs/proposals/card_heat_map.md new file mode 100644 index 0000000..794b6c1 --- /dev/null +++ b/docs/proposals/card_heat_map.md @@ -0,0 +1,25 @@ +# Card Heat Map — Visuell indikator for engasjement + +## Idé + +Kort på storyboard/kanban gløder basert på hvor mye oppmerksomhet de har fått: hover-tid, antall redigeringer, diskusjonstråd-lengde, og tid brukt i innspilling. + +## Hvorfor interessant? + +Hjelper med å se hva teamet faktisk er engasjert i — uten å lese alt. Under innspilling: "det kortet gløder mest, kanskje vi bør ta det først." + +## Fungerer slik +1. Klient tracker hover-tid per kort (lokal state) +2. Server aggregerer: antall edits, tråd-lengde, reaksjoner +3. Kombinert score → CSS-variabel (`--heat: 0.0–1.0`) → glow-effekt +4. Valgfritt: "Hot topics"-filter som sorterer kort etter heat + +## Bygger på +- Meldingsboks (reaksjoner, tråd-lengde) +- Kanban/Storyboard (visuell rendering) + +## Innsats +Lav — ren frontend-logikk med enkel server-aggregering. + +## Wow-faktor +Middels — subtilt men nyttig for redaksjonell prioritering. diff --git a/docs/proposals/collaborative_cursors.md b/docs/proposals/collaborative_cursors.md new file mode 100644 index 0000000..1ab4b2c --- /dev/null +++ b/docs/proposals/collaborative_cursors.md @@ -0,0 +1,29 @@ +# Collaborative Cursors — Sanntids-pekere for flerbrukermiljø + +## Idé + +Alle brukere som er på samme side ser hverandres musepekere som fargede prikker med navn. Fungerer på storyboard, kanban, whiteboard og kalender. + +## Hvorfor interessant? + +Gir "jamming together"-følelse under innspilling og planlegging. Produsent og host ser hverandre jobbe i sanntid uten å snakke om det. + +## Fungerer slik +1. Klient sender `{ user_id, x, y, page }` til SpacetimeDB ved musebevegelse (throttlet til ~10 Hz) +2. Andre klienter renderer fargede SVG-sirkler med brukernavn +3. Prikken fader ut etter 5 sekunder uten bevegelse +4. Valgfritt: kort "trail" som viser bevegelsesretning + +## Bygger på +- SpacetimeDB (pub/sub for posisjoner) +- Svelte ($state store for cursor-map) + +## Innsats +Lav — under 50 linjer Svelte + en SpacetimeDB-reducer. + +## Wow-faktor +Middels — visuelt tiltalende, men ikke kritisk funksjonalitet. + +## Åpne spørsmål +- Bør pekere vises i chat-visning også, eller bare canvas-baserte views? +- Throttling-strategi: SpacetimeDB-reducer eller klient-side debounce? diff --git a/docs/proposals/contradiction_detector.md b/docs/proposals/contradiction_detector.md new file mode 100644 index 0000000..a144430 --- /dev/null +++ b/docs/proposals/contradiction_detector.md @@ -0,0 +1,41 @@ +# Forslag: Contradiction Detector (Live i Studioet) +**Innsats:** Middels | **Wow-faktor:** Høy + +## Idé +Under live-innspilling matcher Live AI nye utsagn mot eksisterende `CONTRADICTS`-edges og gamle segmenter i kunnskapsgrafen. Når en selvmotsigelse oppdages, popper det opp et diskret varsel i studio-UI: + +*"Du sa akkurat «vi må kutte støtte til vindkraft» — men i Episode 17 (segment 3, 14:22) sa du «vindkraft er fremtiden». Vil du adressere det?"* + +Programlederen kan: +1. Ignorere (ingen handling) +2. Markere for oppfølging (Aha-markør) +3. Spille inn et 12-sekunders "correction clip" på stedet + +## Hvorfor +- Den ultimate "live co-host"-funksjonen — AI som faktisk gjør programlederen bedre +- Bygger direkte på eksisterende infrastruktur (Live AI + segmenter + kunnskapsgraf) +- Øker troverdigheten til podcasten (selvkorreksjon er sterkere enn å bli tatt i feil) +- Viralt potensial: "Denne podcasten har en AI som fanger selvmotsigelser i sanntid" + +## Bygger på +- **Live transkripsjon** (Whisper-chunks i sanntid) +- **Live AI** (eksisterende faktoid-oppslag-pipeline) +- **Kunnskapsgraf** (segmenter med NER-tags, `CONTRADICTS`-edges) +- **pgvector** (semantisk matching for "lignende men motstridende" utsagn) +- **Caddy byte-range** (for å hente originalt lydklipp fra gammel episode) + +## Teknisk skisse +1. Whisper-chunk → NER-uttrekk (aktører, temaer, påstander) +2. Søk i kunnskapsgrafen: finnes det segmenter med samme aktør/tema men motstridende innhold? +3. pgvector cosine similarity for semantisk matching + LLM-vurdering via `sidelinja/resonering` +4. Resultat med confidence score > terskel → push til studio-UI via SpacetimeDB + +## Dataklassifisering +- Contradiction-alerts: Flyktig (TTL 24t) — kun relevant under/etter innspilling +- Godkjente contradictions → nye `CONTRADICTS`-edges i kunnskapsgrafen (kritisk) + +## Åpne spørsmål +- Terskel for confidence: for lav = støy under innspilling, for høy = misser reelle motstridelser +- Skal den kun matche mot egne episoder, eller også mot eksterne faktoider? +- Kan dette kombineres med Ghost Host for å "lese opp" motstridelsen? +- Latens-krav: må fungere innen 10-15 sek etter utsagnet for å være nyttig live diff --git a/docs/proposals/debate_club.md b/docs/proposals/debate_club.md new file mode 100644 index 0000000..63c3546 --- /dev/null +++ b/docs/proposals/debate_club.md @@ -0,0 +1,24 @@ +# Forslag: Debate Club (Simulerte debatter) + +## Idé +Velg to aktører + et tema. Systemet genererer en simulert debatt der AI-en spiller begge roller basert på faktiske faktoider og sitater fra kunnskapsgrafen. + +Kan brukes i studioet som research-verktøy: *"Hva ville Støre og Solberg sagt om dette i dag?"* — og eksporteres som lydklipp til episoden via TTS. + +## Hvorfor +- Kreativt bruk av kunnskapsgrafen som går utover oppslag +- Research-verktøy: hjelper programlederne å forberede motargumenter +- Underholdningsverdi: genuint morsomt når det treffer (og når det går galt) +- Kan brukes i valgomaten som "hør hva partiene mener" basert på AI-profiler + +## Bygger på +- Kunnskapsgrafen (faktoider, aktør-profiler, `CONTRADICTS`-edges) +- AI Gateway (`sidelinja/resonering` for nyansert debatt) +- Jobbkø (generering tar tid) +- Valgfritt: TTS for lydklipp (se `ghost_host_tts.md`) + +## Åpne spørsmål +- Etikk: OK å legge ord i munnen på ekte politikere? Trenger tydelig "AI-generert"-merking +- Skal debatten baseres kun på faktoider vi har, eller kan AI-en fylle inn basert på offentlig kjent posisjon? +- Node-type `debate` i grafen, eller bare en melding med spesiell `message_type`? +- Kobling til valgomaten: kan debatter genereres fra AI-kandidatprofiler? diff --git a/docs/proposals/editor.md b/docs/proposals/editor.md new file mode 100644 index 0000000..cbde825 --- /dev/null +++ b/docs/proposals/editor.md @@ -0,0 +1,385 @@ +# Forslag: Universell editor + +## Idé +Én editor-komponent som brukes overalt i Sidelinja — chat, notater, artikler, kanban-kort, show notes. Editoren autodetekterer format (plaintext, markdown, LaTeX) og rendrer riktig uten at brukeren velger modus. Avansert funksjonalitet er alltid tilgjengelig, aldri påtvunget. + +## Kjerneprinsipp: Brukeren bare skriver + +Editoren forstår hva brukeren skriver og rendrer det riktig — live, uten konfigurasjon: + +``` +Bruker skriver Autodetektert Rendres som +────────────── ───────────── ─────────── +hei plaintext tekst +**viktig** markdown bold +$E = mc^2$ LaTeX inline formel +$$\int_0^1 f(x)dx$$ LaTeX blokk sentrert formel +```python\n... kodeblokk syntax highlight +# Overskrift markdown heading H1 +#Skolepolitikk mention graf-edge + lenke +{{segment:uuid}} podcast-embed lydspiller +bilde dratt inn media inline bilde med bildetekst +https://youtu.be/xyz YouTube-embed innebygd videospiller +https://example.com lenke rik forhåndsvisning (OG-kort) +``` + +En bruker som kan markdown bruker markdown. En bruker som kan LaTeX bruker LaTeX. En bruker som bare skriver vanlig tekst får vanlig tekst. Ingen modusvelger, ingen forhåndsvalg. + +## Hvorfor er dette et eget prosjekt? + +Editoren er det mest komplekse enkeltkomponenten i Sidelinja: +- Autodeteksjon av formater (markdown, LaTeX, mentions, embeds) +- Progressiv toolbar (fra usynlig til fullverdig) +- Live rendering av alt innhold +- `#`-mention med autocomplete og graf-integrasjon +- Podcast-embeds, bilder, vedlegg +- Versjonering og auto-save +- Format-kontekstsensitivitet (emoji i chat vs sirlig typografi i artikler) +- Fremtidig: collaborative editing (Yjs) +- Mobilopplevelse + +Alt dette fortjener sin egen spec, sin egen utviklingssyklus, og sin egen iterasjon — uavhengig av tekst-primitiven (arkitektur) og artikkel-publisering (distribusjon). + +## Hva bygger den på? +- **Tekst-primitiv** — filosofien om at enhver melding kan vokse +- **Meldingsboks** — datamodellen editoren skriver til (`messages.body`) +- **Kunnskapsgraf** — `#`-mentions oppretter graf-edges +- **Message revisions** — editoren trigge lagring av revisjoner + +## Skisse + +### Teknologivalg: Tiptap (ProseMirror) + +Tiptap er det naturlige valget for SvelteKit: +- ProseMirror-basert, modular, godt vedlikeholdt +- Headless — full kontroll over UI og toolbar +- Lagrer som JSON (strukturert, transformerbart) +- Utvidbar med custom nodes og marks +- Svelte-kompatibelt (`@tiptap/core` headless + egen Svelte-wrapper) +- Collaborative editing via Yjs-plugin (fase 2) + +### Progressiv toolbar + +Editoren er **én komponent** (``) 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: `![alt-tekst](media:uuid)` eller standard markdown bildesyntaks +- Responsiv `srcset` genereres ved opplasting (jobbkø) for publiserte artikler + +**Lenker:** +- Paste en URL → autodetekteres som lenke +- Rik forhåndsvisning (Open Graph): tittel, beskrivelse, miniatyrbilde — hentes asynkront ved paste, caches +- Brukeren kan velge mellom rik forhåndsvisning (kort) og enkel tekstlenke +- I raw: standard markdown `[tekst](url)` eller bare URL + +**Externe embeds:** +- YouTube, Vimeo, Twitter/X, o.l. — paste en URL, editoren gjenkjenner domenet og rendrer innebygd spiller/visning +- Basert på oEmbed-protokollen der tilgjengelig, ellers sandboxed iframe +- I raw: bare URL-en på egen linje. I rendered: innebygd player med riktig aspekt-ratio +- Whitelist-basert: kun kjente domener får embed-behandling. Ukjente URL-er vises som rik lenke-forhåndsvisning + +**Filvedlegg:** +- PDF, dokumenter, andre filer — lastes opp til `media_files`, vises som nedlastbar lenke med filtype-ikon og størrelse + +**Teknisk:** +- Alle opplastinger går via `POST /api/media` → lagres content-addressable i `media_files` +- Referanser i Tiptap JSON er `media:uuid` — aldri direkte fil-URL-er. Gjør det mulig å flytte lagring (lokal → CDN) uten å endre innhold +- Bildeoptimalisering (resize, WebP-konvertering) som jobbkø-oppgave ved opplasting +- oEmbed/OG-metadata caches i en enkel tabell for å unngå gjentatte oppslag + +### AI-behandling — universell knapp + +Editoren har en AI-knapp (✨) som behandler innholdet i boksen. Originalteksten bevares alltid som revisjon (`message_revisions`), og AI-resultatet tar over som nytt innhold — klart for videre redigering av brukeren. + +Det som opprinnelig var tenkt som en separat "AI Research-Klipper"-modal er nå bare én av handlingene her: paste inn hva som helst → trykk ✨ → AI-en behandler det. + +**Flyten:** +``` +Bruker limer inn rotete Ctrl+A-tekst fra en nettavis + → Trykker ✨ (standard: "Fiks tekst") + → Originalen lagres som revisjon (alltid tilgjengelig) + → AI-resultatet erstatter innholdet i editoren + → Brukeren redigerer videre, legger til tittel, justerer + → AI-en foreslår #-mentions basert på innholdet → graf-edges opprettes + → Meldingen lever videre: kan få prominens i avisen, bli et kanban-kort, publiseres +``` + +**Alternativt:** Brukeren kan velge at resultatet publiseres som *ny melding* (svar på originalen) i stedet for å erstatte innholdet. Nyttig for "trekk ut fakta" der originalen og resultatet er to ulike ting. + +### Standard-prompt: "Fiks tekst" (✨) + +Standardhandlingen — den brukeren får ved å trykke ✨ uten å åpne menyen — er en generisk "magi"-prompt: + +``` +Fiks denne teksten. Output på norsk. +- Fiks skrivefeil og grammatikk +- Start med en kort oppsummering av det viktigste (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: +1. Nåværende innhold lagres i `message_revisions` (originalen er alltid tilgjengelig) +2. AI-resultatet erstatter `messages.body` +3. `messages.metadata` oppdateres med `{ ai_processed: true, ai_action: 'fix_text', ai_prompt_id: '...' }` +4. Brukeren ser resultatet i editoren og kan redigere videre, angre (gå tilbake til revisjon), eller kjøre ✨ igjen + +Revisjonshistorikken viser tydelig hva som var original og hva som er AI-behandlet. Brukeren kan alltid gå tilbake. + +### Teknisk +- `POST /api/ai/process` med `{ message_id, action, prompt_id? }` +- Oppretter jobbkø-oppgave (`ai_text_process`) +- Rust-worker sender til AI Gateway (`http://ai-gateway:4000/v1`) +- Ved "erstatt innhold": lagre revisjon + oppdater `messages.body` +- Ved "ny melding": opprett ny melding med `reply_to = original_message_id` +- AI-foreslåtte `#`-mentions vises for bruker-godkjenning før edges opprettes + +### Kontekst-bevisst (Agentic RAG) +AI-en kjenner konteksten meldingen lever i. Hvis den er i en channel knyttet til #Skolepolitikk, brukes det som hint for å identifisere relevante entiteter og fakta. Workspace-kontekst + graf-nabolag gir bedre resultater enn en kontekstløs prompt. + +**RAG-berikelse via kunnskapsgrafen:** Når brukeren skriver et utkast og nevner `#Skolepolitikk`, gjør SvelteKit et usynlig vektorsøk (pgvector) i bakgrunnen mot kunnskapsgrafen *før* prompten sendes til AI Gateway. System-prompten oppdateres dynamisk: + +``` +Sidelinja har tidligere etablert disse faktaene om Skolepolitikk: +- [Faktoide 1 fra grafen] +- [Faktoide 2 fra grafen] +- [Relatert segment fra Episode 42] +Ta hensyn til dette i behandlingen. +``` + +Resultatet: AI-en ikke bare retter skrivefeil, men fyller inn kontekst spesifikk for redaksjonens kunnskapsbase. Krever pgvector-migrasjon (0006) og `generate_embeddings`-jobbtype. + +### Format-kontekst + +Ikke alt passer overalt. En emoji-rik chatmelding og en sirlig publisert artikkel har ulike estetiske forventninger: + +**Chat-kontekst:** Alt tillatt. Emojis, GIFs, korte meldinger, uformelt. + +**Publiserings-kontekst:** Editoren tilbyr et "publiseringsfilter" — en valgfri siste-sjekk som flagger potensielle stilbrudd (emojis i overskrifter, manglende alt-tekst på bilder, etc.). Aldri blokkerende — bare forslag. + +Publiseringskonteksten tilbyr en "forhåndsvisning som leser" der teksten rendres i den ferdige typografi-stacken (Literata, marginer, sidemerknad-fotnoter) slik at forfatteren ser hvordan det blir. Dette er en tredje visning — read-only, ren leseopplevelse — tilgjengelig via [Forhåndsvis]-knappen i publiserings-modus. + +### Brukerinnstillinger + +Skriftstørrelse, linjehøyde, font, tema og andre visuelle preferanser styres per bruker. Se `docs/features/brukerinnstillinger.md` for full spec — inkludert datamodell (`users.settings` JSONB), CSS custom properties, innstillingspanel og editor-spesifikke preferanser (standard Raw/Rendered, stavekontroll, tegnteller). + +### Lagringsformat + +**Tiptap JSON** som universelt format: + +```json +{ + "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: + +1. **Fase 1:** Tiptap med plaintext + mentions + markdown formatting. Kompakt og utvidet modus. Auto-save. +2. **Fase 2:** LaTeX (KaTeX), kodeblokker, bilder/vedlegg. Publiserings-modus. +3. **Fase 3:** Podcast-embeds, sidemerknad-fotnoter, collaborative editing (Yjs). + +## Wow-faktor: Høy +En editor der du bare skriver — markdown rendres automatisk, LaTeX rendres automatisk, mentions oppretter graf-koblinger, og alt kan vokse til en publisert artikkel — er en opplevelse de fleste verktøy ikke tilbyr. Det nærmeste er Notion, men uten graf-integrasjon og uten podcast-embeds. + +## Relasjon til andre proposals og features +- **Tekst-primitiv** — filosofien editoren realiserer +- **Artikkel-publisering** — publiseringslaget som bruker editoren +- **Personlig workspace** — konteksten der editoren brukes daglig +- **Meldingsboks** (feature) — datamodellen editoren skriver til +- **Komponerbare sider** — maximize gir editoren plass til å gå fra chatboks til fullskjerm skriveverksted +- **Chat** (feature) — kompakt modus erstatter dagens chat-input +- **Notater** (feature) — utvidet modus erstatter dagens textarea diff --git a/docs/proposals/emotion_tags.md b/docs/proposals/emotion_tags.md new file mode 100644 index 0000000..a571f23 --- /dev/null +++ b/docs/proposals/emotion_tags.md @@ -0,0 +1,26 @@ +# Emotion Tags — Hurtigkategorisering av segmenter + +## Idé + +Kort i storyboard/kanban kan tagges med stemnings-ikoner — morsom, seriøs, kontroversiell, personlig. Taggen farger kortets ramme og fungerer som filter i etterarbeid. + +## Hvorfor interessant? + +Under redigering er det nyttig å filtrere på stemning: "vis alle kontroversielle segmenter" eller "vi trenger en morsom bit mellom to tunge tema." Raskere enn å lese alle kortene. + +## Fungerer slik +1. Predefinerte tags med ikoner og farger (konfigurerbart per workspace) +2. Klikk-basert tagging — ett klikk per tag, toggle on/off +3. Visuelt: farget border + ikon-badge på kortet +4. Filter: "vis kun 🔥-kort" i storyboard og kanban +5. Lagres som `message.metadata.emotion_tags: string[]` + +## Passer inn i eksisterende +- **Meldingsboks**: tags i metadata-feltet, ingen ny tabell +- **Reaksjoner**: kan gjenbruke reaksjons-mekanismen (emoji = emotion tag) + +## Innsats +Lav — ren frontend + metadata-felt. + +## Wow-faktor +Middels — nyttig for store episoder med mange segmenter. diff --git a/docs/proposals/flow_meter.md b/docs/proposals/flow_meter.md new file mode 100644 index 0000000..ca953aa --- /dev/null +++ b/docs/proposals/flow_meter.md @@ -0,0 +1,24 @@ +# Flow Meter — Visuell episodeprogresjon + +## Idé + +En tynn progresjonslinje langs toppen av storyboardet som fylles etter hvert som kort dras til "Tatt opp". Grønn = solid episode, gul = trenger mer, rød = for kort. Basert på antall segmenter og total opptakstid. + +## Hvorfor interessant? + +Under innspilling er det lett å miste oversikten over om man har nok materiale. Flow meter gir et intuitivt "mage-sjekk" uten å telle manuelt. + +## Fungerer slik +1. Konfigurerbar målvarighet per workspace (f.eks. 45 min) +2. Summerer varighet for alle "Tatt opp"-kort +3. Fargeovergang: rød (0–30%) → gul (30–70%) → grønn (70–100%) +4. Valgfritt: pulserer sakte når man nærmer seg mål + +## Bygger på +- Storyboard (episode-sekvens med tidsstempler) + +## Innsats +Lav — én beregning + CSS gradient. + +## Wow-faktor +Middels — liten ting, men fjerner mental overhead. diff --git a/docs/proposals/ghost_cards.md b/docs/proposals/ghost_cards.md new file mode 100644 index 0000000..b2babce --- /dev/null +++ b/docs/proposals/ghost_cards.md @@ -0,0 +1,27 @@ +# Ghost Cards — Visuelle spor fra tidligere episoder + +## Idé + +Når en episode er ferdig og arkivert, etterlater kort som ble "Tatt opp" svake, semi-transparente spøkelseskort på storyboardet. De fungerer som påminnelser: "vi snakket om dette forrige uke — har vi oppfølging?" + +## Hvorfor interessant? + +Podcast-temaer henger sammen over tid. Ghost cards gir visuell kontinuitet mellom episoder uten manuell sporing. Perfekt for serier og løpende historier. + +## Fungerer slik +1. Ved arkivering: kort som var "Tatt opp" får `ghost_episode_id` i metadata +2. Ved neste episode: ghost cards vises med `opacity: 0.3` og episode-nummer +3. Klikk på ghost → se original diskusjonstråd og tidsstempel +4. Dra ghost → promoter til nytt aktivt kort for oppfølging (beholder graf-edge til originalen) +5. Konfigurerbart: vis ghosts fra siste N episoder (default: 3) + +## Bygger på +- Storyboard (visuell rendering) +- Meldingsboks (message med view-config) +- Kunnskapsgraf (edge mellom original og oppfølger) + +## Innsats +Lav–Middels — mest metadata-design og UI for ghost-rendering. + +## Wow-faktor +Høy — gir podcasten "hukommelse" som oppleves magisk for brukeren. diff --git a/docs/proposals/ghost_host_tts.md b/docs/proposals/ghost_host_tts.md new file mode 100644 index 0000000..2815c28 --- /dev/null +++ b/docs/proposals/ghost_host_tts.md @@ -0,0 +1,29 @@ +# 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 diff --git a/docs/proposals/graph_health_monitor.md b/docs/proposals/graph_health_monitor.md new file mode 100644 index 0000000..72974dd --- /dev/null +++ b/docs/proposals/graph_health_monitor.md @@ -0,0 +1,39 @@ +# Forslag: Knowledge Graph Health Monitor +**Innsats:** Lav | **Wow-faktor:** Middels + +## Idé +En admin-side i SvelteKit som viser "Graf-helse": tetthet, isolerte noder, svake relasjoner, ubalanserte temaer. AI-en foreslår ukentlig nye edges: + +*"Du har 47 faktoider om Støre og 32 om Ap — skal vi koble dem med WORKS_FOR?"* +*"Dette segmentet nevner vindkraft 9 ganger, men ingen #Tema-knute finnes."* + +## Hvorfor +- Kunnskapsgrafen vokser organisk, men kan bli rotete uten vedlikehold +- Gir redaksjonen en "huskeliste" av ting de bør koble manuelt eller godkjenne +- Synliggjør verdien av grafen — man ser den bli smartere over tid +- Forhindrer "orphan nodes" som aldri dukker opp i oppslag + +## Bygger på +- Kunnskapsgrafen (nodes, graph_edges — rekursive CTEs for å finne isolerte subgrafer) +- pgvector (allerede planlagt i Kunnskaps-Bridge — brukes for å finne semantisk like noder som mangler eksplisitt kobling) +- `generate_embeddings`-jobb (eksisterende jobbtype) +- Jobbkø (`graph_suggest_edges` — ny jobbtype, scheduled ukentlig) +- AI Gateway (`sidelinja/resonering` for naturlig språk-forslag) + +## Helsemetrikker +| Metrikk | SQL-skisse | Handlingsforslag | +|---|---|---| +| Isolerte noder | `nodes LEFT JOIN graph_edges ... WHERE edge IS NULL` | "Koble til tema eller slett" | +| Temaer uten faktoider | `themes LEFT JOIN factoids ... HAVING count = 0` | "Tomt tema — berik eller arkiver" | +| Aktører uten relasjoner | Samme mønster | "Ny aktør — trenger kontekst" | +| Semantisk like noder | pgvector cosine distance < 0.15 | "Mulig duplikat — slå sammen?" | +| Manglende edges | AI-analyse av node-par med høy co-occurrence i segmenter | "Koble Støre → Ap?" | + +## Dataklassifisering +- Edge-forslag: Flyktig (TTL 30 dager) — godkjente forslag blir ekte `graph_edges` +- Helsemetrikker: Avledet (beregnes on-demand fra grafen) + +## Åpne spørsmål +- Skal forslagene dukke opp som "innboks-kort" i Redaksjonen, eller kun på en dedikert admin-side? +- Terskel for "semantisk lik": hvor lav cosine distance = mulig duplikat? +- Bør monitoren kjøre per workspace eller globalt (cross-workspace via Bridge)? diff --git a/docs/proposals/guest_prep_simulator.md b/docs/proposals/guest_prep_simulator.md new file mode 100644 index 0000000..c7564fc --- /dev/null +++ b/docs/proposals/guest_prep_simulator.md @@ -0,0 +1,29 @@ +# 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? diff --git a/docs/proposals/kildevern_modus.md b/docs/proposals/kildevern_modus.md new file mode 100644 index 0000000..a0f17c0 --- /dev/null +++ b/docs/proposals/kildevern_modus.md @@ -0,0 +1,34 @@ +# Forslag: Kildevern-modus (100% lokal LLM) + +## Idé +Når Møterommet eller en channel brukes til sensitive, upubliserte redaksjonelle diskusjoner, bryter det med kildevernet å sende transkripsjoner til Claude/Gemini — selv via LiteLLM. En toggle for "kildevern-modus" ruter all AI-prosessering til en lokal modell. Data forlater aldri serveren. + +## Hvorfor er dette interessant? +- Presseetikk og kildevern er ikke-forhandlbart for seriøse redaksjoner +- Kan være et differensierende salgspunkt for plattformen +- LiteLLM støtter allerede Ollama/vLLM som leverandør — arkitekturen er klar + +## Hva bygger den på? +- **AI Gateway** — Ollama/vLLM som ny leverandør i `config.yaml` +- **Møterommet** — kildevern-toggle på channel/rom-nivå +- **Jobbkø** — ruting basert på `kildevern`-flagg + +## Gjennomføring +1. Sett opp Ollama eller vLLM som egen Docker-container med en lett, lokal modell (f.eks. Llama-3-8B eller Gemma-2-9B) +2. Registrer som `sidelinja/lokal` i LiteLLM config +3. Channels/møter får en toggle: `kildevern: true` (lagres i channel-config eller `workspaces.settings`) +4. Når flagget er satt, ruter AI Gateway til `sidelinja/lokal` i stedet for eksterne modeller +5. UI viser tydelig "Kildevern aktiv — all AI-prosessering skjer lokalt" med visuell indikator + +## Ressurskrav +- Lokal 8B-modell krever ~6 GB VRAM (GPU) eller ~8 GB RAM (CPU, saktere) +- På nåværende server (16 GB RAM) er dette mulig men trangt — compute-separasjon (se `docs/infra/jobbkø.md` §4.4) gjør det mer komfortabelt +- Kvaliteten på norsk tekst med 8B-modeller er merkbart lavere enn Claude/Gemini — akseptabelt for oppsummering, ikke for kompleks analyse + +## Åpne spørsmål +- Hvor granulært skal kildevern-toggle være? Per channel, per melding, per workspace? +- Trenger vi et visuelt "sikkerhetsnivå" (grønt/rødt skjold) i UI? +- Bør kildevern-modus også blokkere ekstern embedding-generering (pgvector)? + +## Innsats: Lav–Middels +## Wow-faktor: Høy diff --git a/docs/proposals/komponerbare_sider.md b/docs/proposals/komponerbare_sider.md new file mode 100644 index 0000000..d1ffeb0 --- /dev/null +++ b/docs/proposals/komponerbare_sider.md @@ -0,0 +1,136 @@ +# Komponerbare sider (Dashboard-komposisjon) + +## Idé +Brukere ser ferdige sider (Redaksjonen, Studioet, etc.), men admin kan komponere egne sider fra tilgjengelige byggeklosser — chat, kanban, statistikk, graf-visning, whiteboard, osv. + +## Hvorfor interessant? +Ulike redaksjoner jobber ulikt. Noen vil ha chat + kanban side-om-side, andre vil ha statistikk + research. I stedet for å hardkode alle kombinasjoner, gir vi admin verktøy til å sette opp sider tilpasset teamets arbeidsflyt. Brukerne ser resultatet som ferdige sider i navigasjonen. + +## Modell + +### Fase 1: Forhåndsdefinerte layouts (implementeres først) +- Admin velger fra en katalog av **blokker** (chat, kanban, statistikk, etc.) +- Plasserer dem i et **grid-layout** med forhåndsdefinerte maler (1-kolonne, 2-kolonne, 2+1, etc.) +- Lagres som JSONB i `workspaces.settings` (nøkkel `pages`) +- Brukere ser sidene i navigasjonen — ingen komposisjon, bare bruk +- Mobil: blokkene stacker vertikalt (grid kollapser) + +```jsonc +// Eksempel: admin-definert side +{ + "slug": "oversikt", + "title": "Redaksjonsoversikt", + "layout": "two-column", // Forhåndsdefinert mal + "blocks": [ + { "type": "chat", "channel": "general", "span": 1 }, + { "type": "kanban", "board": "episoder", "span": 1 }, + { "type": "stats", "view": "lyttere-7d", "span": 2 } + ] +} +``` + +### Fase 2: Konfigurerbare dashboards (senere, ved behov) +- Brukere kan lage egne personlige dashboards +- Drag-and-drop for å endre rekkefølge og størrelse +- Lagres per bruker i `workspace_members.dashboard_config` JSONB (PG, ikke fil) + +### Fase 3: Full fri tiling (trolig unødvendig) +- VS Code / Bloomberg-stil fritt plassering med splitters +- Ekstremt høy kompleksitet, lav marginalverdi vs. Fase 2 +- Unngå med mindre det er et demonstrert behov + +## Blokker som resizable containere + +Hver blokk på en side er et selvstendig, resizable panel. Brukeren kan: + +### Resize +Dra i kanten mellom blokker for å justere proporsjoner. Midlertidig (session) eller persistent (lagres i brukerens `dashboard_config`). + +``` +Standard layout: Etter resize: +┌──────┬──────┐ ┌───┬─────────┐ +│ Chat │Kanban│ │ │ │ +│ │ │ → │ │ Kanban │ +│ │ │ │ │ │ +└──────┴──────┘ └───┴─────────┘ +``` + +### Maximize +Dobbelklikk på tittellinjen, eller en maximize-knapp — blokken utvider seg til hele tilgjengelig skjermflate. Alle andre blokker kollapses. Trykk Escape eller minimize for å gå tilbake. + +``` +Maksimert chat: +┌──────────────────────────┐ +│ Chat — #Mediepolitikk ✕ │ +│ │ +│ [full editor, │ +│ full tråd-oversikt, │ +│ full funksjonalitet] │ +│ │ +│ │ +└──────────────────────────┘ +``` + +Dette er spesielt verdifullt for: +- **Editoren** — en chatmelding som vokser til en artikkel trenger plutselig hele skjermen. Maximize gir deg et reelt skriveverksted i stedet for en bitteliten boks. +- **Whiteboard** — trenger plass for å være nyttig +- **Graf-visualisering** — uleselig i en liten boks +- **Mobil** — der skjermplassen er begrenset er maximize den naturlige interaksjonen. Blokkene stacker vertikalt, og du tapper en blokk for å utvide den til fullskjerm. + +### Mobil-opplevelse +På mobil er default-visningen en stacked liste med kollapsede blokker: + +``` +┌──────────────────┐ +│ ▼ Chat (3 nye) │ +│ ▼ Kanban │ +│ ▼ Statistikk │ +└──────────────────┘ +``` + +Tapp en blokk → den ekspanderer til fullskjerm. Swipe ned eller tilbake-knapp → tilbake til oversikten. Editoren i fullskjerm på mobil = et reelt skriveverksted, ikke en liten inputboks nederst. + +### Teknisk implementering +- CSS Grid med `grid-template-columns`/`grid-template-rows` som justeres via drag +- Resize via pointer events på grid-gapene (eller et lett bibliotek som `svelte-splitpanes`) +- Maximize = CSS `position: fixed` + z-index overlay, med smooth transition +- Blokk-størrelse lagres i `dashboard_config` JSONB: `{ "blockId": { "span": 1.5 } }` eller lignende +- Midlertidig resize (ikke lagret) = Svelte `$state`, forsvinner ved refresh + +## Arkitektur-krav +Hver feature-komponent MÅ bygges som en **selvstendig Svelte-komponent** som: +- Tar imot `workspaceId` (og evt. config-props som `channelId`, `boardId`) +- Håndterer sin egen datahenting og tilstand +- Respekterer container-størrelse (responsiv innenfor sin blokk, `container queries` i CSS) +- Eksponerer en `blockMeta`-descriptor (tittel, min-bredde, min-høyde, ikon) for katalogen +- Har en `maximized`-prop som tilpasser layout (f.eks. editoren viser full toolbar i maximized) + +Dette koster ingenting å gjøre fra start og gir full fleksibilitet senere. + +## Bygger på +- Workspace-modell, SvelteKit layout +- Alle feature-komponenter (chat, kanban, whiteboard, statistikk, etc.) +- Editor (proposal) — maximize gir editoren plass til å være et reelt skriveverksted + +## Innsats +- Fase 1: **Lav** (grid-layout + JSON-config, ingen drag-and-drop) +- Fase 1.5: **Lav** (maximize per blokk — relativt enkelt med CSS overlay) +- Fase 2: **Middels** (resize, svelte-splitpanes, bruker-lagring) +- Fase 3: **Stor** (custom tiling engine — trolig unødvendig) + +## Wow-faktor +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.settings` JSONB. Fase 2: personlige overrides i `workspace_members.dashboard_config` JSONB. Alt i PG — ingen filer per bruker. +- Midlertidig vs persistent resize: bør default være at resize forsvinner ved refresh (session), eller lagres automatisk? + → Trolig: auto-lagre med debounce. Brukeren forventer at ting "huskes". En "tilbakestill layout"-knapp for å gå tilbake til admin-default. +- Bør det finnes et "standard-oppsett" per workspace-type (podcast, nyhetsredaksjon)? + → Ja, som templates admin kan velge som utgangspunkt. +- Keyboard shortcut for maximize? F11 er konvensjon for fullskjerm, men kolliderer med nettleser. Kanskje Ctrl+Shift+F eller dobbelklikk. + +## Relasjon til andre proposals +- **Editor** — maximize gir editoren rom til å gå fra chatinput til fullverdig skriveverksted +- **Personlig workspace** — personlige dashboards (fase 2) henger tett sammen med personlig workspace +- **Tekst-primitiv** — en melding som vokser trenger plass; maximize er mekanismen som gir den det diff --git a/docs/proposals/live_audience_qa.md b/docs/proposals/live_audience_qa.md new file mode 100644 index 0000000..caee06f --- /dev/null +++ b/docs/proposals/live_audience_qa.md @@ -0,0 +1,52 @@ +# Forslag: Live Audience Q&A (Studio-integrasjon) +**Innsats:** Middels | **Wow-faktor:** Høy + +## Idé +Under live-innspilling åpner programlederne en "Spør oss"-QR-kode. Tilskuere (eller lyttere på nett) kan sende spørsmål anonymt via en mini-flate. Spørsmålene dukker opp i studio-chatten sortert etter popularitet (real-time voting). AI-en kan foreslå "Svar på dette med faktoid X" i sanntid. + +*"En lytter spør: 'Hvorfor sa dere det motsatte i episode 17?' — og AI-en har allerede funnet klippet."* + +## Hvorfor +- Gjør innspilling interaktiv uten å miste kontroll +- Gir gullmateriale til episoden ("En lytter spurte akkurat...") +- Kobler publikum direkte inn i studioet — bygger community +- Kan gjenbruke valgomat-konseptets anonyme deltakelse + +## Bygger på +- Valgomat (anonym UUID + SpacetimeDB for sanntids-stemming) +- LiveKit + studio-chat (spørsmål vises i studio-UI) +- Kunnskapsgraf (spørsmål kobles automatisk til `#Tema` via NER) +- Live AI (kan foreslå relevante faktoider som svar) + +## Datamodell (skisse) +``` +audience_sessions ( + id UUID, + workspace_id UUID, + livekit_room_id TEXT, + created_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ +) + +audience_questions ( + id UUID, + session_id UUID, + anonymous_id UUID, -- ingen brukerregistrering + body TEXT, + votes INT DEFAULT 0, + status TEXT DEFAULT 'pending', -- pending / shown / dismissed + created_at TIMESTAMPTZ +) +``` + +## Dataklassifisering +- Spørsmål brukt i episode: Kritisk (PG) — del av episodhistorikken +- Spørsmål ikke brukt: Flyktig (TTL per sesjon, slettes ved lukking) +- Anonym ID: Flyktig — ingen kobling til ekte bruker + +## Åpne spørsmål +- Moderering: bør spørsmål gjennom en "godkjenn"-queue før de vises i studio? +- Spam: rate limiting per anonymous_id? Captcha? Eller holder det med manuell moderering? +- Kan dette utvides til live-polling ("Er dere enige med Erna?") med sanntids-resultater? +- Kobling til valgomat: bør QA-flaten være en utvidelse av valgomat-UI, eller helt separat? +- Personvern: lagre IP? Kun for rate limiting, aldri i PG? diff --git a/docs/proposals/meme_generator.md b/docs/proposals/meme_generator.md new file mode 100644 index 0000000..8dfb360 --- /dev/null +++ b/docs/proposals/meme_generator.md @@ -0,0 +1,22 @@ +# 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å? diff --git a/docs/proposals/personlig_workspace.md b/docs/proposals/personlig_workspace.md new file mode 100644 index 0000000..4d2969e --- /dev/null +++ b/docs/proposals/personlig_workspace.md @@ -0,0 +1,117 @@ +# Forslag: Personlig workspace + +## Idé +Hver bruker får et personlig workspace som fungerer som en individuell produktivitetssuite. Alle verktøyene et delt workspace har — kanban, kalender, notater, graf-koblinger — men privat og selvorganisert. I tillegg: en personlig publiseringskanal ("blogg") der tekster kan deles med omverdenen. + +## Hvorfor er dette interessant? + +### Individuell produktivitet +Redaksjonsmedlemmer trenger et sted å jobbe uforstyrret: +- Personlige oppgavelister (kanban) +- Egen kalender (deadlines, påminnelser) +- Kladder og research-notater +- Graf-koblinger til temaer og aktører de følger + +`visibility = 'private'` på meldingsbokser innenfor delte workspaces dekker noe av dette, men gir ikke en *egen arbeidsflate*. Et personlig workspace gir: +- Eget kanban-brett for personlige oppgaver (ikke synlig for andre) +- Egen kalender (kan overlappes med delt kalender i UI) +- Egne notater uten støy fra fellesrommet +- Egne graf-koblinger og research + +### Personlig publisering +Med tekst-primitiven (se `tekst_primitiv.md`) og publiseringsmodellen (se `artikkel_publisering.md`) kan personlig workspace også være utgangspunkt for en personlig blogg/feed: +- Skriv en tekst i personlig workspace +- Publiser den → tilgjengelig på en personlig URL (`sidelinja.org/@vegard/...`) +- Teksten kan også plukkes opp av en felles publikasjon (se artikkel-publisering) + +## Hva bygger den på? +- **Workspace-modellen** (RLS, workspace_members) — et personlig workspace er bare et vanlig workspace med én member +- **Meldingsboks** — alt er allerede workspace-scopet +- **Tekst-primitiv** (proposal) — gir notater en skikkelig editor +- **Artikkel-publisering** (proposal) — gir publiseringskanalen + +## Skisse + +### Verktøy i personlig workspace + +| Verktøy | Hva det er | Bygger på | +|---|---|---| +| Oppgaver | Personlig kanban-brett | `kanban_card_view` | +| Kalender | Personlig kalender | `calendar_event_view` | +| Notater/kladder | Meldinger med rich text editor | Tekst-primitiv | +| Research | Editor AI-knapp + graf-koblinger | Kunnskapsgraf, AI gateway | +| Personlig feed | Publiserte tekster med egen URL | Artikkel-publisering | + +Alle disse er eksisterende features brukt i en personlig kontekst. Ingen ny funksjonalitet — bare et eget workspace å bruke dem i. + +### Opprettelse +Automatisk ved brukerregistrering. Workspacet er implisitt — det dukker opp i workspace-switcheren med et visuelt skille (ikon, farge, eller plassering). + +Slug: `personal-{authentik_id}` (intern), visningsnavn: brukerens display_name. + +### Workspace-switcher +``` +┌─────────────────────┐ +│ 👤 Mitt workspace │ ← alltid øverst, visuelt adskilt +├─────────────────────┤ +│ 📻 Sidelinja │ +│ 🏛️ Foreningen │ +│ ... │ +└─────────────────────┘ +``` + +### Flytt mellom workspaces +Tre strategier, rangert etter pragmatisme: + +1. **Del, ikke flytt** (enklest) — endre `visibility` fra `'private'` til `'workspace'`. Krever at meldingen allerede bor i mål-workspacet. Fungerer for "jobbe privat i fellesrommet", men ikke for å flytte fra personlig workspace til et delt. + +2. **Kopier, ikke flytt** (anbefalt) — opprett ny node i mål-workspace, behold original i personlig. Lenke mellom dem med `COPIED_FROM`-edge. Enkelt, trygt, ingen referanseproblemer. + +3. **Flytt atomisk** — endre `workspace_id` på node + alle avhengigheter i én transaksjon. Komplekst: `graph_edges`, `reply_to`-kjeder, `kanban_card_view`-referanser til kolonner i kilde-workspace. Ikke verdt kompleksiteten initialt. + +**Anbefaling:** Start med (2). "Kopier til fellesrom" er en tydelig handling. Originalen forblir i personlig workspace som referanse. + +### Personlig publisering (avhenger av artikkel-publisering) +Hvert personlig workspace har en implisitt publikasjon (feed). Når en tekst publiseres fra personlig workspace: +- Den får en `article_view` med slug og status +- Den blir tilgjengelig på `sidelinja.org/@brukernavn/slug` +- Den dukker opp i brukerens personlige Atom-feed +- En redaktør i en felles publikasjon kan kuratere den derfra (se `artikkel_publisering.md`) + +## Åpne spørsmål + +### Grense mot delte workspaces +- Kan et personlig workspace ha flere medlemmer (f.eks. invitere en kollega til å se kanban-brettet)? Eller er det strengt personlig? +- Pragmatisk: start strengt personlig (1 member). Utvid later hvis behov oppstår. + +### Kvoter og vekst +- Eget lagringsbudsjett per personlig workspace? +- TTL-policy: samme som delte workspaces, eller mer liberal (personlig innhold slettes ikke automatisk)? +- Trolig: ingen TTL på personlig workspace som default. Brukeren styrer selv. + +### Dashboard / startside +- Bør personlig workspace ha et dashboard? F.eks.: + - Siste notater + - Kommende kalenderhendelser + - Kanban-kort med deadline + - Siste aktivitet i delte workspaces brukeren er med i +- Eller er det overkill — bare vis verktøyene? + +### Alternativ: "Visibility er nok" +Det kan fortsatt hende at `visibility = 'private'` i delte workspaces dekker 80% av behovet. Et personlig workspace er da mest relevant for: +- Innhold som ikke hører til noe delt workspace +- Personlig publisering +- Et "hjem" i appen + +Verdt å evaluere etter at visibility og tekst-primitiven er på plass. + +## Innsats: Lav (opprettelse) / Middels (med publisering og dashboard) +Workspace-opprettelse ved registrering er trivielt. Publiseringslaget avhenger av tekst-primitiv og artikkel-publisering. Dashboard er eget arbeid. + +## Wow-faktor: Middels-Høy +Alene er det "et privat workspace". Med publisering blir det en personlig plattform — Substack-aktig, men integrert i redaksjonsverktøyet. + +## Relasjon til andre proposals +- **Tekst-primitiv** — gir notater og kladder en skikkelig editor +- **Artikkel-publisering** — gir publiseringsmodellen (publikasjoner, kuratorer, feeds) +- Personlig workspace er *konteksten* der tekst-primitiven og publisering møtes for individet diff --git a/docs/proposals/pinboard_mode.md b/docs/proposals/pinboard_mode.md new file mode 100644 index 0000000..0640a73 --- /dev/null +++ b/docs/proposals/pinboard_mode.md @@ -0,0 +1,26 @@ +# Pinboard Mode — Fugleperspektiv over episode-arc + +## Idé + +Hurtigtast (f.eks. `Ctrl+0`) zoomer ut storyboardet til fugleperspektiv. Kort krymper, titler blir store. Du ser hele episodens arc som en visuell flyt: intro → morsom bit → dypt spørsmål → outro. Dra kort som Lego-klosser for å endre rekkefølge. + +## Hvorfor interessant? + +Under innspilling er man "for nær" — man ser enkelt-kort, ikke helheten. Pinboard mode gir 3-sekunders overblikk: "vi har tre morsomme segmenter på rad, vi trenger noe tungt i midten." + +## Fungerer slik +1. Toggle via hurtigtast eller knapp +2. CSS transform: `scale(0.4)` + økt font-size på titler +3. Kort viser kun: tittel, status-farge, varighet (hvis tatt opp) +4. Drag-and-drop endrer rekkefølge i episode-sekvensen +5. Klikk på kort = zoom tilbake til normalvisning på det kortet + +## Bygger på +- Storyboard (episode-sekvens) +- Kanban (drag-and-drop) + +## Innsats +Lav — ren CSS/UI-jobb. + +## Wow-faktor +Høy — visuelt imponerende og genuint nyttig under innspilling. diff --git a/docs/proposals/podcast_time_machine.md b/docs/proposals/podcast_time_machine.md new file mode 100644 index 0000000..1c798bb --- /dev/null +++ b/docs/proposals/podcast_time_machine.md @@ -0,0 +1,23 @@ +# Forslag: Podcast Time Machine + +## Idé +Mens programlederne snakker om et tema, vises en "Play past clip"-knapp i studio-UI-et. Trykker du den, streamer systemet et 20-sekunders segment fra en gammel episode der samme aktør/tema ble nevnt. + +Morsom variant: "Time Machine Roast" — viser bare de mest selvmotsigende eller pinlige sitatene fra arkivet. + +## Hvorfor +- Naturlig utvidelse av segment-søk som allerede finnes +- Gjør podcast-arkivet til en aktiv ressurs under innspilling +- Kan gi gullmomenter: "Du sa jo det stikk motsatte i episode 17!" +- Caddy serverer allerede media med `Accept-Ranges: bytes` — byte-range streaming fungerer + +## Bygger på +- Segmenter med tidsstempler i PG (allerede indeksert med full-text search) +- `DISCUSSED_IN` og `MENTIONS` edges i kunnskapsgrafen +- Live AI-assistent (NER matcher aktører/temaer → oppslag) +- Caddy media-servering (byte-range for å streame bare segmentet) + +## Åpne spørsmål +- Trenger vi en egen "clip cache" eller kan vi streame direkte fra MP3 med start/end byte offset? +- Bør AI-en velge det mest *relevante* klippet, eller det mest *underholdende*? +- Kan dette kombineres med Serendipity Roulette til et "throwback"-modus? diff --git a/docs/proposals/podcasting_2_0.md b/docs/proposals/podcasting_2_0.md new file mode 100644 index 0000000..e46f40b --- /dev/null +++ b/docs/proposals/podcasting_2_0.md @@ -0,0 +1,37 @@ +# 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 | +|---|---|---| +| `` | SRT fra Git (eller VTT-konvertert) | Live tekstssynkronisert teksting | +| `` | `entities` med `type = 'person'` + `avatar_url` | Gjeste-/vertsprofiler med bilde | +| `` | Segmenter (tidsstemplet) | Klikkbare kapitler | +| `` | Aha-markører (hvis implementert) | Utvalgte høydepunkter | + +## Gjennomføring +1. Utvid SvelteKit RSS-generatoren med Podcasting 2.0 namespace: `xmlns:podcast="https://podcastindex.org/namespace/1.0"` +2. Per episode: generer `` med URL til SRT/VTT-fil +3. Per episode: generer `` for aktører koblet til episoden via `DISCUSSED_IN`/`MENTIONS`-edges +4. Per episode: generer `` 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 diff --git a/docs/proposals/serendipity_roulette.md b/docs/proposals/serendipity_roulette.md new file mode 100644 index 0000000..62d40bf --- /dev/null +++ b/docs/proposals/serendipity_roulette.md @@ -0,0 +1,22 @@ +# 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? diff --git a/docs/proposals/social_posting.md b/docs/proposals/social_posting.md new file mode 100644 index 0000000..a734b51 --- /dev/null +++ b/docs/proposals/social_posting.md @@ -0,0 +1,150 @@ +# 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`: + +```json +{ + "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'`: + +```json +{ + "job_type": "social_post", + "payload": { + "platform": "x", + "text": "Fantastisk intervju med @støre...", + "source_message_id": "uuid", + "suggested_by": "authentik_id", + "approved_by": null, + "thread_ids": [] + } +} +``` + +For godkjenningsflyt brukes en ekstra jobbstatus. Jobber med `approval: required` opprettes med `status = 'pending_approval'` (ny enum-verdi) og endres til `pending` ved godkjenning. + +## Admin-panel (workspace-innstillinger) + +Denne featuren forutsetter et minimalt admin-panel i SvelteKit (`/workspace/settings`). Første iterasjon trenger kun: + +- **Sosiale kontoer:** Koble til X (OAuth-flyt), se tilkoblet konto, fjern +- **Godkjenningsflyt:** Velg mellom `none` / `required` / `role` +- **Historikk:** Liste over poster sendt fra dette workspacet (fra jobbkø-data) + +Admin-panelet vil vokse med andre workspace-innstillinger over tid (Whisper-config, AI-prompts, channel-defaults, etc.), men sosial publisering er et godt første bruksområde. + +## Plattform-abstraksjon + +Chat-kommandoen og jobbkøen vet ikke om X spesifikt. Arkitekturen er: + +``` +Chat (/x, /bsky, /social) + │ + ▼ +Jobbkø: social_post { platform: "x", text: "..." } + │ + ▼ +Rust worker: matcher platform → riktig API-klient + ├── X (OAuth 1.0a, v2 API) + ├── Bluesky (AT Protocol) [fremtidig] + └── Mastodon (OAuth 2.0, REST) [fremtidig] +``` + +Å legge til en ny plattform er å implementere én ny handler i Rust-workeren + legge til credentials i workspace-settings. Ingen endring i chat-koden. + +## X API: Tekniske detaljer +- **API:** X API v2, `POST /2/tweets` +- **Auth:** OAuth 1.0a (User Context) — krever app + bruker-tokens +- **Rate limit:** 200 poster/15 min per bruker (mer enn nok) +- **Tråd-støtte:** `reply.in_reply_to_tweet_id` for tråder +- **Pris:** Free tier tillater 1500 poster/mnd. Basic ($100/mnd) gir 3000 poster + lesing. Free er nok for start. + +## Innsats +**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 `/x` kunne brukes på en hel tråd (flere meldinger → X-tråd)? +- Bilder/media i poster — fra chat-vedlegg eller kun tekst i v1? +- Skal poster inkludere en automatisk lenke tilbake til episoden/temaet fra grafen? +- Bør det finnes en `/draft`-kommando som legger posten i en kø uten å sende (planlagt posting)? +- Varsling i chat når en foreslått post venter på godkjenning for lenge? diff --git a/docs/proposals/storyboard.md b/docs/proposals/storyboard.md new file mode 100644 index 0000000..d2efbce --- /dev/null +++ b/docs/proposals/storyboard.md @@ -0,0 +1,298 @@ +# Storyboard — Fritt canvas for innspillingsplanlegging og live-produksjon + +## 1. Konsept + +Storyboardet er et fritt canvas der meldingsboks-kort plasseres, flyttes og grupperes visuelt. Det brukes **før, under og etter innspilling** — fra idémyldring til ferdig episodestruktur. Ikke et erstatningsverktøy for kanban (som er for langsiktig planlegging), men et taktilt arbeidsflate for å *se* en episode ta form. + +Storyboardet er en consumer av Canvas-primitivet (`docs/features/canvas_primitiv.md`) og deltar i universell overføring (`docs/features/universell_overfoering.md`). + +## 2. Kjernemodell + +### 2.1 Kort = Meldingsboks med storyboard-plassering + +Alle kort på storyboardet er vanlige `messages`-noder. Plasseringen på canvaset styres av `message_placements`-tabellen: + +``` +message_placements: + message_id → messages.id + context_type = 'storyboard' + context_id → episode_id (eller standalone storyboard-id) + entered_at = når kortet ble lagt på boardet + position = { "x": 340, "y": 120, "status": "klar" } +``` + +Kortet kan samtidig leve i en chat, på et kanban-brett, eller i en kalender. Endringer i chatten (nye svar, redigeringer) propagerer til storyboard-visningen fordi det er *samme melding*. + +### 2.2 Episode-objekt + +Et storyboard er knyttet til en episode (node av type `episode`) eller eksisterer som frittstående canvas (for idémyldring uten episode-tilknytning). + +Episode-noden eier: +- **Sekvensliste:** En ordnet liste over kort som er "Tatt opp", med tidsstempel for når de ble markert. Lagres som `episode_sequence` i SpacetimeDB. +- **Målvarighet:** Konfigurerbar per workspace (f.eks. 45 min) — brukes av Flow Meter. + +### 2.3 Statuser + +Kort har status (lagret i `position`-JSONB på plasseringen): + +| Status | Visuelt | Betydning | +|--------|---------|-----------| +| **Klar** | Solid, hvit/lys border | Planlagt, venter på tur | +| **Tatt opp** | Grønn border, tidsstempel-badge | Snakket om under innspilling | +| **Droppet** | Dimmet (opacity 0.4), rød stripe | Ikke brukt denne gang | +| **Arkivert** | Skjult (filter) | Ferdig behandlet | + +Status-endring skjer via: +- Drag til en status-sone (valgfri — konfigurer per workspace) +- Hurtigtast: `R` = Tatt opp, `D` = Droppet (når kort er valgt) +- Kontekstmeny +- Flytende toolbar ved seleksjon + +### 2.4 Flere storyboards + +Et workspace kan ha mange storyboards — ett per episode, pluss frittstående for idémyldring. Hver er en `StoryboardBlock` med `props.episodeId` (eller `props.boardId` for frittstående). + +Tre episoder under planlegging = tre storyboard-blokker, enten: +- På samme side (splittet grid-layout) +- På ulike sider i workspace-navigasjonen + +## 3. Fritt canvas + +Storyboardet bruker Canvas-primitivet for all canvas-interaksjon: +- **Pan/zoom:** Se `canvas_primitiv.md` §2 +- **Objekt-plassering:** Kort har `(x, y)` i world-space, ingen kolonner eller rader +- **Snap-to-grid:** Av som default, toggle med `G` +- **Ingen akse-begrensning:** Brukeren plasserer kort fritt. Ingen antatt retning eller tidslinje + +### 3.1 Kort-rendering + +Hvert kort rendres som en `` inne i canvas-primitivets objekt-slot: + +``` +┌─────────────────────────┐ +│ 🔵 Tittel │ ← Status-farge som border +│ │ +│ Kort sammendrag av body │ ← Avkortet tekst +│ │ +│ 💬 3 ⏱ 04:32 │ ← Svar-count + varighet (hvis tatt opp) +└─────────────────────────┘ +``` + +Kort-størrelse er fast bredde (variabel ved zoom), høyde tilpasser seg innholdet opp til en max. + +### 3.2 Kort-interaksjon + +- **Klikk:** Velg kort, vis flytende toolbar +- **Dobbeltklikk:** Åpne meldingsboksen i utvidet modus (full diskusjonstråd) +- **Drag:** Flytt kort på canvaset +- **Høyreklikk:** Kontekstmeny (status, send til, fjern, slett) + +## 4. Overføring mellom blokker + +Storyboardet deltar fullt i universell overføring (`universell_overfoering.md`): + +### 4.1 Som sender + +Dra et kort ut av storyboard-blokken → ghost følger musepekeren → slipp på annen blokk: +- **→ Chat:** Melding ankommer som ny chatmelding med `entered_at = now()` +- **→ Kanban:** Melding blir kort i første kolonne +- **→ Kalender:** Melding blir hendelse på dagens dato +- **→ Annet storyboard:** Melding får ny canvas-posisjon i mål-boardet + +### 4.2 Som mottaker + +Storyboardet kan motta fra alle blokk-typer: +- **Default-plassering:** Senter av viewport +- **Drag-plassering:** Der brukeren slipper objektet på canvaset +- **Status:** Nye kort ankommer som "Klar" + +### 4.3 Inter-storyboard overføring + +For å flytte kort mellom episoder (f.eks. "dette passer bedre i episode 48"): +- Dra kortet til en annen storyboard-blokk +- Eller bruk "Send til..." → velg annet storyboard +- Plasseringen i kilde-boardet fjernes, ny plassering opprettes i mål-boardet + +## 5. Kort som oppstår under innspilling + +Under live innspilling dukker nye idéer opp. Flere veier inn: + +| Metode | Flyt | +|--------|------| +| **Hurtigtast (`N`)** | Popup for tittel + body → kort plasseres nær senter | +| **Fra chat** | Skriv melding i studio-chat → "Send til Storyboard" | +| **AI-forslag** | Live AI foreslår kort basert på transkripsjon (se §8) | + +Alle veier oppretter en meldingsboks + plassering med status "Klar". + +## 6. Kobling til LiveKit / Studioet + +### 6.1 Tidsstempel ved "Tatt opp" + +Når et kort settes til "Tatt opp" under en aktiv innspilling: + +1. **Klient spør LiveKit** om nåværende innspillings-tidspunkt (offset fra oppstart) +2. Tidspunktet lagres i plasserings-metadata: `position.recorded_at_offset = 1823` (sekunder) +3. Episode-sekvensen oppdateres med kortet i riktig posisjon + +Etter innspilling, når Whisper har prosessert lyden, kan `recorded_at_offset` matches mot Whisper-segmenter for å koble kort til eksakte transkripsjonsavsnitt. + +### 6.2 Episode-sekvens + +```rust +#[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, // sekunder fra innspillingsstart + pub workspace_id: String, +} +``` + +Sekvensen er den ordnede listen over "Tatt opp"-kort og blir grunnlaget for episode-strukturen i Podcastfabrikken. + +## 7. Etter innspilling + +### 7.1 Episode-oppsummering + +Automatisk generert visning etter innspilling: + +``` +Episode 47 — Oppsummering +━━━━━━━━━━━━━━━━━━━━━━━━ +1. [00:00] Intro — Kommunevalg 2027 💬 2 svar +2. [04:32] Listekandidatene i Oslo 💬 5 svar +3. [12:15] Valgomaten — første resultater 💬 1 svar +4. [18:40] Debatten om bompenger 💬 3 svar +━━━━━━━━━━━━━━━━━━━━━━━━ +Totalt: 23:10 av 45:00 mål (51%) +Droppet: 3 kort (bevart for neste episode) +``` + +### 7.2 Arkivering + +Ett-klikks arkivering: +1. Alle "Tatt opp"-kort settes til "Arkivert" +2. "Droppet"-kort beholder status (synlige for neste episode som Ghost Cards) +3. "Klar"-kort beholder status (ubrukte idéer) +4. Episode-sekvensen fryses (immutable etter arkivering) + +## 8. AI-integrasjon (fremtidig) + +Under innspilling kan Live AI foreslå nye kort: + +1. Whisper transkriberer i sanntid +2. AI analyserer transkripsjon: "det ble nevnt et nytt tema som ikke er på boardet" +3. AI oppretter et foreslått kort (status: "Foreslått", visuelt distinkt — stiplet border) +4. Bruker aksepterer → status endres til "Klar" + +Dette bygger på Live AI (`docs/features/live_ai.md`) og er ikke del av MVP. + +## 9. SpacetimeDB-modell + +### 9.1 Tabeller + +```rust +// 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, + pub workspace_id: String, +} +``` + +### 9.2 Reducers + +```rust +#[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) { + // 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 +1. Canvas-primitiv (``) med pan, zoom, drag, viewport culling +2. BlockShell fullskjerm-toggle +3. `message_placements`-tabell (PG + SpacetimeDB) +4. `StoryboardBlock` registrert i block registry + +### Fase 2: Kjerne-storyboard +5. `` med status-visning og hurtigtaster +6. Episode-sekvens med SpacetimeDB-synk +7. Universell overføring: "Send til..." kontekstmeny +8. Drag-and-drop mellom blokker + +### Fase 3: Innspillingsintegrasjon +9. LiveKit-kobling for tidsstempler ved "Tatt opp" +10. Episode-oppsummering og arkivering +11. Kort opprettet under innspilling (hurtigtast + fra chat) + +### Fase 4: Polish og utvidelser +12. Ghost Cards (forrige episodes kort) +13. Pinboard Mode (zoom-ut til fugleperspektiv) +14. Flow Meter (visuell progresjon) +15. Mobil listevisning +16. 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 diff --git a/docs/proposals/tekst_primitiv.md b/docs/proposals/tekst_primitiv.md new file mode 100644 index 0000000..b2d1856 --- /dev/null +++ b/docs/proposals/tekst_primitiv.md @@ -0,0 +1,122 @@ +# 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/ → enkel deling (lesbar med lenke, ingen SEO) +sidelinja.org/@vegard/ → publisert artikkel (SEO, feed, OG-tags) +sidelinja.org/pub// → kuratert artikkel (se artikkel-publisering) +``` + +### `article_view` — publiseringslaget +Når en tekst skal publiseres, legges en view-config til: + +```sql +CREATE TABLE article_view ( + message_id UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', -- 'draft', 'review', 'published', 'archived' + excerpt TEXT, + body_html TEXT, -- Pre-rendret HTML (KaTeX + editor → HTML) + published_at TIMESTAMPTZ, + CONSTRAINT unique_slug_per_workspace UNIQUE (message_id) +); +``` + +### Offentlig vs intern kontekst +En publisert artikkel har to ansikter: + +**Inne i workspacet:** Artikkelen er en melding i en tråd. Den har `reply_to` til meldingen som startet diskusjonen, interne svar, graf-koblinger. Full kontekst. + +**Ute på web:** Artikkelen er en frittstående tekst. Den interne konteksten er usynlig. Offentlige kommentarer lever i en separat kanal (`visibility: 'public'`) som workspace-medlemmer kan se, men som ikke blander seg med den interne diskusjonen. + +``` +article_node (melding i workspace) +├── reply_to → intern forelder (usynlig utenfra) +├── channel: #Mediepolitikk (intern diskusjon) +└── public_comment_channel (offentlige kommentarer, separat) +``` + +## Åpne spørsmål + +### Versjoner og kladder +- Er det nok med `message_revisions` (lineær historikk), eller trengs navngitte versjoner / snapshots? +- Bør ulike versjoner kunne ha ulik visibility? ("kladd 1 er privat, kladd 2 er delt med lenke, publisert versjon er offentlig") +- Eller er det enklere: én tekst, én visibility, revisjonshistorikk under panseret? + +### Grensen melding ↔ artikkel +- Teknisk: ingen grense. En melding med `article_view` er en artikkel. +- UX-messig: når tilbyr systemet "vil du publisere dette?" Manuelt via meny? Automatisk forslag når teksten når en viss lengde/kompleksitet? + +### Offentlige kommentarer +- Hvem kan kommentere? Anonymt, autentisert, inviterte? +- Enkleste start: ingen offentlige kommentarer. Artikler er read-only for publikum. Diskusjon skjer internt eller via eksterne kanaler. + +## Innsats: Lav (prinsipp) / Middels (article_view + visibility) +Prinsippet krever ingen ny kode — meldingsboksen støtter det allerede. `article_view`-tabell og visibility-utvidelse er overkommelig. Den tunge delen er editoren (se `editor.md`). + +## Wow-faktor: 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" diff --git a/docs/proposals/valgomat_roast.md b/docs/proposals/valgomat_roast.md new file mode 100644 index 0000000..6335400 --- /dev/null +++ b/docs/proposals/valgomat_roast.md @@ -0,0 +1,21 @@ +# 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? diff --git a/docs/proposals/waveforms.md b/docs/proposals/waveforms.md new file mode 100644 index 0000000..bc35fd2 --- /dev/null +++ b/docs/proposals/waveforms.md @@ -0,0 +1,53 @@ +# 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: `` +- 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 diff --git a/docs/proposals/web_clipper.md b/docs/proposals/web_clipper.md new file mode 100644 index 0000000..ce108ba --- /dev/null +++ b/docs/proposals/web_clipper.md @@ -0,0 +1,43 @@ +# Forslag: Web Clipper / "Send til Sidelinja" + +## Idé +Redaksjonell research skjer oftest utenfor Sidelinja — når man leser VG, Aftenposten, eller PDF-er. En minimal "Send til Sidelinja"-mekanisme lar brukeren sende en URL til sin personlige innboks, der AI-en oppsummerer, trekker ut aktører og lager et ferdig research-klipp. + +## Hvorfor er dette interessant? +- Fjerner friksjonen mellom "leste noe interessant" og "la det inn i systemet" +- Utnytter eksisterende infrastruktur: jobbkø, AI Gateway, kunnskapsgraf +- Research-klipp lander som meldingsbokser — kan bli kanban-kort, faktoider, artikkel-grunnlag + +## Hva bygger den på? +- **Jobbkø** — ny jobbtype `url_ingest` +- **AI Gateway** — oppsummering og entity-uttrekk +- **Meldingsboks** — resultatet er en vanlig melding med `#`-mentions og graf-edges +- **Kunnskapsgraf** — kobler research til eksisterende entiteter +- **Personlig workspace** — innboks for ubehandlet research + +## Gjennomføring + +### Innsendingsmetoder (velg én eller flere) +1. **Share Target (PWA):** SvelteKit PWA registrerer seg som share target på mobil. Brukeren trykker "Del" i nettleseren → Sidelinja mottar URL-en. Krever kun en `share_target`-entry i `manifest.json` + et API-endepunkt. +2. **Chrome-utvidelse:** Minimal popup med "Send til Sidelinja" + workspace-velger. POST til `/api/clip`. +3. **Bookmarklet:** JavaScript-bookmarklet som sender `window.location.href` til API-et. Zero install. + +### Pipeline +``` +URL mottatt via /api/clip + → Opprett melding i personlig innboks (umiddelbart synlig for bruker) + → Legg jobb i køen: url_ingest (prioritet 5) + → Rust-worker: + 1. Hent HTML (eller bruk readability-parser for ren tekst) + 2. Send til AI Gateway (sidelinja/rutine): "Oppsummer, identifiser aktører" + 3. Oppdater meldingen med oppsummering, kilde-URL, foreslåtte #-mentions + 4. Brukeren godkjenner mentions → graf-edges opprettes +``` + +## Åpne spørsmål +- Paywall-innhold? Brukeren ser det i nettleseren, men workeren kan ikke hente det. Løsning: Send full tekst fra klienten i stedet for bare URL? +- Batching? "Legg til 5 artikler i kø" eller én-og-én? +- Automatisk duplikat-deteksjon? Sjekk om URL-en allerede er klippet. + +## Innsats: Lav–Middels +## Wow-faktor: Høy diff --git a/docs/retninger/README.md b/docs/retninger/README.md new file mode 100644 index 0000000..b928f4e --- /dev/null +++ b/docs/retninger/README.md @@ -0,0 +1,36 @@ +# 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](status_quo.md) | Referanse | Hva er Sidelinja i dag? Ankerpunkt for de andre retningene. | +| [Rom, ikke forum](rom_ikke_forum.md) | Åpen | Bør Sidelinja være en oppslukende sanntidsopplevelse fremfor en tradisjonell webapp? | +| [Universell input og mottak](universell_input.md) | Åpen | Én multimodal input-primitiv + personlig mottaksflate, noder + edges i stedet for separate tabeller | +| [Maskinrommet](maskinrommet.md) | Åpen | Én Rust-tjeneste med fast grensesnitt for alle tekniske tjenester: fang, prosesser, lever | +| [Bruker, ikke workspace](bruker_ikke_workspace.md) | Åpen | Brukeren er sentrum, workspaces er frivillige samlings-noder — ikke containere | +| [Datalaget](datalaget.md) | Åpen | PG + Apache AGE som enhetlig graf og arkiv, SpacetimeDB som sanntidslag, CAS for binærdata | + +## 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 diff --git a/docs/retninger/bruker_ikke_workspace.md b/docs/retninger/bruker_ikke_workspace.md new file mode 100644 index 0000000..d700f9e --- /dev/null +++ b/docs/retninger/bruker_ikke_workspace.md @@ -0,0 +1,154 @@ +# Bruker, ikke workspace + +> Primærenheten er brukeren og brukerens edges. Workspaces er ikke +> containere — de er frivillige samlings-noder som gir felles kontekst. + +## Observasjoner + +I dag er workspaces den organisatoriske enheten. Du "er i" et workspace, +og det bestemmer hva du ser: kanaler, boards, kalendre. Vil du se noe +fra et annet workspace må du bytte. Det er en container-modell — ting +*bor* i workspaces. + +Men i node+edge-modellen gir dette ikke mening. En node er ikke "i" noe +— den har edges til ting. Og brukeren er ikke "i" et workspace — brukeren +har edges til noder, personer, topics, samtaler. + +## Tesen + +**Brukeren er sentrum.** Du logger inn og ser dine edges — alt du er +koblet til. Ikke "velg workspace" som første handling, men "her er alt +ditt." + +### Workspace som samlings-node, ikke container + +Et workspace eksisterer fortsatt som konsept, men det er en node i +grafen — ikke en organisatorisk boks: + +- **Tradisjonell modell:** Workspace inneholder kanaler, boards, filer. + Brukeren er "i" workspacet. +- **Node-modell:** Workspace er en node. Noder har edge til den. Brukeren + har edge til den. Workspace-noden bærer felles kontekst (tema, + pruning-profil, AI-konfig). + +En node kan ha edge til flere workspaces. En faktoid om en gjest er +relevant for både podcast-prosjektet og research-samlingen. Ikke kopi — +samme node, to edges. + +### Personlig er default + +Alt uten workspace-edge eller mottaker-edge er privat — tilhører bare +deg. "Personlig workspace" er ikke en egen ting du oppretter. Det er +fravær av deling. Dine private notater, dagbok, voice memos — de har +bare edges til deg. + +### Administrasjon er edges med roller + +Brukerstyring forvaltes i nodene selv, ikke i et sentralt admin-panel: + +| Edge-type | Hva den gir | +|-----------|------------| +| Eier-edge | Full kontroll — slette, endre tilgang, endre innstillinger | +| Admin-edge | Kan invitere/fjerne andre, endre konfigurasjon | +| Deltaker-edge | Kan gi input og motta | +| Leser-edge | Kan kun motta (observatør, lytter) | + +Du oppretter en kommunikasjonsnode (podcast-prosjekt). Du er eier +automatisk. Du inviterer Trond → deltaker-edge. Trond kan gi input +men ikke slette eller endre tilgang. Du gir Trond admin-edge → nå +kan han invitere andre og endre innstillinger på den noden. + +Dette skalerer fra en privat notat (kun eier-edge til deg) til en +organisasjon (samlings-node med mange admin- og deltaker-edges). + +### Brukeropplevelsen + +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** (det som før var workspaces) — grupperer + kontekst, men du trenger ikke "gå inn i" dem +- **Din mottaksflate** — alt som er relevant for deg nå, vektet + +Du kan filtrere etter samlings-node hvis du vil fokusere ("vis bare +podcast-prosjektet"), men det er et filter — ikke en modebytte. + +### Felles kontekst på samlings-noder + +En samlings-node (workspace) bærer kontekst som arves av tilknyttede +noder: + +- **Pruning-profil** — hvor aggressivt slettes binærdata? +- **Tema** — visuelt uttrykk (CSS custom properties) +- **AI-konfigurasjon** — hvilke prompts, hvilken modell, hvilke regler +- **Tilgangsnivå** — default synlighet for nye noder opprettet i + denne konteksten +- **Kapasitet** — ressursgrenser for maskinrommet (f.eks. maks + samtidige transkripsjoner) + +## Implikasjoner + +### Kryssgående noder er naturlig +En node med edge til to samlings-noder arver kontekst fra begge. +Konflikter (ulik pruning-profil) løses med prioriteringsregler: +mest konservativ vinner, eller eier-edge bestemmer. + +### Onboarding forenkles +Ny bruker trenger ikke "settes opp i et workspace." De får edges til +de nodene de trenger tilgang til. Ferdig. Endre tilgang = endre edges. + +### Forlate et prosjekt er å fjerne edges +Ingen "slett bruker fra workspace." Fjern deltaker-edges. Brukerens +private noder som hadde edge til samlings-noden beholder den edgen +(det er brukerens innhold), men de mister tilgang til andres noder. + +## RLS og sikkerhet: samlings-noder som harde siloer + +Viktig arkitekturbeslutning: **"bruker, ikke workspace" er en +UX-modell, ikke en sikkerhetsmodell.** + +Ren edge-basert tilgangskontroll — der hver lesing traverserer grafen +for å sjekke om brukeren har en sti til noden — ville kvelt +databasen når grafen vokser. I dag har PG bunnsolid RLS med +`workspace_id = current_setting(...)` — instant og vanntett. + +Løsningen er å skille UX fra sikkerhet: +- **UX-laget:** Brukeren ser sine edges. Ingen "velg workspace." + Filtrering etter samlings-node er frivillig. +- **Sikkerhetslaget:** Under panseret har hver node fortsatt en + `workspace_id` (eller samlings-node-id) som RLS sjekker. Det er + den harde sikkerhetsgensen — billig, velprøvd, instant. +- **Finere tilgang innenfor siloen:** Edge-basert tilgang (eier, + admin, deltaker, leser) sjekkes *innenfor* en silo, ikke som + erstatning for den. + +Samlings-noder er altså ikke bare frivillig organisering — de er +sikkerhetsiloer i databasen. Brukeren merker det ikke, men PG +trenger det. + +## Spenninger og åpne spørsmål + +- **Overblikk.** Uten workspaces som organisatorisk enhet — hvordan + unngår du at alt flyter sammen? Samlings-noder er svaret, men de + må være intuitive å opprette og bruke uten å bli "workspaces med + ny navn." +- **Kryssgående noder og siloer.** Hvis noder har `workspace_id` for + RLS, kan en node da tilhøre to samlings-noder? Kanskje via en + "primær silo" for RLS + sekundære edges for visning. Trenger + gjennomtenkt design. +- **Arv og konflikter.** En node med edge til to samlings-noder med + ulike pruning-profiler — hva vinner? Trenger klare regler som er + intuitive for brukeren. +- **Migrering.** Eksisterende workspace-modell har innhold. Kan + workspaces bli samlings-noder gradvis? RLS-modellen gjør dette + enklere — workspace_id kan bli samlings-node-id uten endring i + sikkerhetslogikk. + +## Forhold til andre retninger + +- [Rom, ikke forum](rom_ikke_forum.md) — "rommet" er ikke et workspace, + det er summen av dine edges +- [Universell input og mottak](universell_input.md) — mottaksflaten er + "noder med edge til *meg*", ikke "noder i *mitt workspace*" +- [Maskinrommet](maskinrommet.md) — leser samlings-node-edges for + kontekst (pruning, kapasitet, AI-konfig) diff --git a/docs/retninger/datalaget.md b/docs/retninger/datalaget.md new file mode 100644 index 0000000..78ca613 --- /dev/null +++ b/docs/retninger/datalaget.md @@ -0,0 +1,182 @@ +# Datalaget — PG + AGE som enhetlig graf og arkiv + +> PostgreSQL er den eneste databasen. Apache AGE gir Cypher-semantikk +> for graftraverseringer. SpacetimeDB er sanntidslaget over samme data. +> Én database, to tilgangsmønstre, ingen eierskapskonflikt. + +## Observasjoner + +Hele retningsarbeidet har bygget seg mot "alt er noder og edges." +Tilgang er edges, visninger er spørringer, administrasjon er +traversering. Spørsmålet er: hvilken teknologi støtter dette best? + +### Neo4j ble vurdert og forkastet + +Neo4j er best-in-class for dype graftraverseringer, men for dette +prosjektet gir den mer problemer enn den løser: + +- **Tredje database.** PG + SpacetimeDB + Neo4j = tre systemer å + drifte, sikkerhetskopiere og overvåke. På én Hetzner VPS. +- **Mister PG-økosystemet.** pgvector (semantisk søk), fulltekstsøk, + JSONB, pg_cron, statistikk-aggregeringer — alt dette trenger vi + uansett, og det lever i PG. Med Neo4j trenger vi PG *i tillegg*. +- **Minnehungrig.** Neo4j anbefaler 8-16GB heap. På en delt VPS med + PG, SpacetimeDB, Whisper og LiteLLM er det for mye. +- **Lisens.** Community er AGPLv3. Enterprise-features (clustering, + avansert sikkerhet) krever betalt lisens. + +### Apache AGE — Cypher i PG + +Apache AGE er en PG-extension som legger til openCypher-støtte. Noder +og edges lagres i PG-tabeller, men spørres med Cypher-semantikk. + +**Oppsider:** +- **Én database.** Ingen ny infrastruktur. Samme backup, connection + pooling, tooling, monitoring. +- **SQL + Cypher i samme spørring.** "Finn alle noder brukeren har + tilgang til via team-traversering, JOINet med fulltekstsøk og + pgvector." Det kan du ikke gjøre med en separat grafdatabase. +- **Null migrasjonskostnad.** Eksisterende nodes/edges-tabeller kan + eksponeres som graf. Legg til extension, ikke bytt database. +- **Økosystemet bevares.** pgvector, fulltekstsøk, JSONB, pg_cron — + alt fungerer side om side med Cypher-spørringer. +- **Produksjonsbruk.** Azure og EDB tilbyr AGE som managed extension. + +**Nedsider:** +- **Ikke native graf.** Under panseret er det PG-tabeller. For svært + dype traverseringer (10+ hopp over millioner av noder) er Neo4j + raskere. Men de fleste spørringene våre er 1-5 hopp. +- **Yngre prosjekt.** Mindre community, tynnere dokumentasjon enn Neo4j. +- **Quirks.** Spørringer må være enten read-only eller write-only + (WITH-clause for overgang). Noen pg_upgrade-begrensninger. + +## Beslutning + +**PostgreSQL** som enhetlig datalager for noder og edges. +**Apache AGE** som planlagt utvidelse — ikke forpliktet fra dag én. + +De fleste spørringer i dette systemet er grunne: +- "Vis noder med edge til denne brukeren" — 1 hopp +- "Sjekk tilgang via team" — 2 hopp +- "Finn alle kommunikasjonsnoder brukeren har tilgang til" — 2-3 hopp +- "Traverser kunnskapsgrafen mellom to emner" — 3-5 hopp + +PG med rekursive CTEs håndterer 1-3 hopp utmerket. AGE legges til +når graftraversering faktisk blir en målbar flaskehals — ikke før. +AGE er en extension, ikke en migrering, så den kan boltes på uten +å endre eksisterende kode. + +Pragmatisk rekkefølge: +1. **Nå:** PG med nodes/edges-tabeller og CTEs +2. **Når CTEs blir smertefulle:** Legg til AGE for Cypher-spørringer +3. **Usannsynlig:** Evaluer Neo4j hvis AGE ikke holder + +## Lagmodell + +``` +┌─────────────────────────────────────┐ +│ GUI (SvelteKit) │ +│ Primitiver, visninger │ +└────────┬──────────────────┬─────────┘ + │ skriv │ les (sanntid) + │ │ direkte WebSocket +┌────────▼────────┐ ┌─────▼─────────┐ +│ Maskinrommet │ │ SpacetimeDB │──┐ +│ (Rust) │ │ (STDB) │ │ +│ Orkestrering │ └───────────────┘ │ +└──┬─────┬─────┬──┘ │ + │ │ │ sync ↕ │ + ▼ ▼ ▼ │ +┌─────┐┌─────┐┌─────┐┌─────────────┐ │ +│ PG ││STDB ││ CAS ││ Whisper, │ │ +│+AGE ││(skr)││ ││ LiteLLM, │ │ +│ ││ ││ ││ LiveKit ... │ │ +└─────┘└─────┘└─────┘└─────────────┘ │ +``` + +### Skriv vs les — to stier med god grunn + +**Skriv:** GUI → Maskinrommet (Rust) → tjenester (PG, STDB, CAS, ...) + +All orkestrering, edge-logikk, ressursallokering og validering går +gjennom Rust. Maskinrommet bestemmer hva som skjer: hva som skrives +til PG, hva som speiles til SpacetimeDB, hva som lagres i CAS, +hvilke tjenester som trigges. + +**Les (sanntid):** SpacetimeDB → GUI (direkte WebSocket) + +SpacetimeDB sitt klient-SDK kobler seg direkte via WebSocket, +definerer SQL-subscriptions, og synkroniserer automatisk med lokal +cache — uten network round-trips for lesing (~10μs per transaksjon). +Å proxy dette gjennom Rust ville lagt til et hopp, serialisering og +kontekstbytte — en dårligere reimplementering av noe STDB gjør +optimalt. Den direkte lese-stien er en bevisst arkitekturbeslutning, +ikke et hull i lagmodellen. + +**Les (tradisjonelt):** GUI → Maskinrommet (Rust) → PG + +Søk, historikk, statistikk, arkiv — alt som ikke er sanntid går +gjennom Rust og PG. AGE-spørringer for graftraversering, pgvector +for semantisk søk, SQL for aggregeringer. + +### PG er arkivet og grafen +Alle noder og edges lever i PG. AGE gir Cypher-spørringer for +traversering. Standard SQL for alt annet (aggregeringer, fulltekstsøk, +JOINs mot pgvector). Én database, to spørrespråk som utfyller +hverandre. + +### SpacetimeDB er sanntidslaget +Aktive noder og edges speilet til SpacetimeDB for live-oppdateringer. +Ting som er "nå" lever i SpacetimeDB. Ting som er "ferdig" lever kun +i PG. Ingen eierskapskonflikt — SpacetimeDB er en live-visning av +en delmengde av PG-grafen. + +### CAS er binærlageret +Lyd, bilde, video lagres content-addressable utenfor PG. Noder i +PG peker på CAS-hasher. Pruning-regler basert på edges, aksesslog +og modalitet (se maskinrommet). + +## Migreringsstrategi + +### Fase 1: AGE på eksisterende PG +Installer Apache AGE extension. Eksponer eksisterende nodes/edges- +tabeller som graf. Ingen endring for resten av systemet — bare en +ny måte å spørre på. + +### Fase 2: Konsolider til node+edge-modellen +Migrer meldingsboks, kanban, kalender etc. til den universelle +node+edge-modellen gradvis. Gamle tabeller kan leve som views +over grafen i overgangsperioden. + +### Fase 3: Parallell prototype +Det gamle systemet kjører uforstyrret. En ny frontend-prototype +bygges ved siden av som bruker AGE-grafen direkte. Live sync fra +gammelt til nytt via enveis oppdateringer. Testbrukere leker i +det nye, produksjon er trygt i det gamle. + +### Fase 4: Cutover +Når det nye er godt nok, flyttes trafikk over. Det gamle systemet +kan kjøre som read-only fallback en periode. + +## Spenninger og åpne spørsmål + +- **AGE-modenhet.** Prosjektet er aktivt og støttet av Azure/EDB, + men community er mindre enn Neo4j. Risikoen er håndterbar fordi + dataene alltid er PG-tabeller — AGE er bare et spørrelag. +- **SpacetimeDB-synk.** Hvordan synkes noder/edges mellom PG (AGE) + og SpacetimeDB effektivt? Trenger en sync-mekanisme som forstår + "aktiv delmengde." +- **Skjemadesign.** Én node-tabell med alle typer innhold — hva er + felles kolonner vs edge-metadata vs JSONB-payload? Trenger + gjennomtenkt skjema som balanserer fleksibilitet og spørrbarhet. + +## Forhold til andre retninger + +- [Universell input og mottak](universell_input.md) — noder og edges + er den underliggende datamodellen for alle tre primitiver +- [Maskinrommet](maskinrommet.md) — CAS og pruning lever her, AGE + brukes for edge-drevet ressursorkestrering +- [Bruker, ikke workspace](bruker_ikke_workspace.md) — tilgang via + graf-traversering (AGE) i stedet for workspace-membership-tabeller +- [Rom, ikke forum](rom_ikke_forum.md) — to-lags-modellen: + SpacetimeDB for nåtid, PG+AGE for arkiv og graf diff --git a/docs/retninger/maskinrommet.md b/docs/retninger/maskinrommet.md new file mode 100644 index 0000000..0a1856b --- /dev/null +++ b/docs/retninger/maskinrommet.md @@ -0,0 +1,322 @@ +# Maskinrommet — teknisk tjenestelaget + +> Én Rust-tjeneste med et fast grensesnitt. Alle tekniske tjenester beveger +> seg gjennom dette laget. Fang, prosesser, lever. + +## Observasjoner + +I dag er tekniske tjenester spredt: +- **Worker** (Rust) — kjører bakgrunnsjobber +- **Jobbkø** (PG) — koordinerer arbeid +- **AI Gateway** (LiteLLM) — ruter AI-kall +- **Whisper** — transkripsjon +- **LiveKit** — lyd/video-strømmer + +Hver har sitt eget grensesnitt. Frontend og primitiv-laget må vite hva +som finnes under panseret. Det er ingen felles abstraksjon, ingen felles +logging, ingen felles kapasitetsstyring. + +## Tesen + +Alt som krever tunge ressurser eller eksterne tjenester går gjennom +**ett lag** med **ett grensesnitt**. Ikke fordi det er elegant — fordi +det gir et fast punkt som er enkelt å fange, modifisere og forbedre. + +Maskinrommet gjør tre ting: + +### 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, sentimentanalyse, + faktasjekk, edge-forslag +- **Beriking** — URL → metadata, bilde → beskrivelse, lyd → segmenter +- **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, snapshot) +- Strukturert data (noder, edges, metadata tilbake i grafen) +- Push (webhook, SSE, SpacetimeDB-reducer) + +## Edge-drevet ressursorkestrering + +Nøkkelinnsikten: **maskinrommet leser edges for å vite hva det skal gjøre.** +Noden selv er alltid enkel. Det er edgene som bestemmer hvilke ressurser +som spinnes opp. + +### Security by default +Input uten mottaker-edge er automatisk privat. Du trenger ikke "velge +privat" — det er utgangspunktet. Ingen ser det. Ingen ressurser kobles +inn utover det grunnleggende (fang + transkriber). Privat er ikke en +innstilling, det er fravær av deling. + +### Ressurser er proporsjonale med edges +Samme nodetype, vilt forskjellig ressursbruk: + +``` +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 + publiserings-edge + → fang lyd → transkriber → stream via LiveKit → distribuer + → generer segmenter → kjør live AI → publiser + Ressurser: STT + LiveKit + LLM + mediaprosessering +``` + +Maskinrommet gjør ikke mer enn det edges krever. Ingen overhead for +enkle ting. Noden vet ingenting om LiveKit — den har bare edges som +sier "stream til disse mottakerne", og maskinrommet bestemmer at det +betyr LiveKit. + +### Naturlig eskalering +Du starter en privat voice-note. Bestemmer deg for å dele den med Trond +→ legg til mottaker-edge, maskinrommet begynner å levere. Trond foreslår +at dere tar det som et møte → legg til flere deltaker-edges, maskinrommet +kobler inn sanntidsstrømming. Møtet blir en innspilling → legg til +publiserings-edge, maskinrommet aktiverer produksjonspipeline. + +Hvert steg er bare å legge til edges. Maskinrommet reagerer og kobler +inn flere ressurser etter hvert. Ingen migrering, ingen modebytte. + +## Grensesnittet + +Maskinrommet eksponerer et konsistent API — sannsynligvis en Rust trait +eller et sett traits: + +``` +fang(input: RåInput) → NodeId +prosesser(node: NodeId, operasjon: Operasjon) → Resultat +lever(node: NodeId, mottaker: Mottaker, format: Format) → Status +``` + +Men i praksis er mye av dette *reaktivt*: maskinrommet observerer +edge-endringer og handler automatisk. Legger noen til en mottaker-edge +→ maskinrommet begynner å levere. Legger noen til en stream-edge → +maskinrommet kobler inn LiveKit. Primitivene trenger ikke eksplisitt +kalle `lever()` — de manipulerer edges, og maskinrommet reagerer. + +## Hva dette gir + +### Isolasjon +Bytt Whisper med noe annet? Endre maskinrommet. Frontend vet ingenting. +Legg til bildegenerering? Ny operasjon i maskinrommet. Primitivene +kaller den uten å vite hva som skjer under. + +### Observerbarhet +Alt går gjennom ett punkt. Logging, metrikker, kostnadsrapportering, +feilhåndtering — alt på ett sted. "Hva bruker vi AI-ressurser på?" +har ett svar. + +### Kapasitetsstyring +Prioritering, kø, rate limiting, fallback mellom leverandører — alt +håndtert av maskinrommet. En podcastinnspilling som trenger live +transkripsjon kan prioriteres over en bakgrunns-oppsummering. + +### Fast utviklingspunkt +To team (eller to hatter) med klart grensesnitt: +- **Over maskinrommet:** primitiver, noder, edges, UI, brukeropplevelse +- **I maskinrommet:** ytelse, integrasjoner, kapasitet, kostnad + +Du kan perfeksjonere det ene uten å røre det andre. + +## Content-Addressable Storage og intelligent pruning + +Maskinrommet forvalter også lagring. Ikke alt kan lagres for evig — men +ikke alt trenger det heller. Signalene for hva som er viktig finnes +allerede i grafen. + +### CAS som lagringsprimitiv +All binærdata (lyd, bilde, video) lagres i et content-addressable store. +Fordeler: +- **Deduplisering gratis** — samme fil delt i tre kontekster = én kopi +- **Separasjon** — "innholdet eksisterer" er adskilt fra "innholdet er + tilgjengelig." Noden peker på en hash, CAS har filen (eller ikke). +- **Enkel opprydning** — slett hashen fra CAS, alle noder som pekte + dit mister binærdataen men beholder metadata og transkripsjon. + +### Lagringsregler per modalitet + +| Modalitet | Default levetid | Begrunnelse | +|-----------|----------------|-------------| +| Tekst | Evig | Billig, er essensen av innholdet | +| Transkripsjon | Evig | Tekstlig representasjon av lyd/video — tar vare på meningen | +| Lyd | 30 dager | Mellomkostnad, transkripsjon bevarer innholdet | +| Bilde | 30 dager | Mellomkostnad, beskrivelse/metadata bevarer kontekst | +| Video | 14 dager | Dyrest, transkripsjon + thumbnail bevarer det meste | + +### Signaler som forlenger levetid + +Default-TTL er bare utgangspunktet. Maskinrommet justerer basert på: + +- **Edges.** En lydfil med edge til episoderegisteret = publisert + podcast, beholdes. En privat voice-memo uten edges = 30-dagers TTL. +- **Aksesslog.** Hvis noen har spilt av lydfilen i løpet av TTL-perioden, + forlenges den. Ingen aksess = ingen verdi i å beholde binærdataen. +- **Transkripsjonsstatus.** Lyd som er transkribert har "overlevert sin + essens" til tekst. Lyd som *ikke* er transkribert (f.eks. musikk, + lydeffekter) kan trenge lengre TTL. +- **Edge-type.** Edge til publisert innhold = behold. Edge til arkivert + møte = transkripsjon holder. Edge til ingenting = teksten lever videre, + binærdataen kan dø. + +### Eksempler + +``` +Privat voice-memo, aldri delt: + → Lyd transkriberes → tekst lagres evig + → Lydfil: 30 dager, ingen aksess, ingen edges → slettes + → Noden lever videre med teksten + +Podcastepisode: + → Lyd har edge til episoderegister + publiserings-edge + → Aksesseres regelmessig via podcastarkivet + → Lydfil: beholdes så lenge edges og aksess tilsier det + +Rutinemøte for et år siden: + → Video (6 kanaler): ingen har sett den på 6 måneder → slettes + → Lyd: ingen har spilt av → slettes + → Transkripsjon: tekst, lagres evig. Søkbar, refererbar. + → Noden lever med full kontekst minus binærdata + +Viktig styremøte: + → Video aksesseres av styremedlemmer → forlenges + → Workspace-innstilling: "behold video i 1 år" → overrider default +``` + +### Generert innhold er en cache +TTS, thumbnails, AI-oppsummeringer, waveforms — alt som kan regenereres +fra kildedata er i praksis en cache. Det lagres i CAS med samme TTL- +mekanisme som alt annet: +- Peter ber om lyd-versjon av en tekstmelding → TTS genereres, lagres +- Ingen spiller den av på 30 dager → filen slettes fra CAS +- Peter (eller noen andre) ber om lyd igjen → regenereres on-demand +- Teksten er der alltid. Binærdataen er flyktig. + +Maskinrommet trenger ikke skille mellom "original lyd" (voice memo) og +"generert lyd" (TTS) i pruning-logikken. Begge er binærdata i CAS med +en TTL som forlenges ved aksess. Forskjellen er bare at generert +innhold alltid kan gjenskapes fra kilden — så det er tryggere å prune. + +### Workspace-styrt aggressivitet +Hvert workspace kan justere sin pruning-profil: +- **Konservativt** — behold alt lenge (f.eks. arkiv-workspace) +- **Aggressivt** — tekst bevares, binærdata prunes raskt (f.eks. + daglig drift-workspace med mye rutineinnhold) +- **Tilpasset** — egne regler per modalitet og edge-type + +### Brukerens erfaringsbaserte meny +Over tid bruker du noen edges oftere enn andre, noen noder oftere enn +andre. Maskinrommet observerer dette og tilbyr en erfaringsbasert meny: +dine mest brukte koblinger, dine vanligste input-mønstre, dine +foretrukne modaliteter. Ikke som en rigid konfigurasjon — som en +adaptiv overflate du kan aktivere og deaktivere fortløpende. + +Dette er ikke maskinlæring eller kompleks AI — det er frekvenstelling +på edges og aksesslog. Enkelt å implementere, intuitivt for brukeren. + +## Pragmatisk vei dit + +Ikke bygg dette fra scratch. Formaliser det som allerede finnes: + +1. **Worker + jobbkø er allerede kjernen.** De trenger et konsistent + API, ikke en omskriving. +2. **AI Gateway (LiteLLM) absorberes** — i stedet for en separat proxy, + blir LLM-kall en operasjon i maskinrommet som alt annet. +3. **Whisper, TTS, mediaprosessering** — allerede planlagt som + worker-jobber. Gi dem samme grensesnitt. +4. **LiveKit** — den mest spesielle tjenesten (sanntidsstrømmer). Kan + starte som en separat integrasjon og formaliseres inn over tid. + +Rekkefølge: definer traits → migrer eksisterende worker-jobber inn → +legg til nye tjenester etter hvert. Fast punkt fra dag én, full +dekning over tid. + +## Compute-separasjon + +Maskinrommet orkestrerer — men tunge jobber trenger ikke kjøre på +samme maskin. Hetzner CPX42 (8 vCPU, 16 GB RAM) skal håndtere state +(PG, SpacetimeDB) og sanntid (Caddy, LiveKit, SvelteKit). Whisper +(CPU-intensiv, spesielt large-v3) og lokal LLM (kildevern-modus) +vil konkurrere om ressurser under live innspilling. + +Maskinrommets abstraksjon gjør dette løsbart: +- **Nå:** Alt på én VPS. Jobbkøen prioriterer sanntid over batch. + Whisper kjøres med lavere concurrency under live-sesjoner. +- **Senere:** Trekk ut tunge workers til en separat node (billig + ARM/Ampere-instans) som poller jobbkøen over internt nettverk. + Maskinrommet ruter transparent — primitivene merker ingenting. +- **Kildevern-modus:** Lokal LLM (Llama/Gemma) krever GPU eller + dedikert compute. Urealistisk på delt VPS. Egen node for dette. + +Poenget: maskinrommet er designet for å rute arbeid, ikke for å +*utføre* alt selv. Compute-separasjon er en konfigurasjon, ikke en +arkitekturendring. + +## Spenninger og åpne spørsmål + +- **Synkron vs asynkron.** "Fang" og "lever" kan være instant, men + "prosesser" kan ta sekunder (TTS) eller minutter (full episode- + transkripsjon). Grensesnittet må håndtere begge naturlig. +- **Strømmer.** Live lyd/video er fundamentalt annerledes enn + request/response. Men edge-modellen løser mye: maskinrommet ser en + stream-edge og vet at det betyr LiveKit. Utfordringen er *reaktivitet* + — maskinrommet må observere edge-endringer i sanntid og koble inn/ut + ressurser dynamisk. +- **Granularitet.** Hvor mye skal maskinrommet vite om domenet? "Fang + lyd" er generisk, men "transkriber og splitt i segmenter med + taler-identifikasjon" er domenespesifikt. Hvor går grensen? +- **Overhead.** Et ekstra lag betyr et ekstra kall. For tunge + operasjoner (Whisper, LLM) er det neglisjerbart. For lette + operasjoner (slå opp metadata) kan det være unødvendig indirection. + +## Plassering i lagmodellen + +Maskinrommet (Rust) er det eneste orkestringslaget. Alle tjenester — +inkludert PG, SpacetimeDB, CAS, Whisper, LiteLLM, LiveKit — er +likeverdige tjenester *under* maskinrommet. SpacetimeDB er ikke et lag +mellom Rust og GUI, det er en tjeneste maskinrommet skriver til. + +Ett unntak: SpacetimeDB har en direkte WebSocket-kobling til frontend +for sanntids lese-strøm. Dette er en bevisst optimering — STDB sitt +klient-SDK gir ~10μs-oppdateringer med automatisk synk og lokal cache. +Å proxy dette gjennom Rust ville vært å bygge en dårligere versjon av +noe STDB gjør optimalt. + +Se [datalaget](datalaget.md) for full lagmodell med diagram. + +## Forhold til andre retninger + +Maskinrommet er infrastrukturen *under* de tre primitivene i +[universell input og mottak](universell_input.md): +- Input-primitiven kaller `fang()` + `prosesser()` +- Mottak-primitiven kaller `lever()` +- Kommunikasjonsnoden bruker alle tre (fang input fra deltakere, + prosesser sanntid, lever til mottakere) + +Det er også det som gjør to-lags-modellen fra [rom, ikke forum](rom_ikke_forum.md) +praktisk: maskinrommet ruter til riktig lag (sanntid vs tradisjonelt) +uten at primitivene trenger å vite forskjellen. diff --git a/docs/retninger/rom_ikke_forum.md b/docs/retninger/rom_ikke_forum.md new file mode 100644 index 0000000..57b017b --- /dev/null +++ b/docs/retninger/rom_ikke_forum.md @@ -0,0 +1,211 @@ +# 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 +"Personlig workspace" trenger ikke være en egen ting. 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. diff --git a/docs/retninger/status_quo.md b/docs/retninger/status_quo.md new file mode 100644 index 0000000..44ce58f --- /dev/null +++ b/docs/retninger/status_quo.md @@ -0,0 +1,72 @@ +# Status quo — Hva Sidelinja er i dag + +> 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. diff --git a/docs/retninger/universell_input.md b/docs/retninger/universell_input.md new file mode 100644 index 0000000..09c8d61 --- /dev/null +++ b/docs/retninger/universell_input.md @@ -0,0 +1,299 @@ +# Universell input og mottak + +> Én multimodal input-primitiv. Én personlig mottaksflate. Alt som fanges +> er samme type objekt. Hva det "er" bestemmes av edges, ikke av tabellen +> det ligger i. Hvordan det *presenteres* bestemmes av mottakeren. + +## Observasjoner + +I dag har vi meldingsboksen som "universell primitiv" — men den er egentlig en +*lagringsprimitiv*. Den samler chat, kanban-kort, kalenderoppføringer og notater +i én tabell, men *input* er fortsatt forskjellig per kontekst: chat har ett +tekstfelt, kanban har et skjema, kalender har en datovelger. Brukeren velger +kontekst først, deretter gir de input. + +Og vi har separate pipelines for ulike modaliteter: tekst går én vei, lyd +(voice/transkripsjon) en annen, bilder en tredje. Hver med sin egen flyt. + +## Tesen + +**Én input-primitiv, to versjoner:** + +- **Sanntidsversjon** — live i SpacetimeDB-laget. Brukes i rom, samarbeid, + samtaler. Streamer input og viser resultater i sanntid. +- **Flat versjon** — tradisjonelt mot PG. Brukes på bussen, alene, offline-aktig. + Fanger input og lagrer asynkront. + +Begge aksepterer 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 +- **Nettoppslag** — lim inn URL, den berikes automatisk +- **Kommunikasjon** — samme primitiv for alene (dagbok), en-til-en (melding), + gruppe (kanal) + +Forskjellen mellom "jeg skriver dagbok", "jeg sender en melding" og "jeg lager +et kanban-kort" er ikke *hva brukeren gjør* — det er hvilke edges som knyttes +til resultatet. + +## Én tabell, edges definerer alt + +All output fra input-primitiven lander som noder i kunnskapsgrafen. Én tabell. +Ingen `messages`-tabell, ingen `cards`-tabell, ingen `notes`-tabell. + +Hva en node "er" bestemmes utelukkende av 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) = dagboknotis +- Node + edge til topic = faktoid i kunnskapsgrafen +- Node uten edges = løs tanke, ennå uorganisert + +**Retyping er trivielt.** Å gjøre en chatmelding om til et kanban-kort er å +legge til en edge til et board og en status-edge. Fjerne fra chat er å fjerne +kanal-edgen. Ingen datamigrering, ingen transformasjon av innhold. Bare edges. + +**Multitype er naturlig.** En node kan være *både* et kanban-kort *og* en +kalenderoppføring *og* en faktoid. Det er ikke en edge case — det er +arkitekturen. + +## Implikasjoner + +### Meldingsboksen erstattes av noe dypere +Meldingsboksen var riktig intuisjon — men den er en lagringsprimitiv som +prøver å forene ulike domenemodeller. Universell input + kunnskapsgrafen +gjør det renere: det finnes bare noder og edges. "Meldingsboks" blir et +view-konsept (hvordan noder vises i en kontekst), ikke et lagrings-konsept. + +### Input-metode og innholdstype er ortogonale +Du kan snakke inn et kanban-kort. Du kan tegne en kalenderoppføring. Du kan +skrive en voice memo (tekst som transkriberes til lyd for en annen bruker). +Input-primitiven bryr seg ikke om hva det *blir* — den fanger det som +kommer inn. + +### Samme input, ulik routing +Lyd inn i input-primitiven kan routes helt forskjellig basert på edges: +- Edge til et møterom → streames live til andre deltakere (sanntidslaget) +- Edge til kun deg selv → transkriberes og lagres som personlig notat +- Edge til en podcast-kanal → goes into produksjonspipeline +- Edge til en person → sendes som lydmelding + +Brukeren gjør det samme — snakker inn i input-feltet. *Systemet* router +basert på kontekst og edges. Det er ingen "møte-app" eller "notat-app" +eller "meldings-app" — det er én input med ulike destinasjoner. + +### 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 +- Anna skriver tekst → node med tekst + TTS-versjon +- Trond har lyd-preferanse → hører TTS-opplesning av Annas tekst + +Senderen trenger ikke vite eller bry seg. Innholdet er det samme — +presentasjonen er en mottaker-side preferanse. Modalitet er ikke +en egenskap ved meldingen, men ved *lesningen* av den. + +### Én overflate å perfeksjonere +Brukerens mentale modell kollapser til én ting: input-feltet. All +UX-investering konsentreres ett sted i stedet for å smøres tynt utover +ti ulike grensesnitt. Én perfekt input-opplevelse — responsiv, multimodal, +med god AI-støtte — i stedet for ti middelmådige spesialgrensesnitt. + +Dette er en radikal forenkling av utviklingsoverflaten. I stedet for å +bygge og vedlikeholde chat-input, kanban-skjema, kalender-dialog, +notat-editor, voice-recorder, dagbok-felt — bygger vi *ett* grensesnitt +og investerer alt i å gjøre det feilfritt. Alt etterpå er edges. + +### Visninger er spørringer mot grafen +Chat-visningen = "vis noder med edge til denne kanalen, sortert på tid." +Kanban-visningen = "vis noder med edge til dette boardet, gruppert på status." +Kalender-visningen = "vis noder med dato-edge, plassert på tidslinje." +Dagbok-visningen = "vis private noder for denne brukeren, sortert på tid." + +Alle visninger leser fra samme graf. Ingen har "sin egen" data. + +### To versjoner passer to-lags-modellen +Sanntidsversjonen lever i SpacetimeDB-laget: input streames, resultater er +live, andre ser hva du gjør. Flat versjonen lever i det tradisjonelle laget: +input sendes, lagres i PG, ferdig. Begge produserer identiske noder i grafen. + +### Synlighet er bare en edge +Privat = edge kun til deg. Delt = edge til en gruppe/kanal. Publisert = edge +til en offentlig kontekst. Å "dele" noe er å legge til en edge. Å "gjøre +privat" er å fjerne den. Innholdet endres aldri. + +## Universelt mottak — den andre primitiven + +Input-primitiven er halvparten. Den andre halvdelen er *mottak*: hvordan +du konsumerer det andre produserer. Der input er "én overflate som fanger +alt", er mottak "én overflate som presenterer alt tilpasset *deg*." + +### Dimensjoner ved mottak + +**Format.** Lyd, tekst, visuelt — mottaker bestemmer (allerede beskrevet +over). Men det gjelder alt, ikke bare meldinger: en AI-oppsummering kan +leses eller høres. Et whiteboard-snapshot kan vises som bilde eller som +tekstlig beskrivelse. + +**Filtrering.** Hva ser du? Alt fra alle er støy. Mottaksflaten filtrerer +basert på dine edges: hvilke kanaler du følger, hvilke personer du +samarbeider med, hvilke topics du er interessert i. Du kuraterer ikke +manuelt — du justerer edges, og mottaksflaten oppdateres. + +**Prioritering.** Hva er viktig *nå*? En AI-assistert redaksjonell flate +som løfter frem det som trenger oppmerksomhet: ubesvarte meldinger, +oppgaver med frist, noder som er endret siden sist, tråder med aktivitet. +Ikke en notifikasjonsliste — en *vektet visning* av det som er relevant. + +**Tempo.** Sanntid eller asynkront. I sanntidslaget: ting streamer inn +mens de skjer — en kollega snakker, du hører/leser live. I det +tradisjonelle laget: du får en digest, en oppsummering, et overblikk +over hva som har skjedd siden sist. Samme noder, ulikt tempo. + +**Kilde.** Direkte fra en person, eller via en node. Et møte som +genererer innsikter. En AI-jobb som er ferdig. En tråd som har blitt +aktiv igjen. En podcast-episode som er klar for review. Kilden trenger +ikke være et menneske — det kan være en prosess, en hendelse, en +tilstandsendring i grafen. + +### Mottaksflaten som speilbilde av input + +| Input | Mottak | +|-------|--------| +| Én overflate for all input | Én overflate for alt mottak | +| Sender bestemmer ikke format | Mottaker bestemmer format | +| Modalitet er ortogonal | Presentasjon er ortogonal | +| Kontekst gir edges | Preferanser gir filtrering | +| Sanntid + flat versjon | Sanntid (stream) + asynkron (digest) | + +### Mange-til-én og mange-via-mange +Mottaksflaten håndterer naturlig: +- **Én-til-én** — Trond sender deg en melding +- **Mange-til-én** — fem personer i en kanal, du ser alt +- **Via node** — et møte genererer et referat, du mottar det +- **Via kjede** — en chatmelding → blir oppgave → oppgaven fullføres → + du får oppdatering. Hele kjeden er edges, og du ser resultatet i din + mottaksflate uten å ha fulgt hvert steg. + +### Mottaksflaten er også en visning av grafen +Akkurat som chat-visningen er "noder med kanal-edge sortert på tid", er +mottaksflaten "noder med edge til *meg*, vektet på relevans og tid." +Det er ikke en egen mekanisme — det er enda en spørring mot samme graf, +bare med *deg* som sentrum. + +## Kommunikasjonsnoden — den tredje primitiven + +Input fanger. Mottak presenterer. Men det mangler noe: *stedet* der folk +møtes. En kommunikasjonsnode er en node i grafen som samler deltakere, +definerer tilgangsregler, og fungerer som kontekst for input og mottak. + +### Én node, mange former + +En kommunikasjonsnode er konseptuelt identisk uansett skala: + +| Variant | Deltakere | Input-tilgang | Mottak-tilgang | +|---------|-----------|---------------|----------------| +| Én-til-én samtale | 2 | Begge | Begge | +| Gruppechat | N | Alle medlemmer | Alle medlemmer | +| Redaksjonsmøte | N | Alle medlemmer | Alle medlemmer | +| Allmøte | 1 + N | Lederen snakker | Alle lytter, noen kan rekke opp hånden | +| Podcastinnspilling | 2-4 + N | Vertene snakker | Alle lytter, markører for produsent | +| Livesending | 1-4 + ∞ | Vertene | Streamet til nettside/app, lyd eller video | +| Asynkron gjest | 1 + 1 | Gjest gir input innen frist | Redaksjonen mottar | + +Forskjellen er *ikke* ulike systemer — det er ulike edge-konfigurasjoner +på samme nodetype: +- **Eier-edge** — hvem kontrollerer noden (kan invitere, endre regler, avslutte) +- **Input-edge** — hvem kan gi input (snakke, skrive, tegne, dele skjerm) +- **Mottak-edge** — hvem kan motta (lytte, lese, se stream) +- **Rolle-edge** — spesialroller (moderator, produsent, gjest) + +### Kommunikasjonsnoden er en kontekst for de andre primitivene + +Når du gir input *i* en kommunikasjonsnode, arver inputen kontekst-edges +automatisk. Sier du noe i et møte → noden du skaper får edge til møtet. +Du trenger ikke tenke på det — konteksten følger med. + +Mottak i en kommunikasjonsnode er det samme som universelt mottak, bare +scoped til den noden: du ser/hører det andre deltakere gir som input, +presentert etter dine preferanser. + +### Livssyklus + +En kommunikasjonsnode kan være: +- **Live** — aktiv i sanntidslaget. Deltakere er til stede, input streames. +- **Asynkron** — aktiv i det tradisjonelle laget. Deltakere gir input i + eget tempo (chat, asynkron gjest). +- **Avsluttet** — arkivert i PG. Alt som ble sagt/delt er noder med edges + til kommunikasjonsnoden. Kan søkes, gjenfinnes, refereres. +- **Gjenåpnet** — løftet tilbake til sanntidslaget. "Vi tar opp tråden + fra forrige møte" er bokstavelig talt å reaktivere en node. + +### Skalering er en edge-endring, ikke en migrasjonsoperasjon + +En samtale mellom to blir et møte ved å legge til flere deltaker-edges. +Et møte blir en livesending ved å legge til offentlige mottak-edges. +En livesending blir en podcast ved å legge til publiserings-edges på +arkivert innhold. Ingen migrering, ingen konvertering — bare edges. + +## Tekniske forutsetninger + +### STT (tale → tekst): løst +Faster-whisper kjører lokalt, god norsk kvalitet. Allerede i stacken. + +### TTS (tekst → tale): løsbart +Norsk TTS lokalt er ikke godt nok ennå (Piper er "usable" men dårlig rytme, +XTTS-v2 støtter ikke norsk). Kommersielt er det løst: +- **ElevenLabs** — beste kvalitet, eksplisitt norsk med regionale aksenter +- **Azure Neural TTS** — god kvalitet, ~$15/M tegn +- **Google Cloud TTS** — god kvalitet, WaveNet/Neural2 + +Strategi: start med kommersiell API (ElevenLabs) bak AI Gateway, bytt til +lokal modell (Chatterbox Multilingual el.l.) når kvaliteten er god nok. +Brukeren merker ingenting — det er en backend-swap bak gatewayen. Samme +mønster som Whisper: tung jobb → jobbkø → worker → resultat som node-metadata. + +Følg med på: **Chatterbox Multilingual** (Resemble AI) — annonsert norsk +støtte, 350M params, lovende for lokal kjøring. + +## Spenninger og åpne spørsmål + +- **Ytelse.** Én tabell med *alt* i — skalerer det? PG med riktige indekser + og partisjonering håndterer mye, men det er en reell designbeslutning. +- **Skjema.** Noder trenger noe felles skjema (innhold, created_at, author). + Men ulike modaliteter har ulike metadata (transkripsjon, bildestørrelse, + varighet). Hva er felles og hva er edge-metadata? +- **AI-klassifisering.** Når brukeren bare "sier noe" — hvem bestemmer hvilke + edges som knyttes? Manuelt? AI-foreslått? Kontekstbasert (sa det i en + kanal → kanal-edge)? Sannsynligvis en blanding, men det krever gjennomtenkt + UX. +- **Migrering.** Meldingsboksen har allerede innhold. Kan vi migrere til + node+edge-modellen gradvis, eller er det et brudd? +- **Kompleksitet for utviklere.** "Alt er noder og edges" er konseptuelt rent, + men å bygge en kanban-visning som spørrer en graf er mer komplekst enn å + lese fra en `cards`-tabell. Er abstraksjonen verdt kompleksiteten? + +## Tre primitiver, én graf + +| Primitiv | Hva den gjør | Brukerens opplevelse | +|----------|-------------|---------------------| +| **Input** | Fanger alt — tekst, lyd, bilde, AI | Én overflate å snakke/skrive/tegne i | +| **Mottak** | Presenterer alt tilpasset deg | Én personlig flate med det som er relevant | +| **Kommunikasjon** | Samler folk med tilgangsregler | Et sted å være — samtale, møte, sending | + +Alt er noder og edges i samme graf. Input skaper noder. Mottak spør +grafen med deg som sentrum. Kommunikasjonsnoder gir kontekst og +tilgangsregler. Visninger (chat, kanban, kalender, dagbok, stream) +er bare spørringer med ulike filtre. + +## Forhold til andre retninger + +Denne retningen konkretiserer [rom, ikke forum](rom_ikke_forum.md): +- "Formløs input, struktur etterpå" → universell input + edges +- "To lag" → sanntidsversjon + flat versjon +- "Privat/delt som lag" → synlighet som edge +- "Siloer forsvinner" → alt er noder, visninger er spørringer +- "Rommet som primitiv" → kommunikasjonsnoden diff --git a/docs/setup/lokal.md b/docs/setup/lokal.md new file mode 100644 index 0000000..23da049 --- /dev/null +++ b/docs/setup/lokal.md @@ -0,0 +1,84 @@ +# 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 i prod | Serveren er dev-miljø | +| Caddy/Authentik/Forgejo config | Direkte i prod | 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 + +```bash +# 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 + +```bash +cd ~ +git clone ssh://git@git.sidelinja.org:222/sidelinja/server.git sidelinja-v2 +cd sidelinja-v2 +``` + +## 3. Miljøvariabler (.env.local) + +```bash +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 + +```bash +# Start alt lokalt +./dev.sh + +# Eller manuelt: +cd web && npm run dev # SvelteKit med HMR +cd rust && cargo run # Rust maskinrom +``` + +`dev.sh` er kanonisk — oppdater alltid scriptet når nye steg oppdages. + +## 5. Deploy + +```bash +# 1. Commit og push +git push forgejo main + +# 2. Deploy til prod (krever eksplisitt godkjenning) +ssh sidelinja@157.180.81.26 "cd /srv/sidelinja && 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 | diff --git a/docs/setup/migration_safety.md b/docs/setup/migration_safety.md new file mode 100644 index 0000000..ddfc395 --- /dev/null +++ b/docs/setup/migration_safety.md @@ -0,0 +1,148 @@ +# Migration Safety Checklist + +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`: + +```sql +-- 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 = ''; +SELECT count(*) FROM nodes WHERE workspace_id = ''; +-- 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 +```sql +-- 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 +```sql +-- 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: + +```sql +-- 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: + +```sql +-- 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: + +```sql +-- 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).** diff --git a/docs/setup/produksjon.md b/docs/setup/produksjon.md new file mode 100644 index 0000000..70c6d4e --- /dev/null +++ b/docs/setup/produksjon.md @@ -0,0 +1,424 @@ +# Oppsett: Produksjonsserver (Hetzner VPS) +**Filsti:** `docs/setup/produksjon.md` + +Denne oppskriften tar en fersk Ubuntu VPS fra null til en komplett Sidelinja-installasjon. Hvert steg er sekvensielt — ikke hopp over noe. + +## 0. Forutsetninger +- Hetzner VPS med Ubuntu 24.04 LTS (8 vCPU, 16 GB RAM minimum) +- DNS A-records som peker til VPS-ens IP: + - `sidelinja.org` + `*.sidelinja.org` + - `vegard.info` + `*.vegard.info` +- SSH-tilgang med nøkkelpar (passordautentisering deaktiveres i steg 1) + +## 1. Grunnsikring av VPS + +```bash +# 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 + +```bash +# 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 + +```bash +sudo mkdir -p /srv/sidelinja/{config,data,media,logs} +sudo mkdir -p /srv/sidelinja/config/{caddy,authentik} +sudo mkdir -p /srv/sidelinja/data/{postgres,spacetimedb,forgejo,authentik} +sudo mkdir -p /srv/sidelinja/media/podcast +sudo mkdir -p /srv/sidelinja/logs/caddy +sudo chown -R sidelinja:sidelinja /srv/sidelinja +``` + +Resultat: +``` +/srv/sidelinja/ +├── docker-compose.yml +├── .env +├── config/ +│ ├── caddy/Caddyfile +│ └── authentik/ +├── data/ +│ ├── postgres/ +│ ├── spacetimedb/ +│ ├── forgejo/ +│ └── authentik/ +├── media/ +│ └── podcast/ +└── logs/ + └── caddy/ +``` + +## 4. Miljøvariabler (.env) + +```bash +cat > /srv/sidelinja/.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= +POSTGRES_DB=sidelinja + +# === Authentik === +AUTHENTIK_SECRET_KEY= +AUTHENTIK_POSTGRESQL_PASSWORD= +# 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= + +# === LiveKit === +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= + +# === OpenRouter === +OPENROUTER_API_KEY= + +# === Intern === +# Ingen porter eksponeres utenom 80/443. Alt rutes internt via Docker-nettverket. +EOF + +chmod 600 /srv/sidelinja/.env +``` + +## 5. Tjeneste-installasjon (rekkefølge) + +Tjenestene startes i rekkefølge fordi noen avhenger av andre. Alle defineres i `docker-compose.yml`, men vi verifiserer hvert lag før vi går videre. + +### Lag A: Fundament (ingen avhengigheter mellom seg) +1. **Docker-nettverk:** Opprett internt nettverk `sidelinja-net` +2. **PostgreSQL:** Start, opprett databaser for Authentik og Forgejo, verifiser (`pg_isready`) +3. **Caddy:** Start med Caddyfile for alle domener, verifiser at HTTPS fungerer +4. **Authentik:** Start, gjennomfør initial setup via `https://auth.sidelinja.org` +5. **Forgejo:** Start med Authentik som OAuth2-provider, opprett organisasjon og repo + +### Lag B: Sanntid (krever nettverk) +6. **SpacetimeDB:** Start, verifiser tilkobling +7. **LiveKit:** Start, verifiser at WebRTC fungerer + +### Lag C: Applikasjon (krever alt over) +8. **SvelteKit:** Bygg og start container, verifiser at frontenden laster +9. **Rust Workers:** Bygg og start container(e), verifiser at jobbkøen polles + +## 6. docker-compose.yml (skjelett) + +```yaml +# 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/sidelinja/ +# - .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/sidelinja/data/postgres + authentik: # SSO for alle domener, på auth.sidelinja.org + forgejo: # data:/srv/sidelinja/data/forgejo, på git.sidelinja.org + spacetimedb: # data:/srv/sidelinja/data/spacetimedb + livekit: # Intern port, proxyet via Caddy + sveltekit: # Intern port, proxyet via Caddy + workers: # Rust job workers, ingen porter +``` + +## 7. Caddy (Caddyfile grunnstruktur) + +```caddyfile +# === 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/sidelinja/media + file_server + } + + # Podcast access log (kun media-forespørsler) + log { + output file /srv/sidelinja/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: + +```sql +-- Kjøres mot PostgreSQL etter første start +-- (eller via init-script montert til /docker-entrypoint-initdb.d/) + +CREATE USER authentik WITH PASSWORD ''; +CREATE DATABASE authentik OWNER authentik; + +CREATE USER forgejo WITH PASSWORD ''; +CREATE DATABASE forgejo OWNER forgejo; +``` + +## 9. Authentik: Initial konfigurasjon + +Etter oppstart, gå til `https://auth.sidelinja.org/if/flow/initial-setup/`: +1. Opprett admin-konto +2. Opprett OAuth2/OpenID Connect-provider for Forgejo +3. Opprett OAuth2/OpenID Connect-provider for SvelteKit (senere) +4. Konfigurer brukergrupper etter behov (redaksjon, admin) + +## 10. Forgejo: Koble til Authentik + +Forgejo konfigureres med Authentik som OAuth2-kilde: +- Authentication Source: OAuth2 +- Provider: OpenID Connect +- Discovery URL: `https://auth.sidelinja.org/application/o//.well-known/openid-configuration` +- Etter oppsett: opprett organisasjon `sidelinja`, opprett repo `sidelinja` + +## 11. Backup-strategi + +Se `docs/arkitektur.md` seksjon 2.2 for full dataklassifisering. Kun kategori 1 (kritisk) og Forgejo-data backupes. + +### 11.1 PostgreSQL (daglig dump, 03:00) +```bash +# pg_dump er konsistent selv under last — ingen nedetid +docker compose exec -T postgres pg_dump -U sidelinja -Fc sidelinja \ + > /srv/sidelinja/backup/pg/sidelinja_$(date +%Y%m%d).dump + +# Behold 30 dager, slett eldre +find /srv/sidelinja/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. + +```bash +# 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) +```bash +# Inkrementell med rsync til lokal backup-disk eller ekstern lagring +rsync -a --delete /srv/sidelinja/media/ /srv/sidelinja/backup/media/ +``` + +### 11.3 Forgejo-data (daglig, 04:00) +```bash +# Forgejo-repos kan gjenskapes, men det er tidkrevende. +# Sikkerhetsnett-backup av hele data-mappen: +rsync -a --delete /srv/sidelinja/data/forgejo/ /srv/sidelinja/backup/forgejo/ +``` + +### 11.4 Hemmeligheter (.env) +```bash +# Manuell kopi ved endring — ALDRI i Git +cp /srv/sidelinja/.env /srv/sidelinja/backup/env_$(date +%Y%m%d) +chmod 600 /srv/sidelinja/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`. + +```bash +# 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/sidelinja/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/sidelinja/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/sidelinja/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/sidelinja/logs/backup-offsite.log +``` + +### 11.6 Cron-oppsett +```bash +# /etc/cron.d/sidelinja-backup +0 3 * * * sidelinja /srv/sidelinja/scripts/backup-pg.sh +30 3 * * * sidelinja /srv/sidelinja/scripts/backup-media.sh +0 4 * * * sidelinja /srv/sidelinja/scripts/backup-forgejo.sh +30 4 * * * sidelinja /srv/sidelinja/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 +```bash +# 1. PostgreSQL +docker compose exec -T postgres pg_restore -U sidelinja -d sidelinja --clean \ + < /srv/sidelinja/backup/pg/sidelinja_YYYYMMDD.dump + +# 2. Media +rsync -a /srv/sidelinja/backup/media/ /srv/sidelinja/media/ + +# 3. Forgejo +docker compose down forgejo +rsync -a /srv/sidelinja/backup/forgejo/ /srv/sidelinja/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: + +```bash +# Fra lokal maskin (WSL2): +git push forgejo main + +# SSH inn til server: +ssh sidelinja@ +cd /srv/sidelinja +git pull +docker compose build --no-cache +docker compose up -d +``` + +## 13. Verifisering etter oppsett + +### Lag A (minimum fungerende server) +- [ ] `https://auth.sidelinja.org` viser Authentik login +- [ ] `https://git.sidelinja.org` viser Forgejo, innlogging via Authentik fungerer +- [ ] PostgreSQL: `docker compose exec postgres pg_isready` returnerer OK +- [ ] SSH-push fra lokal WSL2 til Forgejo fungerer + +### Lag B-C +- [x] `https://sidelinja.org` laster SvelteKit-appen (deployet 2025-03-15) +- [x] `https://sidelinja.org/api/health` returnerer 200 +- [x] Authentik OIDC-innlogging fungerer fra nettleser (verifisert 2025-03-15) +- [x] Chat: meldinger sendes og vises med riktig brukernavn (verifisert 2025-03-15) +- [ ] `https://vegard.info` svarer +- [ ] SpacetimeDB: WebSocket-tilkobling fra nettleser fungerer +- [ ] LiveKit: Test-rom med video/lyd fungerer +- [ ] Media: `curl -I https://sidelinja.org/media/podcast/test.mp3` returnerer `Accept-Ranges: bytes` diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..84ca53a --- /dev/null +++ b/ops/README.md @@ -0,0 +1,20 @@ +# Ops — Repeterbare vedlikeholdsjobber + +Denne mappen inneholder veldefinerte, repeterbare jobber for å holde prosjektet +ryddig og gjennomsiktig. Hver jobb er en markdown-fil med sjekkliste som kan +kjøres av Claude eller manuelt. + +## Jobber + +| Jobb | Fil | Frekvens | Beskrivelse | +|------|-----|----------|-------------| +| Ryddejobb | [ryddejobb.md](ryddejobb.md) | Annenhver uke / ved behov | Full revisjon av prosjektet — docs, kode, drift, fremdrift | +| Doc-audit | [doc-audit.md](doc-audit.md) | Månedlig / etter store endringer | Sjekk at docs/ stemmer med faktisk kode | +| Drift-sjekk | [drift-sjekk.md](drift-sjekk.md) | Ved deploy / ved behov | Asynkron tilstand mellom prod, lokal og docs | + +## Konvensjoner + +- Hver jobb har seksjonene: **Hva**, **Når**, **Sjekkliste**, **Sist kjørt** +- «Sist kjørt»-seksjonen oppdateres hver gang jobben kjøres +- Funn skrives som korte bullet points med dato +- Fikser gjøres underveis eller logges som oppgaver i `tasks/` diff --git a/ops/doc-audit.md b/ops/doc-audit.md new file mode 100644 index 0000000..bbe0f46 --- /dev/null +++ b/ops/doc-audit.md @@ -0,0 +1,46 @@ +# Doc-audit — Dokumentasjon vs kode + +## Hva +Målrettet gjennomgang av `docs/`-treet for å sikre at dokumentasjonen stemmer med +faktisk kode. Mer fokusert enn ryddejobben — kun docs. + +## Når +- Månedlig som rutine +- Etter store refaktoreringer eller arkitekturendringer +- Når nye docs legges til + +## Sjekkliste + +### 1. Filreferanser +- [ ] Sjekk alle filstier nevnt i docs — eksisterer de? +- [ ] Sjekk alle komponent-/modul-navn nevnt i docs — eksisterer de i koden? +- [ ] Sjekk alle route-referanser — matcher de `web/src/routes/`? + +### 2. CLAUDE.md doc-tre +- [ ] Er alle filer i `docs/` listet i CLAUDE.md doc-treet? +- [ ] Er det filer listet i CLAUDE.md som ikke eksisterer? +- [ ] Er beskrivelsene i doc-treet fortsatt dekkende? + +### 3. Tekniske detaljer +- [ ] Stemmer database-skjema beskrevet i docs med faktiske migrasjoner? +- [ ] Stemmer API-endepunkter beskrevet i docs med faktiske routes? +- [ ] Stemmer SpacetimeDB-tabeller/reducere i docs med modulen? + +### 4. Setup-docs +- [ ] `docs/setup/lokal.md` — fungerer stegene fortsatt? +- [ ] `docs/setup/produksjon.md` — stemmer med faktisk server-oppsett? +- [ ] `docs/setup/migration_safety.md` — er sjekklisten oppdatert? + +### 5. Proposals +- [ ] Er noen proposals implementert og bør flyttes til `concepts/` eller `features/`? +- [ ] Er noen proposals foreldet og bør slettes eller arkiveres? +- [ ] Er noen proposals egentlig retningsspørsmål og bør flyttes til `retninger/`? + +### 6. Retninger +- [ ] Er tesene i `docs/retninger/` fortsatt relevante? +- [ ] Har noen retninger modnet nok til å påvirke andre docs (arkitektur, features, infra)? +- [ ] Er det nye arkitektoniske spenninger som fortjener en egen retning? + +## Sist kjørt + +_Ikke kjørt ennå._ diff --git a/ops/drift-sjekk.md b/ops/drift-sjekk.md new file mode 100644 index 0000000..b7d21a6 --- /dev/null +++ b/ops/drift-sjekk.md @@ -0,0 +1,45 @@ +# Drift-sjekk — Prod vs lokal vs docs + +## Hva +Verifiser at produksjonsserveren, lokalt utviklermiljø og dokumentasjon er i synk. +Fanger opp tilfeller der noe er deployet men ikke dokumentert, eller dokumentert +men ikke implementert. + +## Når +- Før og etter deploy til produksjon +- Når noe oppfører seg annerledes i prod vs lokalt +- Ved mistanke om drift + +## Sjekkliste + +### 1. Git-status +- [ ] Er prod-server på siste commit? (`ssh sidelinja@157.180.81.26 'cd /srv/sidelinja/server && git log -1'`) +- [ ] Er det ucommittede endringer lokalt som burde vært pushet? +- [ ] Er det commits på Forgejo som ikke er deployet til prod? + +### 2. Database-migrasjoner +- [ ] Er alle lokale migrasjoner pushet til repo? +- [ ] Er alle migrasjoner i repo kjørt i prod? +- [ ] Stemmer migrasjonsnumre mellom miljøer? + +### 3. Docker-tjenester +- [ ] Kjører alle forventede containere i prod? (`docker compose ps`) +- [ ] Er det tjenester i `docker-compose.dev.yml` som mangler i prod (eller omvendt)? +- [ ] Er image-versjoner oppdatert? + +### 4. Miljøvariabler +- [ ] Er det nye env-vars lagt til lokalt som mangler i prod `.env`? +- [ ] Er det env-vars i prod som er utdaterte? +- [ ] Er secrets rotert der de bør være? + +### 5. SpacetimeDB-modul +- [ ] Er SpacetimeDB-modulen publisert med siste endringer? +- [ ] Stemmer modul-skjema mellom lokal og prod? + +### 6. Caddy / reverse proxy +- [ ] Er Caddyfile i repo synk med prod? +- [ ] Er det nye subdomener eller routes som mangler? + +## Sist kjørt + +_Ikke kjørt ennå._ diff --git a/ops/ryddejobb.md b/ops/ryddejobb.md new file mode 100644 index 0000000..8c807d0 --- /dev/null +++ b/ops/ryddejobb.md @@ -0,0 +1,65 @@ +# Ryddejobb — Full prosjektrevisjon + +## Hva +Systematisk gjennomgang av hele prosjektet for å oppdatere fremdrift, tette hull, +fjerne utdaterte referanser, og sikre at dokumentasjon stemmer med virkeligheten. + +## Når +- Annenhver uke som rutine +- Etter store implementeringsjobber +- Når prosjektet føles uoversiktlig + +## Sjekkliste + +### 1. CLAUDE.md — stemmer instruksjonene? +- [ ] Er stack-beskrivelsen oppdatert? +- [ ] Er doc-treet komplett (alle filer i `docs/` er listet)? +- [ ] Er reglene fortsatt relevante? +- [ ] Finnes det nye konvensjoner som bør inn? + +### 2. Docs vs virkelighet +- [ ] Gå gjennom `docs/concepts/` — stemmer beskrivelsene med hva som finnes i koden? +- [ ] Gå gjennom `docs/features/` — er det features beskrevet som ikke er påbegynt? Marker dem. +- [ ] Gå gjennom `docs/infra/` — stemmer infrastruktur-docs med `docker-compose.dev.yml` og prod? +- [ ] Gå gjennom `docs/setup/` — fungerer oppsettinstruksjonene fortsatt? +- [ ] Gå gjennom `docs/retninger/` — er tesene fortsatt relevante? Har noen modnet til beslutninger? +- [ ] Er det docs som refererer til filer, routes eller komponenter som ikke eksisterer? + +### 3. Kode-hygiene +- [ ] Ubrukte SvelteKit-routes (mapper i `web/src/routes/` uten innhold eller med stub) +- [ ] Ubrukte komponenter (filer i `web/src/lib/components/` som ikke importeres) +- [ ] Ubrukte Rust-moduler i worker +- [ ] Ubrukte SpacetimeDB-reducere eller tabeller +- [ ] Gamle migrations som bør dokumenteres eller konsolideres +- [ ] `package.json` / `Cargo.toml` — ubrukte dependencies + +### 4. Fremdriftsstatus +- [ ] Hva er faktisk implementert og fungerer? +- [ ] Hva er påbegynt men ufullstendig? +- [ ] Hva er kun planlagt (kun docs)? +- [ ] Oppdater en kort statusoversikt (kan legges i `ops/status.md` ved behov) + +### 5. Asynkron tilstand — prod vs lokal vs docs +- [ ] Stemmer `docker-compose.dev.yml` med det som faktisk kjøres lokalt? +- [ ] Er prod-server oppdatert med siste push? +- [ ] Er det migrasjoner som er kjørt lokalt men ikke i prod (eller omvendt)? +- [ ] Er miljøvariabler (.env) synkronisert mellom miljøer? + +### 6. CLAUDE.md minne +- [ ] Gå gjennom `~/.claude/projects/-home-vegard-server/memory/MEMORY.md` +- [ ] Fjern utdaterte minner +- [ ] Oppdater minner som har blitt unøyaktige +- [ ] Er det ny kunnskap fra nylige samtaler som bør lagres? + +### 7. Erfaringslogg +- [ ] Er det gjort arbeid nylig som mangler erfaringsdokumentasjon i `docs/erfaringer/`? +- [ ] Er eksisterende erfaringsdokumenter fortsatt relevante og korrekte? + +### 8. dev.sh og utviklermiljø +- [ ] Fungerer `./dev.sh` fra scratch? +- [ ] Er alle nødvendige tjenester dekket? +- [ ] Er det nye quirks eller workarounds som bør inn i scriptet? + +## Sist kjørt + +_Ikke kjørt ennå._ diff --git a/reference/Editor.v1.svelte b/reference/Editor.v1.svelte new file mode 100644 index 0000000..7ec284b --- /dev/null +++ b/reference/Editor.v1.svelte @@ -0,0 +1,741 @@ + + + +
+ {#if mode === 'extended' || expanded} +
+ {#if !rawMode} + + + + + + + + + + + + + + + {/if} + +
+ {/if} + +
+ {#if rawMode} + + {:else} +
+ {/if} + + {#if mode === 'compact' && !expanded} + + + {/if} +
+ + {#if mode === 'compact' && expanded} +
+ + +
+ {/if} + + + {#if suggestions.length > 0 && mentionPopupPos} +
+ {#each suggestions as entity, i (entity.id)} + + {/each} +
+ {/if} +
+ + diff --git a/reference/server-state.md b/reference/server-state.md new file mode 100644 index 0000000..281ad7a --- /dev/null +++ b/reference/server-state.md @@ -0,0 +1,73 @@ +# Server-tilstand ved v1→v2 overgang (mars 2025) + +Denne filen dokumenterer hva som kjører på serveren ved overgangen. +Slettes når v2 er oppe og stabilt. + +## Kjørende containere (docker-compose) + +| Container | Image | Rolle | Beholdes? | +|---|---|---|---| +| postgres | postgres:16 | Database (sidelinja, authentik, forgejo) | JA | +| caddy | caddy:2 | Reverse proxy, HTTPS | JA | +| authentik-server | goauthentik/server:latest | SSO | JA | +| authentik-worker | goauthentik/server:latest | SSO bakgrunn | JA | +| redis | redis:7-alpine | Cache for Authentik | JA | +| forgejo | forgejo:10 | Git | JA | +| spacetimedb | clockworklabs/spacetime:latest | Sanntid | FJERNES (tom v1-modul) | +| web | sidelinja-web (bygget) | SvelteKit v1 | FJERNES | +| worker | sidelinja-worker (bygget) | Rust worker v1 | FJERNES | +| ai-gateway | litellm:main-stable | AI-ruter | BEHOLDES (uavhengig av app) | + +## Caddyfile (fungerende) +- auth.sidelinja.org → authentik-server:9000 +- git.sidelinja.org → forgejo:3000 +- sidelinja.org → web:3000 + /media/* (filservering) + JSON access log +- vegard.info → placeholder +- rt.sidelinja.org → spacetimedb:3000 + +## PG init-script +`/srv/sidelinja/config/postgres/init/01-create-databases.sql` +Oppretter authentik og forgejo DB-brukere med hardkodede passord. +Disse passordene matcher .env-variablene og er allerede kjørt — scriptet +kjøres kun ved *første* PG-oppstart. + +## Dockerfiles (referanse) +### web/Dockerfile +- node:20-alpine, to-stegs bygg +- VITE_SPACETIMEDB_URL som build-arg +- `npm run build` → `node build` på port 3000 + +### worker/Dockerfile +- rust:1-bookworm, to-stegs bygg med dependency-caching +- debian:bookworm-slim runtime med ca-certificates +- Binary: sidelinja-worker + +## .env-variabler på server +AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET, AUTHENTIK_ISSUER, +AUTHENTIK_POSTGRESQL_HOST, AUTHENTIK_POSTGRESQL_NAME, AUTHENTIK_POSTGRESQL_PASSWORD, +AUTHENTIK_POSTGRESQL_USER, AUTHENTIK_SECRET_KEY, AUTH_SECRET, +COMPOSE_PROJECT_NAME, DATABASE_URL, DOMAIN_AUTH, DOMAIN_SIDELINJA, DOMAIN_VEGARD, +FORGEJO_DB_PASSWD, LITELLM_MASTER_KEY, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, +OPENROUTER_API_KEY, POSTGRES_DB, POSTGRES_PASSWORD, POSTGRES_USER + +## Migrasjoner (v1, 15 stk) +0001-0015: initial_schema, kanban, calendar, notes, meldingsboks, +warmup_config, ai_config, ai_prompts, provider_extra_params, openrouter_only, +api_keys_toggle, api_keys_values, drop_priority_unique, workspace_ai_prompts, +usage_action_column. Pluss seed_dev.sql. + +v2 starter med nytt skjema (noder+edges). Disse migreringene er irrelevante. + +## Crontab +Ingen crontab konfigurert (backup-strategi er dokumentert men ikke implementert). + +## Mappestruktur /srv/sidelinja/ +``` +config/authentik/ config/caddy/ config/postgres/init/ +data/authentik/ data/caddy/ data/forgejo/ data/postgres/ data/redis/ data/spacetimedb/ +logs/caddy/ +media/ (podcast-filer) +server/ (git-klon av v1-repoet) +docker-compose.yml +.env +``` diff --git a/scripts/summary.sh b/scripts/summary.sh new file mode 100755 index 0000000..8ab4eea --- /dev/null +++ b/scripts/summary.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Samler all prosjektdokumentasjon til én fil for deling med AI-er etc. +# Bruk: ./scripts/summary.sh → skriver scripts/summary.md +# ./scripts/summary.sh - → skriver til stdout (for piping) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUT="$SCRIPT_DIR/summary.md" + +files=( + # Overblikk — visjon og retning først + "$ROOT/docs/arkitektur.md" + "$ROOT/CLAUDE.md" + "$ROOT"/docs/retninger/*.md + + # Primitiver + "$ROOT"/docs/primitiver/*.md + + # Infrastruktur + "$ROOT"/docs/infra/*.md + + # Erfaringer + "$ROOT"/docs/erfaringer/*.md +) + +collect() { + for f in "${files[@]}"; do + [[ -f "$f" ]] || continue + rel="${f#"$ROOT/"}" + echo "================================================================" + echo "FILE: $rel" + echo "================================================================" + echo "" + cat "$f" + echo "" + echo "" + done +} + +if [[ "${1:-}" == "-" ]]; then + collect +else + collect > "$OUT" + echo "Wrote $OUT ($(wc -l < "$OUT") lines)" +fi