From 290a1e398d8635f116883aba5ebefa7301fc9772 Mon Sep 17 00:00:00 2001 From: vegard Date: Fri, 13 Mar 2026 04:51:26 +0100 Subject: [PATCH] Initial commit: arkitekturdokumentasjon og feature-specs --- ARCHITECTURE.md | 133 +++++++++ CLAUDE.md | 36 +++ docs/features/ai_research_klipper.md | 19 ++ docs/features/api_grensesnitt.md | 73 +++++ docs/features/jobbkø.md | 87 ++++++ docs/features/kunnskapsgraf_og_relasjoner.md | 134 +++++++++ docs/features/live_ai_assistent.md | 19 ++ docs/features/podcast_statistikk.md | 19 ++ docs/features/podcastfabrikken.md | 33 +++ docs/features/produktivitetssuite.md | 22 ++ docs/features/synkronisering.md | 63 ++++ docs/features/valgomat.md | 16 + docs/setup/lokal.md | 224 ++++++++++++++ docs/setup/produksjon.md | 292 +++++++++++++++++++ 14 files changed, 1170 insertions(+) create mode 100644 ARCHITECTURE.md create mode 100644 CLAUDE.md create mode 100644 docs/features/ai_research_klipper.md create mode 100644 docs/features/api_grensesnitt.md create mode 100644 docs/features/jobbkø.md create mode 100644 docs/features/kunnskapsgraf_og_relasjoner.md create mode 100644 docs/features/live_ai_assistent.md create mode 100644 docs/features/podcast_statistikk.md create mode 100644 docs/features/podcastfabrikken.md create mode 100644 docs/features/produktivitetssuite.md create mode 100644 docs/features/synkronisering.md create mode 100644 docs/features/valgomat.md create mode 100644 docs/setup/lokal.md create mode 100644 docs/setup/produksjon.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..d4109de --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,133 @@ +# Sidelinja - Architecture Decision Record & System Overview + +**Dette dokumentet definerer den overordnede arkitekturen, teknologistacken og datamodellen for Sidelinja-suiten. AI-agenter (som Claude Code) SKAL lese og forstå dette dokumentet før de foreslår endringer, skriver kode eller gjør arkitektoniske valg.** + +## 1. Visjon og Konsept +Sidelinja er ikke bare en podcast-host; det er et **redaksjonelt operativsystem** og en **kunnskapsgraf**. Målet er å bygge en plattform som sømløst integrerer research, asynkron kommunikasjon (chat), sanntids innspilling (Lyd/Video) og automatisert publisering. Visjonen inkluderer også at plattformen skal fungere som en "live co-host" (virtuell assistent) under innspilling ved å boble opp relevant informasjon fra kunnskapsgrafen i sanntid. Systemet er bygget for full datakontroll, eierskap og minimal bruk av lukkede tredjepartstjenester. + +## 2. Infrastruktur og DevOps +* **Produksjonsserver:** Hetzner VPS (Ubuntu, 8 vCPU, 16 GB RAM). Kapasiteten er tilstrekkelig for nåværende behov. Ved behov kan VPS-en dobles (16 vCPU, 32 GB). Mest CPU-krevende tjenester er faster-whisper og LiveKit under samtidig bruk — disse bør overvåkes først ved kapasitetsproblemer. +* **Orkestrering:** Docker / Docker Compose. Alle tjenester kjører i isolerte containere på et internt Docker-nettverk. +* **Reverse Proxy & Webserver:** **Caddy**. Håndterer all innkommende trafikk for flere domener, automatisk HTTPS (Let's Encrypt), og ruting til interne containere. Port 80/443 er de *eneste* portene som er eksponert mot internett. +* **Domener:** + - `sidelinja.org` — Hovedapplikasjon (SvelteKit, media, SpacetimeDB, LiveKit) + - `auth.sidelinja.org` — Authentik SSO (felles for alle domener) + - `git.sidelinja.org` — Forgejo + - `vegard.info` — Separat nettsted, deler SSO med Sidelinja +* **Kildekode og CI/CD:** **Forgejo** (Selv-hostet Git). All kode og konfigurasjon lever her. +* **Utvikling og Utrulling (Claude Code Workflow):** All prototyping og koding skjer lokalt i **WSL2 (Ubuntu)** på en lokal Windows 11-maskin. Når koden er testet og klar, pusher AI-agenten (Claude Code) til Forgejo. Deretter logger agenten seg på produksjonsserveren via SSH for å hente koden, trigge kompilering og starte tjenestene på nytt. Regelen "ikke programmere i produksjon" betyr utelukkende at redigering av kildekode og "prøving og feiling" hører hjemme lokalt, ikke live på serveren. + +### 2.1 Serverstruktur og Backup-strategi (Produksjon) +All persistent data, konfigurasjon og kildekode monteres via Docker Bind Mounts til en fast struktur på vertssystemet, typisk `/srv/sidelinja/`. Dette muliggjør granulert backup. + + /srv/sidelinja/ + ├── docker-compose.yml # Orkestrering + ├── .env # Miljøvariabler (IKKE i Git) + ├── config/ # Konfigurasjonsfiler (Caddy, Authentik, etc.) + ├── data/ # Databaser (Postgres, SpacetimeDB, Forgejo) -> KRITISK BACKUP + ├── media/ # Lydfiler (podcast, råopptak) -> KRITISK BACKUP + └── logs/ # Caddy access-logger, app-logger -> SEPARAT/LANGTIDS BACKUP + +*Målrettet backup:* Mappen `logs/` ekskluderes fra den daglige snapshot-backupen for å spare plass, men rulleres og arkiveres separat for fremtidig dataanalyse. + +### 2.2 Lokalt Utviklingsmiljø (Dev Replika) +For å sikre smidig lokal utvikling i WSL2, bygger vi en nøyaktig replika av produksjonsmiljøet, men optimalisert for utviklingshastighet (Hot Reloading). **Komplett steg-for-steg oppsett finnes i `docs/setup/lokal.md`, produksjonsoppsett i `docs/setup/produksjon.md`.** + +* **Docker Compose Dev:** Vi bruker en egen `docker-compose.dev.yml` som spinner opp lokale instanser av databasene (PostgreSQL, SpacetimeDB) og LiveKit. Volumene for disse er lokale og flyktige/seedede. +* **SvelteKit HMR:** SvelteKit-klienten kjøres *utenfor* Docker under aktiv utvikling (ved bruk av `npm run dev` i WSL2). Dette sikrer at Hot Module Replacement (HMR) fungerer lynraskt når kode endres. +* **Lokal Ruting:** En lokal Caddy-instans ruter trafikk fra `localhost` til SvelteKit, SpacetimeDB og LiveKit, med self-signed sertifikater (`local_certs`) for sikker kontekst (WebRTC). +* **Forgejo:** Kjører *ikke* lokalt. Push direkte til produksjons-Forgejo fra WSL2. + +## 3. Teknologistack +Vi følger et "Best tool for the job"-prinsipp, med en sterk preferanse for minnesikkerhet, ytelse og rene grensesnitt. + +* **Backend/Automasjon:** **Rust**. Brukes som bakgrunnsworkers (jobbkø), logg-parsing og SpacetimeDB-moduler. Rust er *ikke* en API-server — SvelteKit server-side håndterer all HTTP-kommunikasjon og PG-tilgang direkte (se `docs/features/api_grensesnitt.md`). +* **Frontend / UI:** **SvelteKit** (med TypeScript). Bygges som en PWA. Valgt for ytelse og enkel integrasjon med WebRTC og vanilla JS-biblioteker. +* **Sanntids Lyd/Video:** **LiveKit** (Selv-hostet). Håndterer WebRTC, fler-bruker videochat og opptak i det virtuelle "studioet". +* **AI / Prosessering:** `faster-whisper` (lokal transkripsjon) og OpenRouter (Claude-modeller for tekstanalyse). +* **SSO / Autentisering:** **Authentik** (Selv-hostet). Sentralisert rollestyring. + +## 4. Den To-delte Databasestrategien +1. **PostgreSQL (Historikk & Kunnskapsgraf):** Én sentralisert instans i Docker for brukerkontoer, Git-metadata, aggregert statistikk og Kunnskapsgrafen (Artikler, Faktoider). +2. **SpacetimeDB (Sanntid & Arbeidsflyt):** In-memory database for live chat, status på episoder, og live-oppdateringer i studio. Klienten (Svelte) lytter direkte på SpacetimeDB. Strategisk avhengighet, men all persistent data synkes til PostgreSQL — ved eventuelt bortfall kan sanntidslaget erstattes uten tap av data. +3. **Synkronisering:** Event-drevet med ~5 sek forsinkelse. SpacetimeDB er autoritativ for sanntidsdata (chat, kanban), PostgreSQL for persistent data (kunnskapsgraf, metadata). Detaljer i `docs/features/synkronisering.md`. + +## 5. Datamodell: Kunnskapsgrafen +Systemet er bygget rundt **Temaer** og **Aktører**, ikke episoder. Dette bygger et asynkront research-arkiv. Alle entiteter arver UUID fra en felles `nodes`-supertabell som gir ekte FK-integritet i grafmodellen (detaljer i `docs/features/kunnskapsgraf_og_relasjoner.md`). +* **Tema (Saker):** Levende konsepter ("Skolepolitikk"). +* **Aktør (Entity):** Personer eller organisasjoner ("Jonas Gahr Støre"). +* **Faktoide (Factoid):** En atomisk bit med informasjon koblet til Aktører/Temaer ("Søkte jobb i AP i 2011"). +* **Episode:** Et tidsbegrenset prosjekt ("Episode 42") som samler et utvalg av aktuelle *Temaer*. +* **Segment:** En tidsavgrenset del av en episode med egen transkripsjon, koblet til Temaer/Aktører i grafen. Muliggjør presise oppslag ("hva sa vi om X i Episode 42?"). +* **Research-klipp:** Råtekst renset av AI, koblet til Temaer/Aktører. + +**Transkripsjoner:** Git (Forgejo) er kilde til sannhet (redigerbar, sporbar). PostgreSQL er søkeindeks (full-text, koblet til grafen). Endringer i Git reimporteres automatisk via Forgejo webhook. + +## 6. Podcast Hosting og Distribusjon +* **Lagring:** MP3-filer lagres flatt i `/srv/sidelinja/media/`. Ingen lydfiler i databaser. +* **Servering:** Caddy serverer media-mappen. MÅ ha `Accept-Ranges: bytes` aktivert for podcast-streaming. +* **RSS-Feed:** Genereres av SvelteKit og leveres statisk eller dynamisk med aggressiv caching. + +## 7. Planlagte Funksjoner (Feature Ideas) +Dette er hovedkonseptene plattformen skal støtte. **Merk: Detaljerte tekniske spesifikasjoner, flytskjemaer og datastrukturer for hver av disse ligger i mappen `docs/features/`.** + +* **Live AI-Assistent i Studio:** Sanntidstranskripsjon via mikrofonene som lytter etter nøkkelord (Named Entity Recognition). Gjør asynkrone oppslag i PostgreSQL og dytter relevante "Faktoider" live til Svelte-grensesnittet via SpacetimeDB mens programlederne snakker. +* **AI Research-Klipper ("Ctrl+A workflow"):** Et verktøy der redaksjonen limer inn rotete nyhetsartikler. AI-en (OpenRouter) renser, oppsummerer, og trekker ut Aktører og Faktoider som lagres i Kunnskapsgrafen. +* **Produktivitetssuiten:** En Svelte/SpacetimeDB-basert flate for Kanban-styring av episoder, trådet chat knyttet til Temaer, og kollaborative show notes. +* **Valgomat:** En publikumsrettet, avansert og interaktiv valgomat drevet av SpacetimeDB for umiddelbar respons og vekting av svar. +* **Podcast-Statistikk (Privacy First):** Batch-prosessering i Rust som tygger Caddy JSON-logger, dedupliserer lyttere, fjerner bots og lagrer ferdig statistikk i PostgreSQL. +* **Podcastfabrikken:** System for automatisk og manuell publisering (Whisper transkripsjon, metadata via OpenRouter) og versjonshåndtering/cache-busting ved oppdatering av eksisterende episoder. + +## 8. Bygge-rekkefølge (Avhengighetskart) + +``` +Lag 1 — Fundament (ingen avhengigheter): + ├── PostgreSQL-skjema (nodes, graph_edges, job_queue) + ├── SpacetimeDB grunnoppsett + └── SvelteKit skjelett med Authentik-integrasjon + +Lag 2 — Kjernekomponenter (krever Lag 1): + ├── Jobbkø-worker (Rust) + ├── Kunnskapsgraf CRUD (SvelteKit server-side) + └── Produktivitetssuiten: Chat + Kanban (SpacetimeDB ↔ PG synk) + +Lag 3 — Features (krever Lag 2): + ├── AI Research-Klipper (kunnskapsgraf + jobbkø) + ├── Podcastfabrikken (jobbkø + episoder/segmenter) + └── Podcast-Statistikk (jobbkø + episoder) + +Lag 4 — Avansert (krever Lag 3): + ├── Live AI-Assistent (fylt kunnskapsgraf + LiveKit + Whisper) + └── Valgomat (selvstendig, lav prioritet) +``` + +## 9. Observabilitet + +### 9.1 Helse +Alle Docker-containere skal ha `healthcheck` definert i `docker-compose.yml`: +- PostgreSQL: `pg_isready` +- SpacetimeDB: TCP-sjekk mot intern port +- Caddy: `curl -f http://localhost/health` +- SvelteKit: `curl -f http://localhost:3000/health` +- Rust Workers: Heartbeat-rad i `job_queue` (en `worker_heartbeat`-jobb som re-enqueuer seg selv hvert minutt — fravær betyr død worker) + +### 9.2 Logging +- **Format:** Strukturert JSON fra alle komponenter (Rust, SvelteKit, Caddy) +- **Plassering:** `/srv/sidelinja/logs/` med undermapper per tjeneste +- **Rotasjon:** Standard Linux logrotate, daglig rotasjon, 30 dagers retensjon +- Caddy podcast-logger behandles separat av statistikk-workeren (se `docs/features/podcast_statistikk.md`) + +### 9.3 Jobbkø-overvåking +- Admin-visning i SvelteKit som viser `job_queue`-status (pending, running, error-count) +- Feilede jobber (`status = 'error'`) poster automatisk en varslingsmelding til et dedikert system-tema i Produktivitetssuiten (intern chat), slik at redaksjonen ser det i sin daglige arbeidsflate + +### 9.4 Ingen eksterne tjenester +All overvåking og varsling skjer internt i Sidelinja-suiten. Ingen avhengighet til Discord, Slack eller andre tredjepartstjenester. + +## 10. AI Agent Guidelines (Instrukser for Claude Code) +* **Start her:** Når du settes til å bygge en ny komponent, sikre alltid at det lokale utviklingsmiljøet (`docker-compose.dev.yml`) kjører først. +* **Dokumentasjonsstandard:** Når du skal implementere en ny funksjon (feature), sjekk ALLTID om det finnes et dokument i `docs/features/.md` først. Oppdater disse dokumentene hvis arkitekturen for funksjonen endres. +* **Ingen "gh" CLI:** Vi bruker Forgejo. For Pull Requests/Issues, bruk `tea` CLI. +* **Deployment:** Kod og test lokalt i WSL. Push til Forgejo, logg inn via SSH for å pulle kode og restarte containere/tjenester (`docker compose up -d`). +* **Asynkron AI:** Tyngre jobber (Whisper, OpenRouter) skal aldri blokkere web-forespørsler. Alle bakgrunnsjobber kjøres via den felles PostgreSQL-baserte jobbkøen (se `docs/features/jobbkø.md`). +* **Sikkerhet:** Forsøk aldri å eksponere databaseporter ut mot internett i Docker Compose-filer (hverken lokalt eller i prod). Port 80/443 (Caddy) er de eneste inngangsportene. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..91da9e2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,36 @@ +# Sidelinja - Claude Code Prosjektguide + +## Prosjektoversikt +Sidelinja er et redaksjonelt operativsystem og kunnskapsgraf for podcast-produksjon. +Self-hosted på Hetzner VPS med full datakontroll. + +## Nøkkelfiler +- `ARCHITECTURE.md` — Overordnet arkitektur, stack, datamodell og infrastruktur +- `docs/setup/produksjon.md` — Steg-for-steg oppsett av Hetzner VPS fra scratch +- `docs/setup/lokal.md` — Steg-for-steg oppsett av lokalt WSL2 utviklingsmiljø +- `docs/features/` — Detaljerte feature-spesifikasjoner: + - `kunnskapsgraf_og_relasjoner.md` — Nodes & Edges-modell i PostgreSQL + - `ai_research_klipper.md` — AI-drevet research-inntak til kunnskapsgrafen + - `live_ai_assistent.md` — Sanntids faktoid-oppslag under innspilling + - `produktivitetssuite.md` — Kanban, chat, show notes (SpacetimeDB-tung) + - `podcastfabrikken.md` — Publiseringspipeline (Whisper + OpenRouter + RSS) + - `podcast_statistikk.md` — IAB-kompatibel lytterstatistikk fra Caddy-logger + - `valgomat.md` — Publikumsrettet valgomat (SpacetimeDB) + - `jobbkø.md` — Felles PostgreSQL-basert køsystem for alle bakgrunnsjobber + - `synkronisering.md` — PostgreSQL ↔ SpacetimeDB dataflyt og eierskapsmodell + - `api_grensesnitt.md` — Kommunikasjonskart: SvelteKit er web-API, Rust er worker + +## Stack +- **Backend/Automasjon:** Rust +- **Frontend:** SvelteKit (TypeScript, PWA) +- **Sanntid:** SpacetimeDB (arbeidsflyt/state) + LiveKit (lyd/video) +- **Database:** PostgreSQL (persistent/kunnskapsgraf) + SpacetimeDB (in-memory/sanntid) +- **AI:** faster-whisper (transkripsjon), OpenRouter (Claude-modeller) +- **Infra:** Docker Compose, Caddy, Authentik (SSO), Forgejo (Git) + +## Viktige regler +- Aldri eksponere databaseporter mot internett (kun port 80/443 via Caddy) +- Bruk `tea` CLI, ikke `gh` (vi bruker Forgejo, ikke GitHub) +- Tunge AI-jobber (Whisper, OpenRouter) skal aldri blokkere web-requests +- Kod og test lokalt i WSL2, deploy via push til Forgejo + SSH pull +- Sjekk alltid `docs/features/.md` før du implementerer en feature diff --git a/docs/features/ai_research_klipper.md b/docs/features/ai_research_klipper.md new file mode 100644 index 0000000..73619cf --- /dev/null +++ b/docs/features/ai_research_klipper.md @@ -0,0 +1,19 @@ +# Feature Spec: AI Research-Klipper ("Ctrl+A Workflow") +**Filsti:** `docs/features/ai_research_klipper.md` + +## 1. Konsept +Et internt redaksjonelt verktøy for å samle inn research fra nettet. Programlederne limer inn uformatert tekst (ofte med menyer, annonser og støy fra "Ctrl+A"-kopiering), og en AI renser teksten og trekker ut strukturert kunnskap. + +## 2. Arkitektur & Dataflyt +1. **Input (SvelteKit):** En modal i grensesnittet der brukeren limer inn råtekst og valgfri kilde-URL, og knytter det til et *Tema* (f.eks. "Skolepolitikk"). +2. **Prosessering (Jobbkø + OpenRouter):** + * Backend mottar teksten og oppretter en `research_clip`-jobb i jobbkøen (se `docs/features/jobbkø.md`). Rust-workeren plukker opp jobben og sender request til OpenRouter (Claude-modell). + * **System Prompt:** Skal instruere AI-en til å returnere JSON med følgende struktur: + `{ "title": "...", "summary": ["..."], "cleaned_text": "...", "actors": ["..."], "factoids": ["..."] }` +3. **Lagring (PostgreSQL):** Backend lagrer resultatet relasjonelt i Kunnskapsgrafen. *Aktører* som ikke finnes opprettes. *Faktoider* kobles til aktørene. Selve artikkelen knyttes til det valgte *Temaet*. +4. **Broadcast (SpacetimeDB):** + Når lagringen er ferdig, sendes et signal via SpacetimeDB slik at chatten/tema-visningen oppdateres hos alle innloggede brukere med et "Kort" som viser det nye sammendraget. + +## 3. Instruks for Claude Code +* Sørg for at OpenRouter API-kallet forventer og validerer streng JSON-struktur. +* Lagringen i PostgreSQL må håndtere "upserts" for Aktører elegant, slik at vi ikke får duplikater av f.eks. "Arbeiderpartiet". \ No newline at end of file diff --git a/docs/features/api_grensesnitt.md b/docs/features/api_grensesnitt.md new file mode 100644 index 0000000..7aaa88e --- /dev/null +++ b/docs/features/api_grensesnitt.md @@ -0,0 +1,73 @@ +# Feature Spec: API-grensesnitt og Tjenesteansvar +**Filsti:** `docs/features/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/features/jobbkø.md b/docs/features/jobbkø.md new file mode 100644 index 0000000..f4fc07b --- /dev/null +++ b/docs/features/jobbkø.md @@ -0,0 +1,87 @@ +# Feature Spec: Jobbkø (PostgreSQL-basert) +**Filsti:** `docs/features/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(), + 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) + +``` +┌─────────────────────────────────────────────┐ +│ Rust Worker-prosess (én per jobbtype) │ +│ │ +│ Loop: │ +│ 1. SELECT ... FOR UPDATE SKIP LOCKED │ +│ WHERE status IN ('pending','retry') │ +│ AND job_type = $type │ +│ AND scheduled_for <= now() │ +│ ORDER BY priority DESC, scheduled_for │ +│ LIMIT 1 │ +│ │ +│ 2. UPDATE status = 'running' │ +│ 3. Utfør jobben │ +│ 4a. OK: UPDATE status = 'completed' │ +│ 4b. Feil: attempts += 1 │ +│ Hvis attempts < max_attempts: │ +│ status = 'retry' │ +│ scheduled_for = now() │ +│ + backoff(attempts) │ +│ Ellers: status = 'error' │ +│ │ +│ Poll-intervall: 1 sekund (konfigurerbart) │ +└─────────────────────────────────────────────┘ +``` + +**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 | +| `research_clip` | AI Research-Klipper | Rens og strukturer innlimt tekst | +| `stats_parse` | Podcast-Statistikk | Batch-prosesser Caddy-logger | + +## 6. Observabilitet +- Jobber med `status = 'error'` skal være synlige i Produktivitetssuiten (enkel admin-visning) +- Valgfritt: SpacetimeDB-event ved statusendring slik at UI kan vise fremdrift i sanntid (f.eks. "Transkriberer... 2/3 forsøk") + +## 7. Instruks for Claude Code +- Implementer worker-logikken som et Rust-bibliotek (`sidelinja-jobs`) som de ulike binærene kan bruke +- Hver jobbtype får sin egen handler-funksjon, men deler polling-loopen +- Unngå å spinne opp mange tråder — én tokio-task per jobbtype er tilstrekkelig +- Aldri lagre lydfiler i `payload` — bruk filstier +- Ved `stats_parse`: denne erstatter den frittstående cronjobben beskrevet i podcast_statistikk.md — bruk jobbkøen med `scheduled_for` for periodisk kjøring diff --git a/docs/features/kunnskapsgraf_og_relasjoner.md b/docs/features/kunnskapsgraf_og_relasjoner.md new file mode 100644 index 0000000..278de7d --- /dev/null +++ b/docs/features/kunnskapsgraf_og_relasjoner.md @@ -0,0 +1,134 @@ +# 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 ( + 'tema', 'aktør', 'faktoide', 'episode', 'segment', 'melding' +); + +CREATE TABLE nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_type node_type NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +### 3.2 Detailtabeller +Hver nodetype har sin egen tabell med FK til `nodes`. Eksempler: + +```sql +CREATE TABLE actors ( + id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT -- 'person', 'organisasjon', etc. +); + +CREATE TABLE topics ( + id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, + name TEXT NOT NULL +); + +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: + +```sql +CREATE TABLE graph_edges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + target_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + relation_type TEXT NOT NULL, -- 'MENTIONS', 'CONTRADICTS', 'WORKS_FOR', 'PART_OF', 'DISCUSSED_IN' + context_id UUID REFERENCES nodes(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT no_self_reference CHECK (source_id != target_id) +); + +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); +``` + +## 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/features/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 +* **`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. +* **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. +* **Full-text search:** Bruk `to_tsvector('norwegian', transcript)` for norsk språkstøtte i søk. diff --git a/docs/features/live_ai_assistent.md b/docs/features/live_ai_assistent.md new file mode 100644 index 0000000..c15d694 --- /dev/null +++ b/docs/features/live_ai_assistent.md @@ -0,0 +1,19 @@ +# Feature Spec: Live AI-Assistent i Studio +**Filsti:** `docs/features/live_ai_assistent.md` + +## 1. Konsept +En "virtuell co-host" som lytter på innspillingen i sanntid. Når programlederne nevner spesifikke personer eller organisasjoner, slår systemet opp i Kunnskapsgrafen og dytter relevante "Faktoider" til skjermen deres umiddelbart. + +## 2. Arkitektur & Dataflyt +Denne funksjonen krever lav forsinkelse og asynkron prosessering. + +1. **Lydkilde (SvelteKit + LiveKit):** SvelteKit-appen bruker `livekit-client`. I tillegg til å sende høykvalitetslyd til de andre deltakerne, rutes en komprimert lydstrøm (via WebSockets eller LiveKit sine egne server-side hooks) til en lokal Rust-tjeneste. +2. **Transkripsjon (Rust + Whisper):** Rust-tjenesten mater lyden inn i `faster-whisper` (eller et tilsvarende raskt API) i små chunks. Den spytter ut en kontinuerlig tekststrøm. +3. **Entity Extraction & Oppslag (Rust + PostgreSQL):** Rust-skriptet analyserer tekststrømmen for egennavn (Named Entity Recognition). Den gjør et lynraskt asynkront oppslag i PostgreSQL: `SELECT * FROM factoids JOIN actors... WHERE actor.name = $1`. +4. **Sanntids-Push (SpacetimeDB):** Hvis et treff finnes, dytter Rust-skriptet faktoiden inn i SpacetimeDB som et event: `LiveFactoidEvent`. +5. **Visning (SvelteKit):** Studio-grensesnittet lytter på SpacetimeDB. Når `LiveFactoidEvent` inntreffer, popper faktoiden lydløst opp i en egen boks på skjermen. + +## 3. Utviklingsfaser (For Claude Code) +* **Fase 1:** Ikke bygg live-lyd enda. Bygg funksjonaliteten der grensesnittet lytter på SpacetimeDB, og lag et dummy-script i Rust som dytter test-faktoider inn i SpacetimeDB for å verifisere UI-et. +* **Fase 2:** Koble Whisper til et offline lydopptak og kjør NER/oppslag mot PostgreSQL. +* **Fase 3:** Koble sammen LiveKit-strømmen og Whisper. \ No newline at end of file diff --git a/docs/features/podcast_statistikk.md b/docs/features/podcast_statistikk.md new file mode 100644 index 0000000..54a3cd1 --- /dev/null +++ b/docs/features/podcast_statistikk.md @@ -0,0 +1,19 @@ +# 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/features/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 `date`, `episode_id`, `client_name`, `unique_downloads`). + +## 3. 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. \ No newline at end of file diff --git a/docs/features/podcastfabrikken.md b/docs/features/podcastfabrikken.md new file mode 100644 index 0000000..329d258 --- /dev/null +++ b/docs/features/podcastfabrikken.md @@ -0,0 +1,33 @@ +# Feature Spec: Podcastfabrikken (Lyd & Publiserings-Pipeline) +**Filsti:** `docs/features/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/features/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 trigger `faster-whisper` lokalt på serveren og genererer rå tekst med tidsstempler. +4. **AI-Analyse (OpenRouter):** Transkripsjonen sendes til OpenRouter (Claude-modell) for uttrekk av forslag til tittel, sammendrag, show notes og kapittler. +5. **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). +6. **Publisering (PostgreSQL):** Ved "Godkjenn" lagres metadataene permanent i databasen. +7. **RSS-Generering:** SvelteKit-appen genererer en oppdatert `/feed.xml`. + +## 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. 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. \ No newline at end of file diff --git a/docs/features/produktivitetssuite.md b/docs/features/produktivitetssuite.md new file mode 100644 index 0000000..e70c4a7 --- /dev/null +++ b/docs/features/produktivitetssuite.md @@ -0,0 +1,22 @@ +# Feature Spec: Produktivitetssuiten +**Filsti:** `docs/features/produktivitetssuite.md` + +## 1. Konsept +En asynkron og synkron arbeidsflate der Kunnskapsgrafen møter prosjektstyring. **Temaet** er hovedobjektet, ikke episoden. + +## 2. Datastrukturer og Ansvarsfordeling +Dette systemet bruker SpacetimeDB tungt for sanntidsopplevelsen. + +* **Tema-bassenget (PostgreSQL + SpacetimeDB):** Alle pågående "Saker". PostgreSQL er kilden til sannhet (langtidslagring), mens SpacetimeDB holder de aktive temaene i minnet slik at chat og oppdateringer skjer uten page-reloads. +* **Trådet Chat (SpacetimeDB):** Meldinger sendes via SpacetimeDB. Hver melding tilhører et `Tema`. Meldinger kan ha en `parent_message_id` for å skape tråder. +* **Sanntids Autocomplete & Mentions (Mobil-optimalisert):** + * Siden SpacetimeDB synkroniserer data til klienten, skal Svelte-grensesnittet ha umiddelbar autocomplete på tekstfeltet. + * Trigger-tegn: `/` (for kommandoer som `/oppgave`), `@` (for brukere/redaksjonsmedlemmer), og feks `#` (for Temaer/Aktører fra Kunnskapsgrafen). + * Skriver man `#Ha...` filtrerer Svelte-klienten umiddelbart den lokale SpacetimeDB-cachen og viser en klikkbar/tappbar liste (f.eks. "Hans Petter Sjøli", "Høyre"). Ved trykk settes hele navnet inn, og det lenkes automatisk opp i databasen. +* **Kanban / Kjøreplan (SpacetimeDB):** Opprettelse av en `Episode` fungerer som en container. Brukerne drar *Temaer* fra Tema-bassenget og inn i en Episodes Kjøreplan (Drag and Drop i SvelteKit). Posisjon/Rekkefølge synkroniseres til alle klienter via SpacetimeDB. +* **Kollaborative Show Notes:** Et tekstfelt koblet til et Tema. Enkle "Operational Transformation"-aktige oppdateringer (eller felt-låsing) håndteres i Rust-modulen til SpacetimeDB. +* **Live Studio-Markører ("Blooper-knapp"):** En funksjon i Svelte-studioet der brukere kan trykke på en knapp under innspilling. Dette fanger opp gjeldende opptakstid (timer/min/sek) og lagrer det i SpacetimeDB som et "Klippepunkt" koblet til episoden. + +## 3. Instruks for Claude Code +* Bruk SvelteKit for Drag-and-Drop grensesnitt. Unngå tunge biblioteker hvis native HTML5 Drag and Drop er tilstrekkelig. +* SpacetimeDB skal fungere som "State Manager". Frontend bør ikke ha kompleks lokal state (f.eks. Redux); den skal speile SpacetimeDB sin tilstand. \ No newline at end of file diff --git a/docs/features/synkronisering.md b/docs/features/synkronisering.md new file mode 100644 index 0000000..3be57b3 --- /dev/null +++ b/docs/features/synkronisering.md @@ -0,0 +1,63 @@ +# Feature Spec: PostgreSQL ↔ SpacetimeDB Synkronisering +**Filsti:** `docs/features/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. + +## 2. Strategi: Event-drevet med kort forsinkelse +SpacetimeDB-modulene (Rust) produserer persisterings-events ved dataendringer. En Rust-worker konsumerer disse og skriver til PostgreSQL, batched med ~5 sekunders vindu. + +**Akseptabelt datatap:** Maks 5 sekunder ved hard krasj av SpacetimeDB. Dette er akseptabelt for chat, kanban og show notes. + +## 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-modulene kaller en intern `emit_sync_event()`-funksjon ved relevante dataendringer +- Events bufres i en SpacetimeDB-tabell (`sync_outbox`) med tidsstempel og payload +- Rust-workeren poller `sync_outbox` hvert ~5 sekund, leser alle usynkede events, skriver til PostgreSQL i én transaksjon, og markerer dem som synket +- 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 (eller reconnect) av SpacetimeDB laster Rust-workeren aktive data fra PG: + - Aktive temaer med siste N chatmeldinger + - Kanban-state for pågående episoder + - Aktør/Tema-navn for autocomplete (read-only cache) +- Dette er en enveis-last, ikke kontinuerlig synk. Kunnskapsgrafen oppdateres i SpacetimeDB kun ved oppstart eller eksplisitt refresh + +## 6. Feilhåndtering +- **SpacetimeDB krasjer:** Data siden siste synk (~5 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. 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/features/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 diff --git a/docs/features/valgomat.md b/docs/features/valgomat.md new file mode 100644 index 0000000..0c44b19 --- /dev/null +++ b/docs/features/valgomat.md @@ -0,0 +1,16 @@ +# Feature Spec: Valgomat +**Filsti:** `docs/features/valgomat.md` + +## 1. Konsept +En publikumsrettet web-applikasjon for å hjelpe velgere med å finne partimatch. Må være lynrask, tåle høy trafikk (Spike-trafikk etter publisering av episode), og føles interaktiv ("gamified"). + +## 2. Arkitektur +For å gi en "instant" følelse, bypasses tradisjonell database-arkitektur under selve gjennomføringen. + +* **Logikk og Vekting (SpacetimeDB / Rust):** SpacetimeDB egner seg perfekt for Valgomaten. Databasen holder reglene (spørsmål, vekting per parti) i minnet. Rust-koden inne i SpacetimeDB (modulen) beregner resultatet umiddelbart når en bruker sender inn et svar. +* **Frontend (SvelteKit):** En ren, responsiv SvelteKit-klient (PWA) som kobler seg til SpacetimeDB via WebSockets. Svar klikkes, animasjoner vises, og neste spørsmål lastes uten noe nettverks-forsinkelse (fordi tilkoblingen holdes åpen). +* **Statistikk (PostgreSQL):** Aggregerte resultater ("70% av de under 30 i Oslo fikk SV") lagres periodisk over til PostgreSQL for langsiktig analyse og grafer til podcastepisodene. + +## 3. Instruks for Claude Code +* Implementer vektingen (algoritmen) som en Rust-funksjon (Reducer) inne i SpacetimeDB-modulen. +* Sørg for at brukere ikke må logge inn for å ta valgomaten (Anonym tilgang støttes i SpacetimeDB). \ No newline at end of file diff --git a/docs/setup/lokal.md b/docs/setup/lokal.md new file mode 100644 index 0000000..b27c173 --- /dev/null +++ b/docs/setup/lokal.md @@ -0,0 +1,224 @@ +# Oppsett: Lokalt Utviklingsmiljø (WSL2) +**Filsti:** `docs/setup/lokal.md` + +Denne oppskriften setter opp en lokal utviklingsreplika av Sidelinja i WSL2. Målet er at all utvikling og testing skjer her — aldri på produksjonsserveren. + +## 0. Forutsetninger +- Windows 11 med WSL2 (Ubuntu 24.04 LTS) +- Docker Desktop for Windows med WSL2-integrasjon aktivert +- Node.js 20+ (via nvm i WSL2) +- Rust toolchain (via rustup i WSL2) +- Git konfigurert med SSH-nøkkel mot produksjons-Forgejo + +## 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 + +# tea CLI (Forgejo) +# Installer fra: https://gitea.com/gitea/tea/releases +``` + +## 2. Klon prosjektet + +```bash +mkdir -p ~/server +cd ~/server +git clone ssh://git@sidelinja.no:222/sidelinja/sidelinja.git . +# Eller om repo allerede finnes lokalt, sett opp remote: +git remote add forgejo ssh://git@sidelinja.no:222/sidelinja/sidelinja.git +``` + +## 3. Lokal mappestruktur + +```bash +# Docker-volumer for databaser (flyktige, ikke backupes) +mkdir -p .docker-data/{postgres,spacetimedb} +mkdir -p .docker-data/media/podcast +mkdir -p .docker-data/logs/caddy +``` + +## 4. Miljøvariabler (.env.local) + +```bash +cat > .env.local << 'EOF' +# === Lokalt utviklingsmiljø === +DOMAIN=localhost +COMPOSE_PROJECT_NAME=sidelinja-dev + +# === PostgreSQL (lokale verdier, ikke hemmelige) === +POSTGRES_USER=sidelinja +POSTGRES_PASSWORD=localdev +POSTGRES_DB=sidelinja + +# === SpacetimeDB === +# Lokale defaults + +# === LiveKit (lokale test-nøkler) === +LIVEKIT_API_KEY=devkey +LIVEKIT_API_SECRET=devsecret + +# === OpenRouter (bruk egen nøkkel for AI-testing) === +OPENROUTER_API_KEY= +EOF +``` + +## 5. docker-compose.dev.yml + +Spinner opp kun infrastruktur-tjenestene. SvelteKit kjøres utenfor Docker for HMR. + +```yaml +# Fullstendig docker-compose.dev.yml bygges ut ved implementering. +# Struktur: + +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - ./.docker-data/postgres:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" # KUN localhost, aldri 0.0.0.0 + networks: + - sidelinja-dev + + spacetimedb: + image: clockworklabs/spacetimedb + volumes: + - ./.docker-data/spacetimedb:/var/lib/spacetimedb + ports: + - "127.0.0.1:3001:3000" # KUN localhost + networks: + - sidelinja-dev + + livekit: + image: livekit/livekit-server + ports: + - "127.0.0.1:7880:7880" # KUN localhost + networks: + - sidelinja-dev + + caddy: + image: caddy:2 + ports: + - "127.0.0.1:443:443" + - "127.0.0.1:80:80" + volumes: + - ./config/caddy/Caddyfile.dev:/etc/caddy/Caddyfile + - ./.docker-data/logs/caddy:/var/log/caddy + - ./.docker-data/media:/srv/media + networks: + - sidelinja-dev + +networks: + sidelinja-dev: + driver: bridge +``` + +## 6. Start infrastruktur + +```bash +# Start Docker-tjenestene +docker compose -f docker-compose.dev.yml --env-file .env.local up -d + +# Verifiser +docker compose -f docker-compose.dev.yml ps +docker compose -f docker-compose.dev.yml exec postgres pg_isready +``` + +## 7. Start SvelteKit (utenfor Docker, for HMR) + +```bash +cd sveltekit/ # eller der SvelteKit-prosjektet lever +npm install +npm run dev -- --host # Tilgjengelig på https://localhost:5173 +``` + +## 8. Start Rust Workers (under utvikling) + +```bash +cd workers/ # eller der Rust worker-prosjektet lever +cargo run --bin sidelinja-worker +``` + +## 9. Database-seeding (valgfritt) + +```bash +# Kjør SQL-seed mot lokal PostgreSQL for testdata +psql -h localhost -U sidelinja -d sidelinja -f db/seed.sql +``` + +## 10. Lokal Caddy (HTTPS for WebRTC) + +WebRTC krever sikker kontekst. Lokal Caddy genererer self-signed cert: + +```caddyfile +# config/caddy/Caddyfile.dev +{ + local_certs +} + +localhost { + # SvelteKit dev server + reverse_proxy host.docker.internal:5173 + + # SpacetimeDB + handle_path /spacetime/* { + reverse_proxy spacetimedb:3000 + } + + # LiveKit + handle_path /livekit/* { + reverse_proxy livekit:7880 + } + + # Media + handle_path /media/* { + root * /srv/media + file_server + } +} +``` + +## 11. Daglig utviklingsflyt + +```bash +# 1. Start infrastruktur (om ikke allerede kjørende) +docker compose -f docker-compose.dev.yml --env-file .env.local up -d + +# 2. Start SvelteKit +cd sveltekit && npm run dev + +# 3. Gjør endringer, test lokalt + +# 4. Commit og push til Forgejo +git add +git commit -m "beskrivelse" +git push forgejo main + +# 5. Deploy til prod (via SSH) +ssh sidelinja@ "cd /srv/sidelinja && git pull && docker compose up -d --build" +``` + +## 12. Forskjeller fra produksjon + +| Aspekt | Lokalt | Produksjon | +|---|---|---| +| SvelteKit | `npm run dev` (HMR) | Docker container (bygget) | +| Porter | Localhost-bundet (`127.0.0.1`) | Kun 80/443 via Caddy | +| HTTPS | Self-signed (Caddy `local_certs`) | Let's Encrypt | +| Forgejo | Ikke installert, push direkte til prod | Docker container | +| Authentik | Ikke installert (bypass auth lokalt) | Docker container | +| DB-data | Flyktig (`.docker-data/`, gitignored) | Persistent (`/srv/sidelinja/data/`) | +| Whisper | Valgfritt, kan mockes | Alltid tilgjengelig | diff --git a/docs/setup/produksjon.md b/docs/setup/produksjon.md new file mode 100644 index 0000000..ecbe1ff --- /dev/null +++ b/docs/setup/produksjon.md @@ -0,0 +1,292 @@ +# 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 + +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 + +```bash +# Daglig snapshot (cron, 03:00) +# Inkluderer: data/, media/, config/, docker-compose.yml +# Ekskluderer: logs/ (arkiveres separat månedlig) + +# Eksempel med restic eller borgbackup: +# borg create /backup/sidelinja::{now} /srv/sidelinja --exclude /srv/sidelinja/logs +``` + +## 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 (når implementert) +- [ ] `https://sidelinja.org` laster SvelteKit-appen +- [ ] `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`