Arkitekturbeslutninger: noder er sentrum, edges definerer alt

Grunnleggende arkitekturbeslutninger tatt og dokumentert:

- Alt er noder (brukere, team, innhold, mediefiler, samlings-noder)
- Edges definerer hva en node er (freeform typer, metadata i JSONB)
- Materialisert tilgangsmatrise (node_access) erstatter workspace-RLS
- Visibility (hidden/discoverable/readable/open) på noder
- Aliaser via usynlige system-edges
- Maskinrommet eier all skriving (SpacetimeDB først, PG asynk)
- SpacetimeDB holder hele grafen, PG er persistent backup
- Node- og edge-skjema spesifisert (docs/primitiver/)

Fjernet workspace-konseptet fra hele dokumentasjonen (~40 filer).
Fem retninger besluttet, én åpen (rom, ikke forum).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 10:29:54 +01:00
parent 0a467066ba
commit 00bf5d27ce
44 changed files with 1663 additions and 1548 deletions

View file

@ -1,4 +1,4 @@
# === Sidelinja v2 — Lokalt utviklingsmiljø === # === Synops — Lokalt utviklingsmiljø ===
# Kopier til .env.local og fyll inn verdier: # Kopier til .env.local og fyll inn verdier:
# cp .env.example .env.local # cp .env.example .env.local
# #

View file

@ -12,15 +12,11 @@ plattformkode og infrastruktur er skilt fra tenant-data og -innhold.
- **Standard arbeidsmodus:** Start i planleggingsmodus. Lag en grundig plan, - **Standard arbeidsmodus:** Start i planleggingsmodus. Lag en grundig plan,
få godkjenning, deretter implementer. Jobbene er ment å kunne kjøre få godkjenning, deretter implementer. Jobbene er ment å kunne kjøre
lenge autonomt uten input underveis. lenge autonomt uten input underveis.
- **Testmiljø:** `./dev.sh` er den kanoniske måten å starte utviklingsmiljøet. - **Utvikling mot server.** Ingen lokale databaser eller tjenester.
Når nye steg eller oppsett-quirks oppdages, oppdater alltid `dev.sh`. Frontend (SvelteKit) utvikles lokalt med HMR mot server-API.
Før Vegard tester i browser: kjør `./dev.sh`, verifiser med Rust bygges lokalt, deployes til server for integrasjonstest.
`cargo check`/`svelte-check`/`curl`, og meld tilbake at det er klart.
- **Browser-testing:** Claude har ikke tilgang til browser. Visuell testing - **Browser-testing:** Claude har ikke tilgang til browser. Visuell testing
gjøres av Vegard. Claude verifiserer backend (kompilering, API, DB-state). 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. - **Commit og push:** Bruk egen vurdering. Trygt og reverserbart.
- **Deploy til produksjon:** Krever alltid eksplisitt godkjenning fra Vegard. - **Deploy til produksjon:** Krever alltid eksplisitt godkjenning fra Vegard.
- **Diskusjon:** Forklar og diskuter før arkitekturendringer. - **Diskusjon:** Forklar og diskuter før arkitekturendringer.
@ -36,10 +32,12 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
- `rom_ikke_forum.md` — Opplevelse-først, to-lags-modell, administrativ opplevelse - `rom_ikke_forum.md` — Opplevelse-først, to-lags-modell, administrativ opplevelse
- `universell_input.md` — Tre primitiver (input, mottak, kommunikasjon), noder+edges - `universell_input.md` — Tre primitiver (input, mottak, kommunikasjon), noder+edges
- `maskinrommet.md` — Rust-orkestrator: fang, prosesser, lever - `maskinrommet.md` — Rust-orkestrator: fang, prosesser, lever
- `bruker_ikke_workspace.md`Brukeren er sentrum, samlings-noder med RLS-siloer - `bruker_ikke_workspace.md`Noder er sentrum: brukere, team, innhold er noder. Tilgangsmatrise fra edges.
- `datalaget.md` — PG(+AGE) som graf og arkiv, SpacetimeDB som sanntidslag - `datalaget.md` — PG(+AGE) som graf og arkiv, SpacetimeDB som sanntidslag
- `docs/primitiver/` — Spesifikasjoner for kjerneprimitivene: - `docs/primitiver/` — Spesifikasjoner for kjerneprimitivene:
- (kommer: input, mottak, kommunikasjonsnode, node, edge) - `nodes.md` — Node-skjema, node_kind, visibility, CAS-noder, eierskap
- `edges.md` — Edge-skjema, typer, metadata, systemedges
- (kommer: input, mottak, kommunikasjonsnode)
- `docs/concepts/` — Brukeropplevelser/produktområder: - `docs/concepts/` — Brukeropplevelser/produktområder:
- `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`, - `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`,
`kunnskapsgrafen.md`, `valgomaten.md`, `den_asynkrone_gjesten.md` `kunnskapsgrafen.md`, `valgomaten.md`, `den_asynkrone_gjesten.md`
@ -50,7 +48,7 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
- `docs/setup/` — Oppsett og drift: - `docs/setup/` — Oppsett og drift:
- `produksjon.md` — Steg-for-steg oppsett av Hetzner VPS fra scratch - `produksjon.md` — Steg-for-steg oppsett av Hetzner VPS fra scratch
- `lokal.md` — Lokalt utviklingsmiljø (WSL2, mot server) - `lokal.md` — Lokalt utviklingsmiljø (WSL2, mot server)
- `migration_safety.md` — Sjekkliste for PostgreSQL-migrasjoner (RLS-verifisering) - `migration_safety.md` — Sjekkliste for PostgreSQL-migrasjoner (v1 workspace-RLS, trenger omskriving til node_access)
- `docs/infra/` — Infrastruktur og drift: - `docs/infra/` — Infrastruktur og drift:
- `ai_gateway.md` — LiteLLM som sentralisert AI-ruter (BYOK + fallback) - `ai_gateway.md` — LiteLLM som sentralisert AI-ruter (BYOK + fallback)
- `api_grensesnitt.md` — Kommunikasjonskart: SvelteKit er web-API, Rust er worker - `api_grensesnitt.md` — Kommunikasjonskart: SvelteKit er web-API, Rust er worker
@ -63,7 +61,7 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
## Aktører ## Aktører
- **Vegard** — serveradmin, utvikler, bruker. SSH: `vegard@157.180.81.26` - **Vegard** — serveradmin, utvikler, bruker. SSH: `vegard@157.180.81.26`
- **Claude** — AI-agent, utvikler. SSH: `claude@157.180.81.26` - **Claude** — AI-agent, utvikler. SSH: `claude@157.180.81.26`
- Begge har sudo + docker-tilgang. Tjenestebruker `sidelinja` eier filer/Docker. - Begge har sudo + docker-tilgang på serveren.
## Stack ## Stack
- **Orkestrator/Backend:** Rust (maskinrommet) - **Orkestrator/Backend:** Rust (maskinrommet)
@ -88,10 +86,9 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
- `synops.no` — Plattformdomene (reservert, ikke i bruk ennå) - `synops.no` — Plattformdomene (reservert, ikke i bruk ennå)
## Git ## Git
- **Forgejo-org:** `sidelinja` - **Repos i Forgejo:**
- **Repos:** - `vegard/synops` — plattformkode og arkitektur: `ssh://git@git.sidelinja.org:222/vegard/synops.git`
- `synops` — plattformkode og arkitektur - `sidelinja/sidelinja` — podcastinnhold: `ssh://git@git.sidelinja.org:222/sidelinja/sidelinja.git`
- `sidelinja` — podcastinnhold: `ssh://git@git.sidelinja.org:222/sidelinja/sidelinja.git`
- **Git-identitet:** vegard / vnotnes@pm.me - **Git-identitet:** vegard / vnotnes@pm.me
- **Forgejo-bruker:** vegard (admin) - **Forgejo-bruker:** vegard (admin)
- **CLI:** Bruk `tea`, ikke `gh` (vi bruker Forgejo, ikke GitHub) - **CLI:** Bruk `tea`, ikke `gh` (vi bruker Forgejo, ikke GitHub)
@ -121,8 +118,8 @@ Tjenester: PG+AGE, SpacetimeDB, CAS, Whisper, LiteLLM, LiveKit ...
(samler folk). Alt annet er visninger og edges. (samler folk). Alt annet er visninger og edges.
3. **Maskinrommet orkestrerer alt.** Fang, prosesser, lever. Edge-drevet 3. **Maskinrommet orkestrerer alt.** Fang, prosesser, lever. Edge-drevet
ressursallokering. Tjenester under er utbyttbare. ressursallokering. Tjenester under er utbyttbare.
4. **Brukeren er sentrum.** Ingen workspace-velger. Du ser dine edges. 4. **Noder er sentrum.** Brukere, team, innhold — alt er noder.
Samlings-noder gir felles kontekst med RLS-siloer under. Du ser dine edges. Tilgang via materialisert tilgangsmatrise.
5. **Privat er default.** Input uten mottaker-edge er privat. Security 5. **Privat er default.** Input uten mottaker-edge er privat. Security
by design, ikke konfigurasjon. by design, ikke konfigurasjon.
6. **PG er arkivet, SpacetimeDB er nåtid.** Ingen eierskapskonflikt. 6. **PG er arkivet, SpacetimeDB er nåtid.** Ingen eierskapskonflikt.

View file

@ -1,119 +1,134 @@
# Arkitektur — Sidelinja v2 # Arkitektur — Synops
## Visjon ## Visjon
Sidelinja er en plattform for redaksjonelt arbeid og podcast-produksjon. Synops er en plattform for redaksjonelt arbeid og podcast-produksjon.
Ikke en webapp med features — en plattform med primitiver som kan bli Ikke en webapp med features — en plattform med primitiver som kan bli
hva som helst. hva som helst. Sidelinja (podcastredaksjonen) er en tenant som bruker
Synops.
Alt er noder og edges i en graf. Input fanger, mottak presenterer, Alt er noder og edges i en graf. En bruker er en node. Et team er en
kommunikasjonsnoder samler folk. Maskinrommet orkestrerer tekniske node. En mediefil er en node. Hva noe "er" bestemmes av edges, ikke
tjenester under. Frontend er et tynt lag som viser grafen. av noden selv. Maskinrommet eier alle skrivinger. Frontend er et tynt
lag som leser grafen fra SpacetimeDB.
## Lagmodell ## Lagmodell
``` ```
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ GUI (SvelteKit) │ │ GUI (SvelteKit) │
│ Visninger: spørringer mot grafen │ Visninger: spørringer mot STDB
└────────┬──────────────────┬─────────┘ └────────┬──────────────────┬─────────┘
│ skriv │ les (sanntid) │ intensjoner │ les (sanntid)
│ │ direkte WebSocket │ │ direkte WebSocket
┌────────▼────────┐ ┌─────▼─────────┐ ┌────────▼────────┐ ┌──────▼──────────┐
│ Maskinrommet │ │ SpacetimeDB │ │ Maskinrommet │ │ SpacetimeDB │
│ (Rust) │ │ Aktive noder │ │ (Rust) │ │ Hele grafen │
│ Orkestrering │ └───────────────┘ │ Eier alle │ │ (noder+edges) │
│ skrivinger │ └─────────────────┘
└──┬─────┬─────┬──┘ └──┬─────┬─────┬──┘
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌─────┐┌─────┐┌─────┐┌─────────────┐ ┌─────┐┌─────┐┌─────┐┌─────────────┐
│ PG ││STDB ││ CAS ││ Whisper, │ │ PG ││STDB ││ CAS ││ Whisper, │
││(skr)││ ││ LiteLLM, │ (bak)││(skr)││ ││ LiteLLM, │
│ ││ ││ ││ LiveKit ... │ │ ││ ││ ││ LiveKit ... │
└─────┘└─────┘└─────┘└─────────────┘ └─────┘└─────┘└─────┘└─────────────┘
``` ```
### Skrivestien ### Skrivestien
GUI → Maskinrommet (Rust) → tjenester GUI → intensjon → Maskinrommet (Rust) → SpacetimeDB (instant) → PG (async)
All orkestrering, edge-logikk, validering og ressursallokering Frontend sender intensjoner (ikke data). Maskinrommet validerer,
går gjennom Rust. Maskinrommet leser edges og bestemmer hvilke skriver til SpacetimeDB først for umiddelbar oppdatering, deretter
tjenester som trigges. persisterer til PG asynkront. Maskinrommet leser edges og bestemmer
hvilke tjenester som trigges.
### Lesestien (sanntid) ### Lesestien (sanntid)
SpacetimeDB → GUI (direkte WebSocket) SpacetimeDB → GUI (direkte WebSocket)
STDB klient-SDK gir ~10μs-oppdateringer med lokal cache. SpacetimeDB holder hele grafen — alle noder og edges. Frontend
En bevisst optimering — ikke et hull i lagmodellen. abonnerer via WebSocket med edge-filtre. Visninger er spørringer
mot STDB, ikke forhåndsdefinerte API-endepunkter.
### Lesestien (tradisjonell) ### Lesestien (tunge spørringer)
GUI → Maskinrommet (Rust) → PG GUI → Maskinrommet (Rust) → PG
Søk, historikk, statistikk, arkiv. Cypher via AGE for Søk, statistikk, semantisk søk (pgvector), graftraversering
graftraversering når CTEs ikke holder. (AGE/Cypher). For operasjoner der STDB ikke er egnet.
## Datamodell ## Datamodell
### Noder ### Alt er noder
Én tabell. Alt innhold er noder: meldinger, oppgaver, notater, Én `nodes`-tabell. Alt er noder: brukere, team, meldinger, oppgaver,
faktoider, mediefiler, kommunikasjonsrom, samlings-noder, brukere. notater, mediefiler, kommunikasjonsrom, samlings-noder. En bruker er
en node som tilfeldigvis kan logge inn.
Felles skjema: id, innhold, created_at, author, content_hash (→ CAS). Felles skjema: id, innhold, created_at, content_hash (→ CAS).
Modalitetsspesifikk metadata i JSONB. Modalitetsspesifikk metadata i JSONB.
### Edges ### Edges definerer alt
Én tabell. Alle relasjoner er edges: tilhørighet, tilgang, type, Én `edges`-tabell. Edge-typer er frie strenger. Hva en node "er"
synlighet, rolle, status. bestemmes utelukkende av dens edges:
Hva en node "er" bestemmes utelukkende av edges:
- Node + edge til kanal = chatmelding - Node + edge til kanal = chatmelding
- Node + edge til board + status-edge = kanban-kort - Node + edge til board + status-edge = kanban-kort
- Node + edge til dato = kalenderoppføring - Node + edge til dato = kalenderoppføring
- Node + edge til kun bruker = privat notat - Node + edge til kun bruker = privat notat
- Node + edge til topic = faktoid
- Node uten edges = løs tanke - Node uten edges = løs tanke
### Visninger ### Visninger er spørringer
Visninger er spørringer mot grafen med ulike filtre: Visninger er spørringer mot SpacetimeDB med edge-filtre:
- Chat = noder med kanal-edge, sortert på tid - Chat = noder med kanal-edge, sortert på tid
- Kanban = noder med board-edge, gruppert på status - Kanban = noder med board-edge, gruppert på status
- Kalender = noder med dato-edge, på tidslinje - Kalender = noder med dato-edge, på tidslinje
- Mottaksflate = noder med edge til deg, vektet på relevans - Mottaksflate = noder med edge til deg, vektet på relevans
## Tre primitiver Ingen forhåndsdefinerte visningstyper. Nye visninger er nye filtre.
### 1. Input ## 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 universell input-komponent, gjenbrukt overalt. Fanger tekst, lyd,
Én personlig flate som presenterer alt tilpasset deg. Format bilde, AI, URL. Konteksten (hvor du er) bestemmer hvilke edges som
bestemmes av mottaker (tekst/lyd). Filtrering via dine edges. legges til. Output er alltid en node.
Sanntid (stream) eller asynkront (digest).
### 3. Kommunikasjon ## Struktur uten workspaces
En node som samler folk med tilgangsregler. Samme type fra
én-til-én-samtale til livesending. Forskjellen er edges: Ingen workspace-velger. Ingen `workspace_id`. Samlings-noder gir
eier, input-tilgang, mottak-tilgang, rolle. struktur: et team er en samlings-node, et prosjekt er en samlings-node.
Du ser noder du har tilgang til via dine edges.
### Aliaser
En bruker kan ha alias-noder (f.eks. for ulike roller). Koblet med
system-edges som er usynlige for traversering.
## Maskinrommet ## Maskinrommet
Rust-tjeneste med tre operasjoner: **fang**, **prosesser**, **lever**. Rust-tjeneste med tre operasjoner: **fang**, **prosesser**, **lever**.
Edge-drevet ressursorkestrering: maskinrommet leser edges og Eier alle skrivinger. Frontend sender intensjoner, maskinrommet
bestemmer hvilke tjenester som spinnes opp. Dagboknotat = bare validerer og utfører. Edge-drevet ressursorkestrering: maskinrommet
transkriber. Livesending = transkriber + LiveKit + AI + mediaprosessering. leser edges og bestemmer hvilke tjenester som spinnes opp.
Forvalter også CAS (binærlagring) med intelligent pruning basert Forvalter også CAS (binærlagring) med intelligent pruning basert
på modalitet, edges og aksessmønstre. på modalitet, edges og aksessmønstre.
## Sikkerhet ## Sikkerhet
### RLS-siloer ### Synlighet (visibility)
Samlings-noder (det som var workspaces) er harde sikkerhetsgenser Noder har en visibility-egenskap med fire nivåer:
i PG med RLS. `workspace_id = current_setting(...)` — instant, - **hidden** — usynlig, kun tilgjengelig via system-edges
vanntett. Edge-basert tilgang er UX *innenfor* siloen. - **discoverable** — kan finnes, men innhold skjult
- **readable** — innhold lesbart for de med tilgang
- **open** — tilgjengelig for alle med traverseringssti
Traversering respekterer visibility. Du kan ikke følge edges gjennom
noder du ikke har lov til å se.
### Materialisert tilgangsmatrise
`node_access`-tabell som cacher beregnet tilgang fra edge-grafen.
Oppdateres ved edge-endring, ikke ved lesing. Rask oppslag — ingen
rekursiv graftraversering per forespørsel.
### Privat er default ### Privat er default
Input uten mottaker-edge er automatisk privat. Ingen ser det. Input uten mottaker-edge er automatisk privat. Ingen ser det.
@ -122,28 +137,27 @@ Deling er å legge til edges.
## Datalag ## Datalag
### PostgreSQL ### PostgreSQL
Arkiv og graf. Alle noder og edges. Fulltekstsøk, pgvector Persistent backup og arkiv. Alle noder og edges. Fulltekstsøk,
(semantisk søk), JSONB. Apache AGE for Cypher når CTEs ikke holder. pgvector (semantisk søk), JSONB. Apache AGE for Cypher ved behov.
### SpacetimeDB ### SpacetimeDB
Sanntidslag. Aktive noder og edges speilet fra PG. Frontend Holder hele grafen — alle noder og edges. Frontend abonnerer via
abonnerer via WebSocket. Ingen eierskapskonflikt — STDB er en WebSocket. Maskinrommet skriver hit først for umiddelbar oppdatering,
live-visning av en delmengde av PG-grafen. PG synkroniseres asynkront.
### CAS (Content-Addressable Store) ### CAS (Content-Addressable Store)
Binærdata (lyd, bilde, video) lagret med hash. TTL basert på Binærdata (lyd, bilde, video) lagret med hash. TTL basert på
modalitet, edges og aksesslog. Tekst lever evig, lyd 30 dager, modalitet, edges og aksesslog. Generert innhold (TTS, thumbnails)
video 14 dager — forlenges ved aksess. Generert innhold (TTS, er en cache som regenereres on-demand.
thumbnails) er en cache som regenereres on-demand.
## Teknologivalg ## Teknologivalg
| Rolle | Teknologi | Begrunnelse | | Rolle | Teknologi | Begrunnelse |
|-------|-----------|-------------| |-------|-----------|-------------|
| Orkestrator | Rust | Ytelse, typesikkerhet, allerede i stacken | | Orkestrator | Rust | Ytelse, typesikkerhet, eier alle skrivinger |
| Frontend | SvelteKit | PWA, SSR, allerede i stacken | | Frontend | SvelteKit | PWA, SSR, tynt lag mot STDB |
| Database | PostgreSQL | Økosystem (pgvector, fulltekstsøk, AGE), stabilitet | | Database | PostgreSQL | Persistent backup, pgvector, fulltekstsøk, AGE |
| Sanntid | SpacetimeDB | In-memory, WebSocket-subscriptions, ~10μs | | Sanntid | SpacetimeDB | Hele grafen, WebSocket-subscriptions, ~10μs |
| Binærlagring | CAS (filsystem) | Enkel, deduplisering, ingen ekstern avhengighet | | Binærlagring | CAS (filsystem) | Enkel, deduplisering, ingen ekstern avhengighet |
| AI Gateway | LiteLLM | Multi-provider, BYOK, OpenRouter fallback | | AI Gateway | LiteLLM | Multi-provider, BYOK, OpenRouter fallback |
| STT | faster-whisper | Lokal, god norsk kvalitet | | STT | faster-whisper | Lokal, god norsk kvalitet |

View file

@ -43,8 +43,7 @@ Mange interessante gjester har ikke tid til å stille i studio. Den Asynkrone Gj
```sql ```sql
guest_tokens ( guest_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, -- Samlings- eller tema-node
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
guest_name TEXT NOT NULL, -- Visningsnavn ("Erna Solberg") guest_name TEXT NOT NULL, -- Visningsnavn ("Erna Solberg")
questions JSONB NOT NULL, -- [{ "sort": 1, "text": "Hva tenker du om...?" }] questions JSONB NOT NULL, -- [{ "sort": 1, "text": "Hva tenker du om...?" }]
token TEXT UNIQUE NOT NULL, -- Kryptografisk sikker, URL-safe token token TEXT UNIQUE NOT NULL, -- Kryptografisk sikker, URL-safe token
@ -58,9 +57,9 @@ guest_tokens (
### 4.2 Sikkerhet ### 4.2 Sikkerhet
- Token er kryptografisk tilfeldig (256-bit, URL-safe base64). - 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. - SvelteKit validerer token ved hvert request: sjekker expiry, recordings_count < max_recordings, og node-tilhørighet.
- Gjestens meldinger merkes med `author_id = NULL` og `metadata.guest_name` + `metadata.guest_token_id` for sporbarhet. - 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. - Ingen tilgang til andre noder eller funksjoner.
- Tokenet kan revokeres manuelt av redaksjonen. - Tokenet kan revokeres manuelt av redaksjonen.
### 4.2b Sikkerhetsdybde (mot token-lekkasje og misbruk) ### 4.2b Sikkerhetsdybde (mot token-lekkasje og misbruk)
@ -81,7 +80,7 @@ clamav:
image: clamav/clamav:latest image: clamav/clamav:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /srv/sidelinja/media:/scan:ro - /srv/synops/media:/scan:ro
networks: networks:
- sidelinja-net - sidelinja-net
``` ```
@ -101,7 +100,7 @@ Gjest åpner URL med token
→ SvelteKit validerer token → SvelteKit validerer token
→ Viser spørsmål + opptaksknapp → Viser spørsmål + opptaksknapp
→ Gjest tar opp svar → Gjest tar opp svar
→ SvelteKit streamer lydfil til media/{workspace_slug}/voice/ → SvelteKit streamer lydfil til CAS (content-addressable store)
→ Oppretter message (voice_memo) i channelen → Oppretter message (voice_memo) i channelen
→ Oppretter whisper_transcribe-jobb i jobbkøen → Oppretter whisper_transcribe-jobb i jobbkøen
→ Inkrementerer recordings_count → Inkrementerer recordings_count
@ -118,8 +117,8 @@ Gjest åpner URL med token
## 6. Instruks for Claude Code ## 6. Instruks for Claude Code
* `guest_tokens`-tabellen er **ikke** en node i grafen — den er ren tilgangsstyring. * `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). * Gjeste-UI er en egen SvelteKit-rute (`/guest/[token]`) med minimal layout (ingen navbar).
* Gjenbruk lydmeldinger-komponenten — ikke bygg en egen opptaksflyt. * 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). * 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. * 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. * Tilgang er styrt via node_access. Token bærer node_id eksplisitt.

View file

@ -49,4 +49,4 @@ Grafen vokser organisk via `#`-mentions, men dette skaper uunngåelig fragmenter
**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`. **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 ## 5. Datamodell
Den tekniske datamodellen (nodes-supertabell, graph_edges, detailtabeller, deterministiske UUIDs, workspace-isolasjon) er dokumentert i `docs/features/kunnskapsgraf_og_relasjoner.md`. Den tekniske datamodellen (nodes-supertabell, graph_edges, detailtabeller, deterministiske UUIDs, tilgangsstyring via node_access) er dokumentert i `docs/features/kunnskapsgraf_og_relasjoner.md`.

View file

@ -29,6 +29,23 @@ Hver publisert episode får en side med:
* Fane: **SRT** (nedlastbar undertekstfil — master-kopi fra Git) * Fane: **SRT** (nedlastbar undertekstfil — master-kopi fra Git)
* Fane: **Ren tekst** (lesbart transkripsjonsdokument — avledet fra SRT, lagret i PG) * Fane: **Ren tekst** (lesbart transkripsjonsdokument — avledet fra SRT, lagret i PG)
### 2.2 Live-to-Archive (Studio/Møterom → Episode)
Mye av innholdet som ender i podcastfabrikken starter som live-innspilling
i Studioet eller Møterommet. Denne flyten unngår manuell opplasting:
1. Under innspilling i LiveKit produserer Whisper chunks i sanntid
2. Når innspillingen stoppes → automatisk jobb `studio_to_episode`:
- Konsoliderer live-chunks til én SRT-fil
- Oppretter episode-node med storyboard basert på markører satt under innspilling
- Trigger AI-analyse (samme pipeline som ved vanlig opplasting)
3. Lydfilen lagres i CAS med edge til episode-noden
4. Episoden dukker opp i podcastfabrikken som et utkast — klar for
godkjenning, redigering og publisering
Fordel: aldri behov for «last opp MP3 etter innspilling» — flyten er
live → arkiv → publisering.
## 3. Spesialhåndtering: Oppdatering av eksisterende episoder (Cache-busting) ## 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: 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:
@ -69,17 +86,17 @@ Sidelinja podcast med Vegard Nøtnæs, Trond Sørensen, Arne Eidshagen,
Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie
``` ```
## 5. Workspace-spesifikk konfigurasjon ## 5. Per samlings-node konfigurasjon
Hver workspace har sin egen podcast-konfigurasjon, lagret i `workspaces.settings` (JSONB): Hver samlings-node (f.eks. Sidelinja) har sin egen podcast-konfigurasjon, lagret som JSONB-metadata på noden:
### 5.1 Mediefiler ### 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. Lydfiler lagres i CAS (content-addressable store) med edges til episode-noder. Caddy ruter trafikk basert på domene (fra samlings-nodens metadata) til riktig innhold.
### 5.2 Transkripsjoner (Git-repostruktur) ### 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. Det opprettes **ett Forgejo-repo per samlings-node** for SRT-filer, slik at historikk og redigering ikke blandes på tvers av podcaster.
#### Repo-oppretting #### Repo-oppretting
Repoet opprettes **on-demand** ved første transkripsjonsjobb for en workspace, via Forgejo API. Ikke alle workspaces trenger transkripsjonsrepo. Repoet opprettes **on-demand** ved første transkripsjonsjobb for en samlings-node, via Forgejo API. Ikke alle samlings-noder trenger transkripsjonsrepo.
#### Filnavnkonvensjon #### Filnavnkonvensjon
Flat struktur med prosesseringstidspunkt som filnavn: Flat struktur med prosesseringstidspunkt som filnavn:
@ -93,7 +110,7 @@ Flat struktur med prosesseringstidspunkt som filnavn:
* **Ingen metadata i filnavn:** Episodenummer, tittel, slug og annen metadata lever i PostgreSQL, ikke i filnavnet. Filnavnet er en stabil identifikator som aldri endres. * **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 #### 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. Lydfilen i CAS bruker **samme navnekonvensjon** som SRT-filen: `20260315_143022.mp3` matcher `20260315_143022.srt`. Dette kobler mediefil og transkripsjon uten databaseoppslag.
#### Reprosessering (redigert lyd) #### 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. 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.
@ -104,7 +121,7 @@ En dedikert servicebruker **"serverassistent"** opprettes i Forgejo med push-til
#### Webhook-flyt #### Webhook-flyt
``` ```
Forgejo push-webhook → SvelteKit POST /api/webhooks/forgejo Forgejo push-webhook → SvelteKit POST /api/webhooks/forgejo
→ INSERT INTO job_queue (type: 'srt_parse', payload: {repo, commit, workspace_id}) → INSERT INTO job_queue (type: 'srt_parse', payload: {repo, commit, node_id})
→ Rust-worker plukker opp jobben og parser SRT → avledede formater i PG → 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. SvelteKit validerer webhook-signatur og legger jobb i køen. Rust-worker forblir en ren kø-consumer uten eget HTTP-endepunkt.
@ -113,18 +130,18 @@ SvelteKit validerer webhook-signatur og legger jobb i køen. Rust-worker forblir
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. 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 ### 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. * **Whisper `initial_prompt`:** Navnelister og kontekst lagres per samlings-node i metadata (`whisper_prompt`). Rust-worker bygger prompten fra statisk liste + aktører knyttet til samlings-noden via edges.
* **LLM system-prompts:** OpenRouter-prompts for metadata-uttrekk lagres i `settings.llm_prompts` slik at AI-en kjenner konteksten og vertene for akkurat den podcasten. * **LLM system-prompts:** OpenRouter-prompts for metadata-uttrekk lagres i metadata (`llm_prompts`) slik at AI-en kjenner konteksten og vertene for akkurat den podcasten.
### 5.4 RSS-feed ### 5.4 RSS-feed
SvelteKit genererer `/feed.xml` dynamisk basert på domenet forespørselen kommer fra (matcher `workspaces.domain`), eller workspace-slug som fallback. SvelteKit genererer `/feed.xml` dynamisk basert på domenet forespørselen kommer fra (matcher samlings-nodens domene-metadata), eller node-slug som fallback.
### 5.5 Statistikk ### 5.5 Statistikk
Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig `workspace_id` basert på filsti i loggen. Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig samlings-node basert på domene i loggen.
## 6. Instruks for Claude Code ## 6. Instruks for Claude Code
* **Lydfiler:** Håndter filopplasting i SvelteKit strømmende (streaming) for filer >100MB for å unngå minne-lekkasjer. * **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. * **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. * **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. * **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`. * **Tilhørighet:** Alle jobber, mediefiler og metadata knyttes til riktig samlings-node via edges. Hent config (prompts, domene) fra samlings-nodens JSONB-metadata.

View file

@ -33,4 +33,4 @@ Brukere limer inn uformatert tekst fra nettet i editoren, trykker AI-knappen (
## 4. Instruks for Claude Code ## 4. Instruks for Claude Code
* Bruk SvelteKit for Drag-and-Drop. Unngå tunge biblioteker hvis native HTML5 Drag and Drop er tilstrekkelig. * 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. * 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`. * All tilgang er styrt via node_access-matrisen. SpacetimeDB-tilkoblinger bærer brukerens identitet, og tilgang avgjøres av edges til samlings-noder.

View file

@ -90,7 +90,7 @@ spacetime generate --lang typescript --out-dir ../web/src/lib/chat/module_bindin
--module-path . --module-path .
``` ```
**Merk:** Bruk `./dev.sh` for å starte hele stacken automatisk (inkl. SpacetimeDB publish + binding-generering). `./dev.sh --clean` starter blankt. **Merk:** SpacetimeDB-modulen publiseres manuelt med `spacetime publish` mot server-instansen.
## 6. Arkitekturendring: SpacetimeDB som cache foran PG (mars 2026) ## 6. Arkitekturendring: SpacetimeDB som cache foran PG (mars 2026)
@ -149,6 +149,10 @@ PG-polling adapter (`pg.svelte.ts`) brukes kun når SpacetimeDB ikke er konfigur
## 8. Reducer-parameternavn — unngå underscore-prefix ## 8. Reducer-parameternavn — unngå underscore-prefix
> *Kodeeksemplene i denne seksjonen er fra v1 og bruker `workspace_id`-parametere.
> I v2 er workspace-modellen erstattet av noder og edges (se `docs/retninger/bruker_ikke_workspace.md`),
> men lærdommen om underscore-prefix gjelder generelt for alle SpacetimeDB-reducere.*
SpacetimeDB eksponerer Rust-parameternavn direkte i HTTP JSON API-et. Underscore-prefix (`_workspace_id`) blir til `_workspace_id` i JSON, ikke `workspace_id`: SpacetimeDB eksponerer Rust-parameternavn direkte i HTTP JSON API-et. Underscore-prefix (`_workspace_id`) blir til `_workspace_id` i JSON, ikke `workspace_id`:
```rust ```rust

View file

@ -68,14 +68,11 @@ Eksempel:
``` ```
**Hvorfor `users.settings` og ikke en egen tabell?** **Hvorfor `users.settings` og ikke en egen tabell?**
- Innstillingene er per bruker, ikke per workspace - Innstillingene er per bruker, ikke per samlings-node
- JSONB er fleksibelt — nye innstillinger legges til uten migrering - JSONB er fleksibelt — nye innstillinger legges til uten migrering
- Ingen relasjoner, ingen spørringer mot enkeltfelt — bare hent hele objektet - Ingen relasjoner, ingen spørringer mot enkeltfelt — bare hent hele objektet
**Workspace-spesifikke overrides** kan legges i `workspace_members` ved behov: **Samlings-node-spesifikke overrides** kan legges som edges med metadata mellom bruker og samlings-node ved behov.
```sql
ALTER TABLE workspace_members ADD COLUMN settings JSONB NOT NULL DEFAULT '{}';
```
Men dette er fase 2. Start med globale brukerinnstillinger. Men dette er fase 2. Start med globale brukerinnstillinger.
## 4. Frontend ## 4. Frontend

View file

@ -5,7 +5,7 @@
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. 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 ## 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. En **channel** er en meldingsstrøm knyttet til en vilkårlig node (parent) i grafen. Channelen er selv en node (`node_type = 'channel'`), noe som betyr at den deltar i grafen og arver tilgangsstyring via `node_access`-matrisen.
``` ```
┌─────────────┐ parent_id ┌─────────────┐ ┌─────────────┐ parent_id ┌─────────────┐
@ -50,7 +50,7 @@ Hver channel har en `config` (JSONB) som styrer hvilke capabilities den støtter
| **Studioet** | "Studio-chat" | `threads: false, mentions: false, attachments: false, research_clips: false, ttl_days: 30` | | **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` | | **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. Presets er kun defaults — en admin for samlings-noden kan justere config per channel.
### 2.3 Automatisk opprettelse ### 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. 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.
@ -135,13 +135,13 @@ Channels med `config.ttl_days` satt til et tall får sine meldinger automatisk s
### Gjenstår ### Gjenstår
- **Vedlegg, TTL** — avventer implementering. - **Vedlegg, TTL** — avventer implementering.
- **Workspace-partisjonering:** SpacetimeDB har `workspace_id` men bruker ikke token ennå. - **Tilgangsfiltrering:** SpacetimeDB-laget må filtrere basert på `node_access`-matrisen.
- **Pin/konvertering:** Går fortsatt direkte til PG API (ikke via SpacetimeDB). - **Pin/konvertering:** Går fortsatt direkte til PG API (ikke via SpacetimeDB).
## 11. Instruks for Claude Code ## 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`. * **Opprettelsesrekkefølge:** Opprett `nodes`-rad → `channels`-rad → (for meldinger) `nodes`-rad → `messages`-rad. Alt i én transaksjon.
* **Channel-opprettelse:** Når en Tema, Episode eller Møte opprettes, opprett alltid en default-channel i samme transaksjon. * **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`. * **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. * **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. * **PG er autoritativ** — SpacetimeDB er varm cache. Frontend snakker kun med SpacetimeDB.
* **Alt er workspace-scopet.** Channels arver workspace via `nodes.workspace_id`. * **Tilgang styres via `node_access`-matrisen.** Channels arver tilgang fra sin parent-node via edges.

View file

@ -9,7 +9,7 @@ Månedsbasert kalendervisning for redaksjonell planlegging. Hendelser er nodes i
### Implementert ### Implementert
- Migrering `0003_calendar.sql`: `calendars` + `calendar_events` (begge FK→nodes) - Migrering `0003_calendar.sql`: `calendars` + `calendar_events` (begge FK→nodes)
- Hendelser er nodes — arver workspace-isolasjon automatisk - Hendelser er nodes — tilgangsstyrt via `node_access`-matrise
- Heldagshendelser (`T12:00:00` for tidssone-trygghet) vs. tidshendelser med klokkeslett - Heldagshendelser (`T12:00:00` for tidssone-trygghet) vs. tidshendelser med klokkeslett
- Fargekoder per hendelse (7 forhåndsdefinerte) + standard kalenderfarge - Fargekoder per hendelse (7 forhåndsdefinerte) + standard kalenderfarge
- REST API: GET med tidsvindu-filtrering, POST/PATCH/DELETE hendelser - REST API: GET med tidsvindu-filtrering, POST/PATCH/DELETE hendelser
@ -24,7 +24,7 @@ Månedsbasert kalendervisning for redaksjonell planlegging. Hendelser er nodes i
- Dra-og-slipp for å flytte hendelser mellom datoer - Dra-og-slipp for å flytte hendelser mellom datoer
- Flerdag-hendelser (vises over flere celler) - Flerdag-hendelser (vises over flere celler)
- Abonnementsmodell (kalender → kalender via graph_edges) - Abonnementsmodell (kalender → kalender via graph_edges)
- Personlige vs. workspace-kalendere - Personlige vs. delte kalendere (via samlings-noder)
- ICS/CalDAV-eksport - ICS/CalDAV-eksport
- SpacetimeDB-modul + hybrid-adapter - SpacetimeDB-modul + hybrid-adapter
- Varsler/påminnelser via jobbkøen - Varsler/påminnelser via jobbkøen
@ -84,15 +84,15 @@ Kalendere kan abonnere på andre kalendere via `SUBSCRIBES_TO`-edge i grafen. Ab
| Type | Eier | Synlighet | | Type | Eier | Synlighet |
|------|------|-----------| |------|------|-----------|
| Workspace | Workspace (admin) | Alle medlemmer | | Samlings-node | Admin for samlings-noden | Alle med tilgang via `node_access` |
| Personlig | Bruker | Kun eier + eksplisitt deling | | Personlig | Bruker | Kun eier + eksplisitt deling |
| Offentlig | Workspace | Alle som abonnerer | | Offentlig | Samlings-node | Alle som abonnerer |
## 8. Fremtidig: ICS-eksport ## 8. Fremtidig: ICS-eksport
Offentlige kalendere får en ICS-URL (`/cal/{workspace_slug}/{calendar_id}.ics`) for Google Calendar, Apple Calendar etc. Offentlige kalendere får en ICS-URL (`/cal/{calendar_id}.ics`) for Google Calendar, Apple Calendar etc.
## 9. Instruks for Claude Code ## 9. Instruks for Claude Code
* Bruk `eventsForDate()` med lokal dato-konvertering, ikke UTC-substring * Bruk `eventsForDate()` med lokal dato-konvertering, ikke UTC-substring
* Heldagshendelser: `T12:00:00`, aldri `T00:00:00` (tidssone-felle) * Heldagshendelser: `T12:00:00`, aldri `T00:00:00` (tidssone-felle)
* Alt er workspace-scopet via node-modellen * Tilgang styres via `node_access`-matrisen
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi * Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi

View file

@ -9,7 +9,7 @@ Et drag-and-drop Kanban-brett for planlegging. Primært brukt til episodeplanleg
### Implementert ### Implementert
- Migrering `0002_kanban.sql`: `kanban_boards`, `kanban_columns`, `kanban_cards` - Migrering `0002_kanban.sql`: `kanban_boards`, `kanban_columns`, `kanban_cards`
- Kanban-kort er nodes i kunnskapsgrafen (arver workspace-isolasjon) - Kanban-kort er nodes i kunnskapsgrafen (tilgangsstyrt via `node_access`-matrise)
- REAL-posisjon for midpoint-innsetting (`(1.0 + 2.0) / 2 = 1.5`) — ingen re-nummerering - 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 - 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 - PG polling-adapter (`pg.svelte.ts`) med 5 sek intervall og optimistisk UI
@ -31,7 +31,7 @@ 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) 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`. Kort og brett er nodes — tilgang styres via `node_access`-matrisen.
## 4. API-endepunkter ## 4. API-endepunkter
@ -55,5 +55,5 @@ Kort og brett er nodes — all workspace-isolasjon arves automatisk via `nodes.w
## 6. Instruks for Claude Code ## 6. Instruks for Claude Code
* Bruk native HTML5 Drag and Drop i SvelteKit, unngå tunge biblioteker. * Bruk native HTML5 Drag and Drop i SvelteKit, unngå tunge biblioteker.
* PG-adapter er autoritativ inntil SpacetimeDB-sync er på plass. * PG-adapter er autoritativ inntil SpacetimeDB-sync er på plass.
* Alt er workspace-scopet via node-modellen. * Tilgang styres via `node_access`-matrisen.
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi. * Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi.

View file

@ -1,14 +1,16 @@
# Feature: Kunnskaps-Bridge (Cross-Workspace Discovery) # Feature: Kunnskaps-Bridge (Cross-Context Discovery)
**Filsti:** `docs/features/kunnskaps_bridge.md` **Filsti:** `docs/features/kunnskaps_bridge.md`
> **NB:** Dette dokumentet er en skisse fra v1 og må oppdateres til node/edge-modellen ved implementering.
## 1. Konsept ## 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. En valgfri, opt-in feature som lar brukere oppdage semantisk beslektet kunnskap på tvers av samlings-noder de har tilgang til. Bryter ikke tilgangsstyringen — resultatet er en *peker* ("dette finnes i Podcast B"), ikke selve innholdet. Brukeren må ha tilgang til begge samlings-nodene via `node_access` for å se treffet.
## 2. Avgrensning: Hva dette IKKE er ## 2. Avgrensning: Hva dette IKKE er
- **Ikke datadeling.** Ingen data kopieres mellom workspaces. Bridge viser kun at en relevant node *finnes*. - **Ikke datadeling.** Ingen data kopieres mellom samlings-noder. Bridge viser kun at en relevant node *finnes*.
- **Ikke automatisk.** Begge workspaces må ha Bridge eksplisitt aktivert av en admin. - **Ikke automatisk.** Begge samlings-noder må ha Bridge eksplisitt aktivert av en admin.
- **Ikke synlig for gjester.** Kun for workspace-medlemmer med tilgang til begge sider. - **Ikke synlig for gjester.** Kun for brukere med tilgang til begge samlings-nodene.
- **Ikke et søk i andres data.** Du ser bare treff i workspaces du allerede er medlem av. - **Ikke et søk i andres data.** Du ser bare treff i samlings-noder du allerede har tilgang til.
## 3. Teknisk arkitektur ## 3. Teknisk arkitektur
@ -35,32 +37,33 @@ En jobbkø-jobb som genererer embeddings for nye/endrede noder:
4. Lagrer vektoren i pgvector-kolonnen. 4. Lagrer vektoren i pgvector-kolonnen.
5. Re-genereres ved vesentlige endringer av noden. 5. Re-genereres ved vesentlige endringer av noden.
### 3.3 Cross-workspace søk ### 3.3 Cross-context søk
Når en bruker utforsker en node (f.eks. Tema "Skolepolitikk"): Når en bruker utforsker en node (f.eks. Tema "Skolepolitikk"):
1. SvelteKit server-side henter brukerens tilgjengelige workspaces fra `workspace_members`. 1. SvelteKit server-side henter brukerens tilgjengelige samlings-noder fra `node_access`-matrisen.
2. Kjører et similarity-søk med `<=>` (cosine distance) **som superuser** (bypasser RLS) — men filtrerer eksplisitt mot brukerens workspace-liste: 2. Kjører et similarity-søk med `<=>` (cosine distance), filtrert mot brukerens tilgjengelige noder:
```sql ```sql
SELECT n.workspace_id, t.name, t.embedding <=> $target_embedding AS distance SELECT na.node_id, e.name, e.embedding <=> $target_embedding AS distance
FROM topics t FROM entities e
JOIN nodes n ON t.id = n.id JOIN nodes n ON e.id = n.id
WHERE n.workspace_id = ANY($user_workspace_ids) JOIN node_access na ON n.id = na.node_id
AND n.workspace_id != $current_workspace_id WHERE na.user_id = $current_user
AND t.embedding <=> $target_embedding < 0.3 AND n.id != $current_node_id
AND e.embedding <=> $target_embedding < 0.3
ORDER BY distance ORDER BY distance
LIMIT 10; LIMIT 10;
``` ```
3. Resultatet vises som en diskret "Finnes også i..."-seksjon i UI-et. 3. Resultatet vises som en diskret "Finnes også i..."-seksjon i UI-et.
### 3.4 Workspace-config ### 3.4 Samlings-node config
Bridge aktiveres per workspace i `workspaces.settings`: Bridge aktiveres per samlings-node i nodens metadata (JSONB):
```json ```json
{ {
"bridge_enabled": true, "bridge_enabled": true,
"bridge_discoverable": true "bridge_discoverable": true
} }
``` ```
- `bridge_enabled`workspace kan søke i andre workspaces - `bridge_enabled`samlings-noden kan søke i andre samlings-noder
- `bridge_discoverable` — andre workspaces kan finne noder i dette workspace-et - `bridge_discoverable` — andre samlings-noder kan finne noder under denne
Begge må være `true` for at en kobling skal vises. Begge må være `true` for at en kobling skal vises.
@ -72,7 +75,7 @@ Begge må være `true` for at en kobling skal vises.
## 5. Instruks for Claude Code ## 5. Instruks for Claude Code
* pgvector er en ny avhengighet — dokumenter i docker-compose og setup-guides. * 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. * Cross-context søk bruker `node_access`-matrisen for tilgangsstyring. Isolér denne koden grundig — egen funksjon, aldri gjenbruk i andre kontekster.
* Embedding-dimensjon (768) bør matche modellen som brukes. Konfigurér som konstant, ikke hardkod overalt. * Embedding-dimensjon (768) bør matche modellen som brukes. Konfigurér som konstant, ikke hardkod overalt.
* Jobbtype `generate_embeddings` bruker `sidelinja/rutine` som modellalias. * Jobbtype `generate_embeddings` bruker `sidelinja/rutine` som modellalias.
* Bridge er **Lag 4+** — krever fylt kunnskapsgraf i minst to workspaces. * Bridge er **Lag 4+** — krever fylt kunnskapsgraf i minst to samlings-noder.

View file

@ -28,7 +28,6 @@ CREATE TYPE node_type AS ENUM (
CREATE TABLE nodes ( CREATE TABLE nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, node_type node_type NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@ -86,12 +85,11 @@ CREATE INDEX idx_segments_transcript_fts ON segments USING GIN (to_tsvector('nor
``` ```
### 3.3 Kantene: `graph_edges` ### 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: All kobling skjer i én sentral tabell med ekte FK-integritet:
```sql ```sql
CREATE TABLE graph_edges ( CREATE TABLE graph_edges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, source_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
target_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' relation_type TEXT NOT NULL, -- 'MENTIONS', 'CONTRADICTS', 'WORKS_FOR', 'PART_OF', 'DISCUSSED_IN'
@ -107,7 +105,6 @@ CREATE TABLE graph_edges (
CREATE INDEX idx_edges_source ON graph_edges(source_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_target ON graph_edges(target_id);
CREATE INDEX idx_edges_relation ON graph_edges(relation_type); 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:** `UNIQUE(source_id, target_id, relation_type)` forhindrer duplikate relasjoner. Bruk `ON CONFLICT DO NOTHING` eller `ON CONFLICT DO UPDATE SET confidence = ...` ved upsert.
@ -161,9 +158,9 @@ Grafen bygger seg opp organisk gjennom daglig bruk av Sidelinja-suiten:
5. **Publisering:** Når en episode publiseres, kobles segmentene automatisk til relevante Temaer og Aktører basert på AI-analyse av transkripsjonen. 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 ## 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. * **Tilgangsstyring:** Tilgang til noder styres via `node_access`-matrisen (materialized view). Spørringer mot detailtabeller (entities, etc.) filtrerer via JOIN med `node_access`.
* **`nodes`-tabellen er obligatorisk.** Opprett alltid en rad i `nodes` før du inserter i en detailtabell. Bruk en hjelpefunksjon som gjør begge i én transaksjon. * **`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. * **`graph_edges`-tilgang.** Tilgang til edges avledes fra tilgang til kilde- og målnoder via `node_access`. `UNIQUE(source_id, target_id, relation_type)` hindrer duplikater — bruk `ON CONFLICT` ved upsert.
* **Graf-spørringer:** Bruk `WITH RECURSIVE` i PostgreSQL når du bygger endepunkter som skal hente ut "Linked Mentions" eller nettverket rundt en spesifikk Aktør opp til 2-3 ledd ut. * **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": [...] }`. * **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. * **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.

View file

@ -63,4 +63,4 @@ Kill switch-status (`ai_enabled: bool`) lagres på LiveKit-rommet i SpacetimeDB
* Møte-modus er asynkron — prosessér via jobbkøen etter møteslutt. * 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. * 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. * Kill switch skal alltid være tilgjengelig i studio-modus — default: AI aktivert.
* Alt er workspace-scopet. * Tilgang styres via `node_access`-matrisen.

View file

@ -24,7 +24,7 @@ Den felles Whisper-pipelinen som brukes av flere konsepter for å transkribere l
* **Benchmark og modellvalg:** Se `docs/concepts/podcastfabrikken.md` seksjon 4 for ytelsestall og anbefalinger. * **Benchmark og modellvalg:** Se `docs/concepts/podcastfabrikken.md` seksjon 4 for ytelsestall og anbefalinger.
### 4.1 initial_prompt (navneliste) ### 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. Brukes kun i batch-modus (Podcastfabrikken). Prompten bygges automatisk fra samlings-nodens metadata (`metadata.whisper_prompt`) + entiteter i kunnskapsgrafen.
Effekten er tydelig: Effekten er tydelig:
* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja" * Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja"
@ -32,9 +32,9 @@ Effekten er tydelig:
## 5. Lagring ## 5. Lagring
* **Live-modus:** Transkripsjonen er flyktig (kategori 4, TTL 30 dager). Lagres i `live_transcription_log` for feilsøking. * **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. * **Batch-modus:** SRT committes til Git (Forgejo). Avledede formater (ren tekst, segmenter, søkeindeks) i PostgreSQL.
## 6. Instruks for Claude Code ## 6. Instruks for Claude Code
* Live transkripsjon blokkerer ALDRI web-requests — prosesseres i Rust-worker eller separat tjeneste. * 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`). * Batch-transkripsjon kjøres som `whisper_transcribe`-jobb i jobbkøen (se `docs/infra/jobbkø.md`).
* Workspace-spesifikk config (prompts) hentes fra `workspaces.settings`. * Config (prompts) hentes fra samlings-nodens metadata (JSONB).

View file

@ -19,7 +19,7 @@ Voice-to-text er en del av chat-featuren (se `docs/features/chat.md`, seksjon 8)
2. Trykker record. Nettleseren fanger lyd via `MediaRecorder` API (WebM/Opus). 2. Trykker record. Nettleseren fanger lyd via `MediaRecorder` API (WebM/Opus).
3. Kan valgfritt velge kontekst: et Tema, en Aktør, eller "Usortert". 3. Kan valgfritt velge kontekst: et Tema, en Aktør, eller "Usortert".
4. Ved stopp lastes lydfilen opp til SvelteKit (streaming for store filer). 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/`). 5. Filen lagres som `media_file` i mediamappen (`/srv/synops/media/voice/`).
6. En `whisper_transcribe`-jobb opprettes i jobbkøen for å gjøre opptaket søkbart. 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. 7. Transkripsjonen lagres som metadata på noden — lydfilen forblir master.
@ -27,7 +27,7 @@ Voice-to-text er en del av chat-featuren (se `docs/features/chat.md`, seksjon 8)
Lydmeldingen er en node i Kunnskapsgrafen (`node_type = 'melding'`, `message_type = 'voice_memo'`): Lydmeldingen er en node i Kunnskapsgrafen (`node_type = 'melding'`, `message_type = 'voice_memo'`):
``` ```
nodes (workspace_id, node_type = 'melding') nodes (node_type = 'melding')
→ messages (channel_id, message_type = 'voice_memo', body = transkripsjon, metadata = { duration, transcription_status }) → messages (channel_id, message_type = 'voice_memo', body = transkripsjon, metadata = { duration, transcription_status })
→ message_attachments → media_files (lydfilen) → message_attachments → media_files (lydfilen)
``` ```
@ -70,7 +70,7 @@ AI Gateway (`sidelinja/rutine`) prosesserer transkripsjonen med instruksjon om
Dikterte notater er meldinger i en channel: Dikterte notater er meldinger i en channel:
``` ```
nodes (workspace_id, node_type = 'melding') nodes (node_type = 'melding')
→ messages (channel_id, message_type = 'text', body = ryddet tekst, metadata = { raw_transcript, source: 'dictation' }) → messages (channel_id, message_type = 'text', body = ryddet tekst, metadata = { raw_transcript, source: 'dictation' })
``` ```
@ -100,7 +100,7 @@ Begge moduser bruker live transkripsjonspipelinen (se `docs/features/live_transk
- Diktering: `medium` + `initial_prompt` (kvalitet prioritert — teksten er master) - Diktering: `medium` + `initial_prompt` (kvalitet prioritert — teksten er master)
### 4.4 Personlig "Innboks"-channel ### 4.4 Personlig "Innboks"-channel
Ved workspace-opprettelse opprettes en privat channel per bruker for usorterte lydmeldinger og notater. Config: Ved brukeropprettelse opprettes en privat channel per bruker for usorterte lydmeldinger og notater. Config:
```json ```json
{ {
@ -130,8 +130,8 @@ Ved workspace-opprettelse opprettes en privat channel per bruker for usorterte l
## 7. Instruks for Claude Code ## 7. Instruks for Claude Code
* Opptakskomponenten skal være en gjenbrukbar Svelte-komponent med modus-prop (`voice_memo` / `dictation`). * Opptakskomponenten skal være en gjenbrukbar Svelte-komponent med modus-prop (`voice_memo` / `dictation`).
* Lydfiler lagres i `media/{workspace_slug}/voice/` — aldri i databasen. * Lydfiler lagres i `media/voice/` — aldri i databasen.
* Diktering: slett lydfilen først *etter* at brukeren har godkjent den ryddede teksten. * Diktering: slett lydfilen først *etter* at brukeren har godkjent den ryddede teksten.
* `voice_memo` er en ny `message_type` — utvid enum i migrasjonen. * `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). * Personlig innboks-channel opprettes automatisk for nye brukere.
* Alt er workspace-scopet. * Tilgang styres via `node_access`-matrisen.

View file

@ -50,7 +50,7 @@ CREATE TABLE messages (
body TEXT NOT NULL, body TEXT NOT NULL,
metadata JSONB, -- Ekstra data per message_type metadata JSONB, -- Ekstra data per message_type
pinned BOOLEAN NOT NULL DEFAULT false, pinned BOOLEAN NOT NULL DEFAULT false,
visibility TEXT NOT NULL DEFAULT 'workspace', -- 'workspace' | 'private' visibility TEXT NOT NULL DEFAULT 'shared', -- 'shared' | 'private'
edited_at TIMESTAMPTZ, edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@ -65,11 +65,11 @@ CREATE TRIGGER trg_messages_updated_at BEFORE UPDATE ON messages
**Forskjeller fra gammel `messages`-tabell:** **Forskjeller fra gammel `messages`-tabell:**
- `id` er FK til `nodes(id)`**alle meldinger er noder** i kunnskapsgrafen - `id` er FK til `nodes(id)`**alle meldinger er noder** i kunnskapsgrafen
- Ingen `workspace_id`arves via `nodes.workspace_id` (RLS på nodes gjelder) - Ingen `workspace_id`tilgang styres via `node_access`-matrisen
- `channel_id` er nullable — notater og standalone-bokser trenger ikke en channel - `channel_id` er nullable — notater og standalone-bokser trenger ikke en channel
- `title` er førsteklasses felt — brukes av kanban-kort, notater, kalenderhendelser - `title` er førsteklasses felt — brukes av kanban-kort, notater, kalenderhendelser
- `pinned` for manuell fritak fra TTL-sletting - `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. - `visibility` styrer synlighet — `'shared'` (alle med tilgang via `node_access`) eller `'private'` (kun forfatter). Private meldingsbokser kan brukes som kladd for notater, kanban-kort og kalenderhendelser. Endre til `'shared'` for å dele.
### 3.1.1 Kontekst-navigering via `reply_to` ### 3.1.1 Kontekst-navigering via `reply_to`
@ -219,7 +219,7 @@ Ved nivå 3 tilbyr systemet: **"Skill ut som egen diskusjon?"**
## 8. Eierskap, kurasjon og prominens ## 8. Eierskap, kurasjon og prominens
### 8.1 Eierskap ### 8.1 Eierskap
Trådstarter og workspace-admin deler eierskap over en diskusjonstråd. Eierskap gir tilgang til kurasjonsverktøy (se 7.2). Trådstarter og admin for samlings-noden deler eierskap over en diskusjonstråd. Eierskap gir tilgang til kurasjonsverktøy (se 7.2).
### 8.2 Kurasjon (TODO — UI-features, bygges inkrementelt) ### 8.2 Kurasjon (TODO — UI-features, bygges inkrementelt)
Datamodellen trenger ikke endres for disse — alt håndteres via `messages.metadata` (JSONB): Datamodellen trenger ikke endres for disse — alt håndteres via `messages.metadata` (JSONB):
@ -240,7 +240,7 @@ Beregnes ved visning eller caches i materialized view ved behov. Algoritmen kan
## 9. TTL og livsløp ## 9. TTL og livsløp
### 9.1 To-trinns fading ### 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. 1. **Skjult fra visning** — meldingen forsvinner fra default UI etter TTL (arvet fra channel eller samlings-node). `messages.metadata.hidden_at` settes.
2. **Slettet** — etter tilleggsperiode (dobbel TTL) fjernes raden permanent. 2. **Slettet** — etter tilleggsperiode (dobbel TTL) fjernes raden permanent.
### 9.2 Alder som dynamisk faktor ### 9.2 Alder som dynamisk faktor
@ -260,7 +260,7 @@ Lever boksen, lever alt under den — svar beholdes uansett alder.
### 9.4 Konfigurerbarhet ### 9.4 Konfigurerbarhet
``` ```
Workspace-default TTL: 30 dager (workspaces.settings.default_ttl_days) Samlings-node default TTL: 30 dager (samlings-node metadata.default_ttl_days)
└── Channel kan overstyre: config.ttl_days └── Channel kan overstyre: config.ttl_days
└── Individuelle meldinger frittes via reglene over └── Individuelle meldinger frittes via reglene over
``` ```
@ -311,9 +311,9 @@ Migrasjonen konverterer eksisterende data:
- **Kanban-kort:** INSERT i `nodes` + `messages` (med tittel) + `kanban_card_view`. É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. - **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. - **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. - **Notat:** INSERT i `nodes` + `messages` (med tittel + body). `channel_id` peker på en personlig eller delt channel, eller er NULL.
- **Konvertering mellom roller:** Legg til view-config-rad (kanban/kalender). Aldri kopier data. - **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. - **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. - **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. - **Tilgang:** Styres via `node_access`-matrisen. Visibility håndteres i applikasjonskode (SvelteKit): `WHERE visibility = 'shared' OR author_id = $current_user`.
- **Visibility:** Default `'workspace'`. Sett `'private'` for personlige kladder. Endre til `'workspace'` for å dele — ingen kopiering nødvendig. - **Visibility:** Default `'shared'`. Sett `'private'` for personlige kladder. Endre til `'shared'` for å dele — ingen kopiering nødvendig.

View file

@ -9,7 +9,7 @@ Et enkelt notatverktøy med automatisk lagring. Brukes som scratchpad i ulike ko
### Implementert ### Implementert
- Migrering `0004_notes.sql`: `notes`-tabell (FK→nodes) - Migrering `0004_notes.sql`: `notes`-tabell (FK→nodes)
- Notater er nodes — arver workspace-isolasjon automatisk - Notater er nodes — tilgangsstyrt via `node_access`-matrise
- Auto-save med 500ms debounce (visuell feedback: "Lagrer..."/"Lagret") - Auto-save med 500ms debounce (visuell feedback: "Lagrer..."/"Lagret")
- REST API: GET og PATCH (tittel + innhold) - REST API: GET og PATCH (tittel + innhold)
- PG polling-adapter med 10 sek intervall (tregere enn chat/kanban — notater endres sjeldnere) - PG polling-adapter med 10 sek intervall (tregere enn chat/kanban — notater endres sjeldnere)
@ -62,7 +62,7 @@ CREATE TABLE notes (
- Polling (10 sek) henter siste versjon, men hopper over overskriving mens `saving`-flagget er satt - Polling (10 sek) henter siste versjon, men hopper over overskriving mens `saving`-flagget er satt
## 7. Instruks for Claude Code ## 7. Instruks for Claude Code
* Notater er workspace-scopet via node-modellen * Tilgang til notater styres via `node_access`-matrisen
* Auto-save bruker debounce — ikke send PATCH ved hvert tastetrykk * Auto-save bruker debounce — ikke send PATCH ved hvert tastetrykk
* `updated_at` brukes til UI-feedback, ikke til conflict resolution (ennå) * `updated_at` brukes til UI-feedback, ikke til conflict resolution (ennå)
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi * Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi

View file

@ -5,13 +5,13 @@
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. 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 ## 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`). 1. **Rådata (Caddy):** Caddy konfigureres til å skrive access-logs for stien `/media/podcast/*.mp3` til en formatert JSON-fil (f.eks. `/srv/synops/logs/caddy/podcast_access.log`).
2. **Logrotate:** Standard Linux logrotate arkiverer loggene nattlig. 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. 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 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 B (Deduplisering):** Slår sammen byte-range forespørsler. Hvis samme IP og User-Agent har lastet ned deler av samme fil innenfor et 24-timers vindu, telles det som KUN én (1) nedlasting.
* **Steg C (Geografi/Klient):** Mapper User-Agent til Podcast-klient (Spotify, Apple) basert på OPAWG-regler. * **Steg 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}/...`). 4. **Lagring (PostgreSQL):** Rust-programmet skriver det aggregerte resultatet inn i PostgreSQL (`episode_stats` tabell med felter for `tenant_id`, `date`, `episode_id`, `client_name`, `unique_downloads`). Tenant-tilhørighet utledes fra filstien i loggen (`/media/{tenant_slug}/...`).
## 3. Personvern og GDPR ## 3. Personvern og GDPR
@ -27,4 +27,4 @@ Caddy access-logger inneholder IP-adresser og User-Agent — dette er personoppl
* Bruk Rust-biblioteket `serde_json` for rask parsing av Caddy-loggene. * 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). * 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. * Rålogger skal ALDRI lagres i PostgreSQL.
* Statistikk er workspace-scopet. `episode_stats` merkes med `workspace_id`. Admin-visningen filtrerer per workspace. * Statistikk er tenant-scopet. `episode_stats` merkes med `tenant_id`. Admin-visningen filtrerer per tenant.

View file

@ -1,8 +1,10 @@
# Feature: Prompt-Laboratorium # Feature: Prompt-Laboratorium
**Filsti:** `docs/features/prompt_lab.md` **Filsti:** `docs/features/prompt_lab.md`
> **NB:** Dette dokumentet er en skisse fra v1 og må oppdateres til node/edge-modellen ved implementering.
## 1. Konsept ## 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. Et internt kvalitetssikringsverktøy der redaksjonen kan teste, sammenligne og godkjenne LLM-prompts mot faktiske data fra egen samlings-node — før de ruller ut i produksjon. Integrert med AI Gateway (LiteLLM) og Promptfoo-testsettene.
## 2. Problemet det løser ## 2. Problemet det løser
- Modellkvalitet på norsk varierer kraftig mellom leverandører og versjoner. - Modellkvalitet på norsk varierer kraftig mellom leverandører og versjoner.
@ -14,9 +16,9 @@ Et internt kvalitetssikringsverktøy der redaksjonen kan teste, sammenligne og g
### 3.1 Playground (Ad-hoc testing) ### 3.1 Playground (Ad-hoc testing)
1. Bruker velger en **jobbtype** (research_clip, whisper_postprocess, metadata_extract, dictation_cleanup, etc.). 1. Bruker velger en **jobbtype** (research_clip, whisper_postprocess, metadata_extract, dictation_cleanup, etc.).
2. Systemet laster inn gjeldende system-prompt fra `workspaces.settings`. 2. Systemet laster inn gjeldende system-prompt fra samlings-nodens metadata.
3. Bruker kan redigere prompten i et tekstfelt. 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. 4. Velger **testdata**: enten paste inn tekst manuelt, eller velg fra faktiske transkripsjoner/artikler brukeren har tilgang til.
5. Velger **modeller** å teste mot (f.eks. `sidelinja/rutine` + `sidelinja/resonering`). 5. Velger **modeller** å teste mot (f.eks. `sidelinja/rutine` + `sidelinja/resonering`).
6. Kjører testen — ser resultatene side-om-side. 6. Kjører testen — ser resultatene side-om-side.
7. Kan iterere: endre prompten, kjør igjen, sammenlign. 7. Kan iterere: endre prompten, kjør igjen, sammenlign.
@ -29,8 +31,8 @@ Et internt kvalitetssikringsverktøy der redaksjonen kan teste, sammenligne og g
### 3.3 Deploy ### 3.3 Deploy
Når en prompt er verifisert: Når en prompt er verifisert:
1. Bruker klikker "Deploy til workspace". 1. Bruker klikker "Deploy".
2. Prompten skrives til `workspaces.settings.llm_prompts[jobbtype]`. 2. Prompten skrives til samlings-nodens metadata (`metadata.llm_prompts[jobbtype]`).
3. Alle fremtidige jobber av den typen bruker den nye prompten. 3. Alle fremtidige jobber av den typen bruker den nye prompten.
4. Tidligere prompt lagres i en `prompt_history`-logg for rollback. 4. Tidligere prompt lagres i en `prompt_history`-logg for rollback.
@ -41,7 +43,7 @@ Når en prompt er verifisert:
```sql ```sql
prompt_test_runs ( prompt_test_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, context_node UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, -- samlings-node
job_type TEXT NOT NULL, job_type TEXT NOT NULL,
system_prompt TEXT NOT NULL, system_prompt TEXT NOT NULL,
model_alias TEXT NOT NULL, model_alias TEXT NOT NULL,
@ -53,7 +55,7 @@ prompt_test_runs (
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE INDEX idx_prompt_runs_workspace ON prompt_test_runs(workspace_id, job_type, created_at DESC); CREATE INDEX idx_prompt_runs_context ON prompt_test_runs(context_node, job_type, created_at DESC);
``` ```
`prompt_test_runs` er **ikke** en node i grafen — det er en intern verktøytabell for utviklere/redaktører. `prompt_test_runs` er **ikke** en node i grafen — det er en intern verktøytabell for utviklere/redaktører.
@ -63,7 +65,7 @@ CREATE INDEX idx_prompt_runs_workspace ON prompt_test_runs(workspace_id, job_typ
```sql ```sql
prompt_history ( prompt_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, context_node UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, -- samlings-node
job_type TEXT NOT NULL, job_type TEXT NOT NULL,
system_prompt TEXT NOT NULL, system_prompt TEXT NOT NULL,
deployed_by TEXT NOT NULL REFERENCES users(authentik_id), deployed_by TEXT NOT NULL REFERENCES users(authentik_id),
@ -92,10 +94,10 @@ Muliggjør rollback: "forrige prompt for research_clip fungerte bedre, rull tilb
| `prompt_eval` | (varierer) | Batch-evaluering av testsett mot valgte modeller | | `prompt_eval` | (varierer) | Batch-evaluering av testsett mot valgte modeller |
## 7. Instruks for Claude Code ## 7. Instruks for Claude Code
* Prompt Lab er et admin-verktøy — krev `admin` eller `owner`-rolle i workspace. * Prompt Lab er et admin-verktøy — krev admin-tilgang til samlings-noden.
* Ad-hoc tester kjøres synkront (SvelteKit → AI Gateway → respons). Ikke bruk jobbkø for enkelt-tester. * 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. * Batch-evaluering kjøres via jobbkø for å unngå timeouts.
* Vis alltid token-bruk og latens — dette er et kostnadsbevisst verktøy. * 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. * Testdata (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. * `prompt_test_runs` og `prompt_history` er knyttet til en samlings-node og trenger ikke RLS — de er kun tilgjengelige for admins via applikasjonslogikk.
* Alt er workspace-scopet. * Tilgang styres via `node_access`-matrisen.

View file

@ -88,7 +88,7 @@ Høyreklikk kort → "Send til..." →
└── 🎬 Storyboard: Episode 47 └── 🎬 Storyboard: Episode 47
``` ```
Listen viser alle blokker i det aktive workspacet som kan motta meldinger. Listen viser alle blokker brukeren har tilgang til som kan motta meldinger.
### 3.3 Flytt vs. kopier ### 3.3 Flytt vs. kopier
@ -148,7 +148,6 @@ pub struct MessagePlacement {
pub context_id: String, pub context_id: String,
pub entered_at: Timestamp, pub entered_at: Timestamp,
pub position_json: String, // JSON-serialisert posisjon pub position_json: String, // JSON-serialisert posisjon
pub workspace_id: String,
} }
#[reducer] #[reducer]

View file

@ -17,4 +17,4 @@ En interaktiv graf-visning i SvelteKit som gjør Kunnskapsgrafen visuelt naviger
## 4. Instruks for Claude Code ## 4. Instruks for Claude Code
* Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (D3.js, Vis.js). * 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. * Begrens traversering til 2-3 ledd ut fra startnode for å unngå eksplosjoner i store grafer.
* Workspace-scopet: alle noder filtreres via `nodes.workspace_id`. * Tilgangsstyrt: noder filtreres via `node_access`-matrisen.

View file

@ -14,7 +14,7 @@ Et delt, sanntids tegnebrett for frihåndsskisser, diagrammer og visuell brainst
## 3. Sanntidssynkronisering ## 3. Sanntidssynkronisering
* **SpacetimeDB** synkroniserer strøk (penseltype, farge, koordinater) mellom alle deltakere i sanntid. * **SpacetimeDB** synkroniserer strøk (penseltype, farge, koordinater) mellom alle deltakere i sanntid.
* Hvert whiteboard har en unik ID og tilhører en workspace. * Hvert whiteboard har en unik ID og er en node i grafen.
* Tilgangskontroll følger konteksten: møterom-deltakere, tema-medlemmer, eller kun eieren for personlige tavler. * Tilgangskontroll følger konteksten: møterom-deltakere, tema-medlemmer, eller kun eieren for personlige tavler.
## 4. Funksjonalitet ## 4. Funksjonalitet
@ -24,11 +24,11 @@ Et delt, sanntids tegnebrett for frihåndsskisser, diagrammer og visuell brainst
## 5. Lagring og Eksport ## 5. Lagring og Eksport
* **Sanntidsdata (SpacetimeDB):** Strøk-historikk holdes i minnet så lenge tavlen er aktiv. * **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. * **Eksport (PostgreSQL + filsystem):** Når tavlen "lukkes" eller deles, rendres den til PNG eller SVG og lagres som en `media_file`. Referansen knyttes til konteksten (melding, møte) via `message_attachments` eller tilsvarende.
* **Dataklassifisering:** Strøk-data i SpacetimeDB er flyktig (kategori 4). Eksporterte bilder er avledet (kategori 3) — kan gjenskapes fra strøk-data så lenge tavlen er aktiv, men etter lukking er bildet den permanente kopien. * **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 ## 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. * 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`. * 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. * Ikke bygg et fullverdig tegneprogram — start med frihåndstegning, viskelær og farger. Utvid ved behov.
* Alle whiteboards er workspace-scopet. * Tilgang til whiteboards styres via `node_access`-matrisen.

View file

@ -2,7 +2,7 @@
**Filsti:** `docs/infra/ai_gateway.md` **Filsti:** `docs/infra/ai_gateway.md`
## 1. Konsept ## 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. Synops bruker en sentralisert AI Gateway (LiteLLM) som eneste kontaktpunkt for alle AI-kall i systemet. All kode — Rust-workers, SvelteKit server-side — snakker med `http://ai-gateway:4000/v1`. Aldri direkte til leverandør-APIer.
Fordeler: Fordeler:
* **BYOK (Bring Your Own Key):** Direkte API-nøkler til Anthropic, Google, xAI — ingen markup * **BYOK (Bring Your Own Key):** Direkte API-nøkler til Anthropic, Google, xAI — ingen markup
@ -36,7 +36,7 @@ PostgreSQL er single source of truth for all modellkonfigurasjon. LiteLLM er en
### 3.2 Datamodell ### 3.2 Datamodell
```sql ```sql
-- Globale modellaliaser (server-nivå, ikke per workspace) -- Globale modellaliaser (server-nivå)
CREATE TABLE ai_model_aliases ( CREATE TABLE ai_model_aliases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alias TEXT NOT NULL, -- 'sidelinja/rutine', 'sidelinja/resonering' alias TEXT NOT NULL, -- 'sidelinja/rutine', 'sidelinja/resonering'
@ -180,14 +180,14 @@ tests/prompts/
## 6. Tokenregnskap og kostnadskontroll ## 6. Tokenregnskap og kostnadskontroll
### 6.1 Token-logging per workspace ### 6.1 Token-logging per samlings-node
Rust-workeren logger tokenforbruk etter hvert AI-kall. Dataen lagres i PG: Rust-workeren logger tokenforbruk etter hvert AI-kall. Dataen lagres i PG:
```sql ```sql
CREATE TABLE ai_usage_log ( CREATE TABLE ai_usage_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, collection_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
job_id UUID REFERENCES job_queue(id) ON DELETE SET NULL, job_id UUID REFERENCES job_queue(id) ON DELETE SET NULL,
model_alias TEXT NOT NULL, -- 'sidelinja/rutine' model_alias TEXT NOT NULL, -- 'sidelinja/rutine'
model_actual TEXT, -- 'gemini/gemini-2.5-flash' (fra LiteLLM-respons) model_actual TEXT, -- 'gemini/gemini-2.5-flash' (fra LiteLLM-respons)
@ -199,30 +199,30 @@ CREATE TABLE ai_usage_log (
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE INDEX idx_ai_usage_workspace_month ON ai_usage_log (workspace_id, created_at); CREATE INDEX idx_ai_usage_collection_month ON ai_usage_log (collection_node_id, created_at);
``` ```
**Flyten:** **Flyten:**
1. Rust-worker sender AI-kall via gateway, får tilbake `usage` i responsen 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 2. Worker skriver rad til `ai_usage_log` med collection_node_id, tokens og modellinfo
3. Estimert kostnad beregnes fra en enkel prisliste i config (oppdateres manuelt) 3. Estimert kostnad beregnes fra en enkel prisliste i config (oppdateres manuelt)
### 6.2 Visning to nivåer ### 6.2 Visning -- to nivåer
**Admin (`/admin/ai`):** **Admin (`/admin/ai`):**
Aggregert oversikt over alle workspaces. Tabell med totaler per workspace/modell/periode. Identifiserer kostnadsdrivere. Aggregert oversikt over alle samlings-noder. Tabell med totaler per samlings-node/modell/periode. Identifiserer kostnadsdrivere.
**Workspace (sidebar-widget):** **Samlings-node (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. Enkel tekst-indikator i sidebar: `12.4k tokens denne uken`. Klikk åpner detaljert visning med fordeling per jobbtype og modell. Ingen speedometer -- det krever et definert budsjett for å gi mening, og det er overkill for MVP.
### 6.3 Workspace-budsjett (fase 2) ### 6.3 Budsjett per samlings-node (fase 2)
Når token-logging er på plass, kan budsjett-tak legges til: Når token-logging er på plass, kan budsjett-tak legges til:
- Budsjett lagres i `workspaces.settings` (JSONB): `{ "ai_budget": { "monthly_limit_usd": 50 } }` - Budsjett lagres som JSONB-metadata på samlings-noden: `{ "ai_budget": { "monthly_limit_usd": 50 } }`
- Rust-worker sjekker aggregert forbruk før AI-kall - Rust-worker sjekker aggregert forbruk før AI-kall
- Ved budsjett nær: fall tilbake til `sidelinja/rutine` (billigste) - Ved budsjett nær: fall tilbake til `sidelinja/rutine` (billigste)
- Ved budsjett nådd: sett jobb i `paused` med varsel i workspace-chat - Ved budsjett nådd: sett jobb i `paused` med varsel i samlings-nodens chat
### 6.4 Per-episode maks-kostnad ### 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). 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).

View file

@ -18,7 +18,7 @@ CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'error', 'ret
CREATE TABLE job_queue ( CREATE TABLE job_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, collection_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, -- Samlings-node jobben tilhører
job_type TEXT NOT NULL, -- 'whisper_transcribe', 'openrouter_analyze', 'stats_parse', 'research_clip' job_type TEXT NOT NULL, -- 'whisper_transcribe', 'openrouter_analyze', 'stats_parse', 'research_clip'
payload JSONB NOT NULL, -- Inputdata (filsti, tekst, tema_id, etc.) payload JSONB NOT NULL, -- Inputdata (filsti, tekst, tema_id, etc.)
status job_status NOT NULL DEFAULT 'pending', status job_status NOT NULL DEFAULT 'pending',
@ -120,11 +120,11 @@ Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på
| `url_ingest` | Web Clipper (proposal) | Hent URL, oppsummer via AI, opprett research-klipp med graf-koblinger | | `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 | | `generate_waveform` | Waveforms (proposal) | Generer audio-peaks fra lydfil for visuell bølgeform |
## 6. Workspace-isolasjon ## 6. Tilgangsisolasjon
Alle jobber merkes med `workspace_id`. Rust-workers kjører som superuser (bypasser RLS) og sikrer isolasjon i applikasjonskode: Alle jobber merkes med `collection_node_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 * Worker leser `collection_node_id` fra jobben og bruker det til å lagre resultater tilbake i riktig samlings-node
* Workspace-spesifikk config (AI-prompts, navnelister) hentes fra `workspaces.settings` * Per samlings-node config (AI-prompts, navnelister) hentes fra samlings-nodens JSONB-metadata
* Feilede jobber vises kun for brukere i riktig workspace i admin-visningen * Feilede jobber vises kun for brukere med tilgang til samlings-noden (via node_access) i admin-visningen
## 7. Observabilitet ## 7. Observabilitet
- Jobber med `status = 'error'` skal være synlige i admin-visningen (SvelteKit `/admin/jobs`) - Jobber med `status = 'error'` skal være synlige i admin-visningen (SvelteKit `/admin/jobs`)
@ -135,6 +135,6 @@ Alle jobber merkes med `workspace_id`. Rust-workers kjører som superuser (bypas
- Hver jobbtype implementeres som en handler-funksjon som registreres i en `HashMap<String, Handler>` - Hver jobbtype implementeres som en handler-funksjon som registreres i en `HashMap<String, Handler>`
- Bruk `tokio` med semaphore for concurrency-kontroll (`--max-concurrent`) - Bruk `tokio` med semaphore for concurrency-kontroll (`--max-concurrent`)
- Aldri lagre lydfiler i `payload` — bruk filstier - Aldri lagre lydfiler i `payload` — bruk filstier
- Opprett alltid jobber med riktig `workspace_id` — hent fra konteksten (innlogget bruker, webhook, etc.) - Opprett alltid jobber med riktig `collection_node_id` — hent fra konteksten (innlogget bruker, webhook, etc.)
- Ved `stats_parse`: denne erstatter den frittstående cronjobben beskrevet i podcast_statistikk.md — bruk jobbkøen med `scheduled_for` for periodisk kjøring - 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 - Splitt til flere binærer kun hvis det blir eksplisitt bedt om — start med én

View file

@ -1,176 +1,106 @@
# Infrastruktur: PostgreSQL ↔ SpacetimeDB Synkronisering # Synkronisering: SpacetimeDB ↔ PostgreSQL
**Filsti:** `docs/infra/synkronisering.md`
## 1. Konsept ## 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 holder hele grafen (alle noder og edges) i minne.
SpacetimeDB skal **aldri** være eneste lagringssted for data med varig verdi. All data som SpacetimeDB holder skal enten: PostgreSQL er persistent backup og arkiv. Skriving går til begge.
1. Allerede eksistere i PostgreSQL (read-only cache, f.eks. aktør-navn for autocomplete), eller Lesing går fra SpacetimeDB.
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 ┌──────────────┐ GUI (SvelteKit)
│ SpacetimeDB │ ──────────────► │ Rust Worker │ ────────────────► │ PostgreSQL │ │ skriv │ les (sanntid)
│ (sanntid) │ │ (sync_to_pg) │ │ (persistent)│ ▼ ▼
└──────────────┘ └──────────────┘ └──────────────┘ Maskinrommet (Rust) SpacetimeDB ──→ GUI
│ ▲
┌──────────────┐ oppvarming (oppstart / reconnect) ┌──────────────┐ ├──→ PostgreSQL (persistent) │
│ PostgreSQL │ ──────────────────────────────────────► │ SpacetimeDB │ └──→ SpacetimeDB ───────────┘
└──────────────┘ └──────────────┘
``` ```
## 4. Eierskapsmodell ## Hvorfor hele grafen i SpacetimeDB
| Data | Autoritativ kilde | Synkretning | Merknad | Node- og edge-skjemaet er minimalt: åtte kolonner på nodes, åtte
|---|---|---|---| på edges. For en liten brukerbase er hele grafen triviell å holde
| Chatmeldinger | SpacetimeDB | → PG (event, batched) | | i minne. Dette forenkler alt:
| 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 - Ingen sync-logikk for å bestemme hva som er "aktivt"
- Ingen henting fra PG når noen åpner noe gammelt
- Frontend har alltid alt tilgjengelig via WebSocket
- Én lesekilde — ingen tvetydighet
### 5.1 SpacetimeDB → PostgreSQL (persistering) Når dette *blir* problematisk (hundretusenvis av noder, minnepress),
- SpacetimeDB-reducerne legger sync-events i `sync_outbox`-tabellen med tidsstempel og payload innføres eviction basert på aksessmønstre. Men det er en
- Rust-workeren poller `sync_outbox` hvert sekund, leser alle usynkede events, skriver til PostgreSQL, og markerer dem som synket via `mark_synced`-reducer optimaliseringsbeslutning, ikke en arkitekturbeslutning. Modellen
- Ved PG-nedetid: events akkumuleres i `sync_outbox`. Workeren prøver igjen ved neste poll. Ingen data tapes så lenge SpacetimeDB kjører endres ikke.
### 5.2 PostgreSQL → SpacetimeDB (oppvarming) ## Skrivestien
- 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 Maskinrommet validerer, så skriver i to steg:
- **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 1. **Validering** — tilgangssjekk, unique-constraints, forretningsregler
SpacetimeDB-synkronisering er fullstendig workspace-scopet: 2. **SpacetimeDB først** — frontend oppdateres umiddelbart (~10μs)
* SpacetimeDB-tilkoblinger bærer `workspace_id` som context-token. Modulen partisjonerer minnet og kringkaster kun til klienter i samme workspace. 3. **PG asynkront** — persistent backup i bakgrunnen
* `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 Frontend er allerede oppdatert mens PG-skrivingen skjer. Brukeren
merker ingenting.
### 8.1 Strategi: Last-Write-Wins (LWW) med reservasjoner Hvis PG-skrivingen feiler: maskinrommet logger og prøver igjen.
SpacetimeDB er autoritativ for sanntidsdata. Konflikter kan oppstå i to scenarioer: SpacetimeDB har dataen — den er bare ikke persistert ennå. Ingen
datatap så lenge SpacetimeDB kjører.
**Scenario A — Samtidig redigering i SpacetimeDB:** ## Lesestien
SpacetimeDB er single-threaded per modul. Reducer-funksjoner serialiseres automatisk. Ingen klassiske race conditions.
**Scenario B — PG-oppvarming kolliderer med fersk SpacetimeDB-state:** Frontend leser **kun fra SpacetimeDB** via WebSocket-subscriptions.
Ved restart av SpacetimeDB lastes data fra PG (oppvarming). Hvis en klient skriver til SpacetimeDB *under* oppvarming, kan PG-dataen overskrive ferske endringer. Ingen PG-kall fra frontend for data som vises i sanntid.
**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. Unntak: tunge spørringer som ikke passer sanntidslaget — statistikk,
fulltekstsøk, pgvector-søk, AGE-traverseringer. Disse går
GUI → Maskinrommet → PG.
### 8.2 Kanban: Posisjonskonflikter ## Oppvarming (PG → SpacetimeDB)
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 Ved oppstart av SpacetimeDB laster maskinrommet hele grafen fra PG:
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 1. Alle noder
2. Alle edges
3. `node_access`-matrisen
**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. Rekkefølge: noder først (edges refererer til noder).
CAS-noder lastes uten binærinnhold (bare metadata).
### Eksempel: AI-vask av chatmelding ## Feilhåndtering
**Riktig flyt:** | Scenario | Konsekvens | Håndtering |
``` |----------|-----------|------------|
Frontend viser melding fra SpacetimeDB | SpacetimeDB krasjer | Sanntid forsvinner, upersistert data kan tapes | Restart + oppvarming fra PG. Tap begrenset til skrivinger som ikke rakk PG. |
→ Bruker trykker ✨ | PG nede | Persistering stopper | SpacetimeDB serverer lesing og mottar skrivinger. PG-backlog tas igjen ved recovery. |
→ SvelteKit oppretter jobb i PG job_queue | Maskinrommet krasjer | Ingen skriving | Frontend ser siste state. Restart plukker opp. |
→ 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):** ## SpacetimeDB som utbyttbar
```
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? SpacetimeDB er en sanntidscache, ikke en avhengighet. Hvis den
fjernes fra stacken:
Når frontend trenger å vise noe nytt (metadata, revisjoner, edited_at), er prosedyren: - **Sanntid:** PG `LISTEN/NOTIFY` → SvelteKit SSE
- **Skriving:** Maskinrommet → PG direkte
- **Lesing:** Maskinrommet → PG → GUI
1. **Utvid SpacetimeDB-modulen** — legg til feltet i Rust-strukturen Denne fallbacken trenger ikke implementeres, men arkitekturen
2. **Utvid warmup** — last feltet fra PG ved oppstart skal aldri gjøre den umulig.
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? ## Tilgangsmatrise i SpacetimeDB
Workers kan og bør lese infrastrukturdata direkte fra PG: `node_access`-matrisen lastes inn i SpacetimeDB ved oppvarming.
- Jobbkø (`job_queue`) — det er jobbens opphavssted Når maskinrommet oppdaterer matrisen i PG (ved edge-endring),
- AI-prompts, modellkonfigurasjon, routing — det er infrastruktur, ikke brukerdata oppdaterer den også SpacetimeDB. SpacetimeDB-modulen bruker
- Workspace-konfigurasjon matrisen for å filtrere subscriptions — klienter ser kun noder
de har tilgang til.
Skillet er: **data frontend viser** → SpacetimeDB. **Data kun worker trenger** → PG direkte. ## Konflikthåndtering
## 10. Implementeringsstatus (mars 2026) SpacetimeDB er single-threaded per modul — reducer-funksjoner
serialiseres automatisk. Ingen klassiske race conditions.
### Ferdig Samtidig redigering (to brukere endrer samme node): maskinrommet
- **SpacetimeDB som cache foran PG:** PG er autoritativ, SpacetimeDB er varm cache. Frontend snakker kun med SpacetimeDB. serialiserer via SpacetimeDB. Last-write-wins. 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`. kringkaster resultatet til alle klienter. PG oppdateres asynkront
- **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. med det endelige resultatet.
- **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``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

117
docs/primitiver/edges.md Normal file
View file

@ -0,0 +1,117 @@
# Edges — spesifikasjon
**Status: Besluttet.**
> Edges er relasjoner mellom noder. De er retningsbestemte, typede med
> freeform strenger, og kan bære metadata. Hva en node "er" bestemmes
> av dens edges, ikke av noden selv.
## Skjema
```sql
CREATE TABLE edges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
target_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
edge_type TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES nodes(id),
UNIQUE (source_id, target_id, edge_type)
);
CREATE INDEX idx_edges_source ON edges (source_id);
CREATE INDEX idx_edges_target ON edges (target_id);
CREATE INDEX idx_edges_type ON edges (edge_type);
```
## Retning
Edges er retningsbestemte: `source_id → target_id`. "Vegard eier
Sidelinja" er en annen edge enn "Sidelinja eier Vegard" (som ikke
gir mening). Retningen leses naturlig:
```
Vegard ──owner──→ Sidelinja (Vegard eier Sidelinja)
Referat ──belongs_to──→ Møte (referatet tilhører møtet)
Episode ──mentions──→ Jonas Gahr Støre (episoden nevner ham)
```
Symmetriske relasjoner (f.eks. vennskap) modelleres som to edges
eller som en edge-type maskinrommet vet er symmetrisk.
## Edge-typer
Freeform strenger med konvensjoner — ikke en hard enum. Nye
relasjonstyper krever ingen migrering. Systemkritiske typer
valideres i maskinrommet.
### Kjente edge-typer
| Type | Betydning | Metadata |
|------|-----------|----------|
| `owner` | Eier noden | — |
| `admin` | Administrerer noden | — |
| `member_of` | Er medlem av | — |
| `reader` | Kan kun lese | — |
| `belongs_to` | Tilhører (innhold → samling) | — |
| `alias` | Identitet (systemedge) | `system: true` |
| `mentions` | Refererer til | — |
| `reply_to` | Svar på | — |
| `scheduled` | Tidsplanlagt | `{ "at": "2026-03-20T14:00Z" }` |
| `status` | Tilstand | `{ "value": "done" }` |
| `tagged` | Merket med | `{ "tag": "urgent" }` |
| `host_of` | Vert i | — |
Listen vokser organisk. Nye typer legges til ved behov uten
skjemaendring.
## Metadata
JSONB-feltet bærer kontekstspesifikk data om relasjonen:
- `status`-edge: `{ "value": "in_progress" }`
- `scheduled`-edge: `{ "at": "2026-03-20T14:00Z" }`
- `tagged`-edge: `{ "tag": "urgent", "color": "#ff0000" }`
Metadata er fleksibelt og spørrbart uten migrering.
## Systemedges
Edges med `system: true` er usynlige ved traversering. De
eksisterer for systemet, ikke for brukere.
Kjente systemedges:
- `alias` — identitetskobling, aldri eksponert
## Unique-constraint
`UNIQUE (source_id, target_id, edge_type)` betyr: én edge av
hver type mellom to noder. Vegard kan ha `owner`-edge *og*
`member_of`-edge til Sidelinja, men ikke to `owner`-edges.
## Hva edges gjør med noder
Edges definerer hva en node "er" — ikke noden selv:
```
Node + member_of → kanal = chatmelding
Node + belongs_to → board
+ status → "todo" = kanban-kort
Node + scheduled → tidspunkt = kalenderoppføring
Node + belongs_to → kun bruker = privat notat
Node + mentions → topic = faktoid i kunnskapsgrafen
Node uten edges = løs tanke
```
Retyping er å endre edges. Ingen datamigrasjon.
## Tilgangsmatrise-oppdatering
Når en edge med tilgangsrolle (`owner`, `admin`, `member_of`,
`reader`) opprettes eller slettes, oppdaterer maskinrommet
`node_access`-matrisen i samme transaksjon. Se
[noder er sentrum](../retninger/bruker_ikke_workspace.md)
for full spesifikasjon.

140
docs/primitiver/nodes.md Normal file
View file

@ -0,0 +1,140 @@
# Nodes — spesifikasjon
**Status: Besluttet.**
> Alt er noder. Brukere, team, prosjekter, innhold, møter, mediefiler,
> kunnskapsgraf-entiteter — alt er rader i `nodes`-tabellen. Hva en
> node "er" bestemmes av dens edges, ikke av noden selv.
## Skjema
```sql
CREATE TYPE visibility AS ENUM ('hidden', 'discoverable', 'readable', 'open');
CREATE TABLE nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
node_kind TEXT NOT NULL DEFAULT 'content',
title TEXT,
content TEXT,
visibility visibility NOT NULL DEFAULT 'hidden',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES nodes(id)
);
CREATE INDEX idx_nodes_kind ON nodes (node_kind);
CREATE INDEX idx_nodes_created_by ON nodes (created_by);
CREATE INDEX idx_nodes_visibility ON nodes (visibility);
```
## Kolonner
### `id`
UUID, generert. Primærnøkkel for alt i systemet.
### `node_kind`
Hint om hva noden primært er. Freeform streng — ikke en enum,
samme filosofi som edge-typer. Edges kan gi noden flere roller
utover dette.
Kjente node_kinds:
| Kind | Eksempel |
|------|----------|
| `person` | Vegard, Trond, Bjørn (alias) |
| `team` | Podcastteamet |
| `collection` | Sidelinja Podcast, Research-gruppa |
| `content` | Chatmelding, bloggpost, notat, dagboknotis |
| `communication` | Møte, samtale, livesending |
| `topic` | Kunnskapsgraf-entitet (Jonas Gahr Støre, Skolepolitikk) |
| `media` | CAS-node (lydfil, bilde, video) |
Listen vokser organisk etter behov.
### `title`
Det du viser overalt: i lister, søkeresultater, mottaksflaten.
Universelt — navnet på en person, tittelen på en bloggpost,
navnet på en podcast.
- Vegard: `'Vegard'`
- Sidelinja: `'Sidelinja Podcast'`
- Bloggpost: `'Hvorfor noder er sentrum'`
- Chatmelding: `NULL` (har sjelden tittel)
### `content`
Primært tekstinnhold. Det du leser, søker i, viser i detalj.
Blogginnhold, chatmeldinger, transkripsjoner.
- Bloggpost: hele artikkelen
- Chatmelding: `'Hei, er du klar?'`
- Voice memo: `NULL` ved opprettelse, fylles etter transkribering
- Brukernode: `NULL`
- CAS-node: `NULL` (binærdata lever på disk)
### `visibility`
Default synlighet for alle uten eksplisitt edge.
Se [noder er sentrum](../retninger/bruker_ikke_workspace.md)
for full spesifikasjon av visibility-nivåer og traverseringsregelen.
### `metadata`
JSONB for alt typespesifikt som ikke er tittel eller tekstinnhold:
- Person: `{ "display_name": "Vegard", "preferences": { ... } }`
- CAS-node: `{ "cas_hash": "abc123", "mime": "audio/mp3", "size_bytes": 84000000 }`
- Kommunikasjonsnode: `{ "started_at": "...", "ended_at": "..." }`
- Samlings-node: `{ "pruning_profile": "conservative", "theme": "dark" }`
### `created_at`
Tidsstempel, automatisk.
### `created_by`
Referanse til noden som opprettet denne noden. For alias-bruk:
`created_by` settes til aliasnoden, ikke brukernoden bak.
## Brukernoder
En brukernode er en node med `node_kind = 'person'` og en rad i
`auth_identities`:
```sql
CREATE TABLE auth_identities (
node_id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
authentik_sub TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL
);
```
Alt annet — profil, preferanser, relasjoner — er noden og dens
edges. Autentiseringstabellen er en tynn bro mellom HTTP-sesjonen
og grafen.
## CAS-noder (mediefiler)
Binærfiler er noder med `node_kind = 'media'`. Selve biten lever
på disk i CAS-lageret. Noden bærer bare metadata.
```
Episode #42 (content)
──has_media──→ CAS-node (media, metadata: { cas_hash: "abc123", mime: "audio/mp3" })
──has_media──→ CAS-node (media, metadata: { cas_hash: "def456", mime: "text/srt" })
──belongs_to──→ Sidelinja Podcast (collection)
```
En innholdsnode kan ha mange mediefiler via edges. Pruning-logikken
i maskinrommet opererer på CAS-noder — sletter binærfilen fra disk,
men noden kan leve videre som tombstone.
## Eierskap og tilgang
`created_by` gir redigeringsrett til egne noder. Men sletting og
strukturelle endringer scopes av rollen i konteksten:
- Du kan alltid redigere noder du opprettet.
- Sletting krever `owner` eller `admin` i samlings-noden
innholdet tilhører — selv om du opprettet det.
- En privat node (ingen samlings-edge) kan slettes fritt av creator.
Flere noder kan ha `owner`-tilgang til samme node via
tilgangsmatrisen (direkte og transitiv). Forretningslogikk for
hva ulike tilgangsnivåer tillater lever i maskinrommet, ikke i
databasen.

View file

@ -1,5 +1,12 @@
# Forslag: Personlig workspace # Forslag: Personlig workspace
> **Superseded (v2):** Dette forslaget er erstattet av retningen
> "noder er sentrum" (se `docs/retninger/bruker_ikke_workspace.md`).
> I v2 finnes det ingen workspaces. Privat rom oppstår naturlig:
> noder uten edges til andre brukere er ditt private rom. Personlig
> kanban, kalender og notater er bare noder du eier uten delte edges.
> Dokumentet er bevart som historisk referanse.
## Idé ## 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. 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.

View file

@ -23,10 +23,10 @@ andre dokumenter. En retning kan også forkastes eller parkeres.
|---------|--------|----------------| |---------|--------|----------------|
| [Status quo](status_quo.md) | Referanse | Hva er Sidelinja i dag? Ankerpunkt for de andre retningene. | | [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? | | [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 | | [Universell input og mottak](universell_input.md) | **Besluttet** | Én multimodal input-primitiv, én mottaksflate, kommunikasjonsnoder. Edges definerer alt. |
| [Maskinrommet](maskinrommet.md) | Åpen | Én Rust-tjeneste med fast grensesnitt for alle tekniske tjenester: fang, prosesser, lever | | [Maskinrommet](maskinrommet.md) | **Besluttet** | Én Rust-tjeneste: fang, prosesser, lever. Eier all skriving. Edge-drevet ressursorkestrering. |
| [Bruker, ikke workspace](bruker_ikke_workspace.md) | Åpen | Brukeren er sentrum, workspaces er frivillige samlings-noder — ikke containere | | [Noder er sentrum](bruker_ikke_workspace.md) | **Besluttet** | Alt er noder (brukere, team, innhold). Edges definerer relasjoner og tilgang. Materialisert tilgangsmatrise for RLS. |
| [Datalaget](datalaget.md) | Åpen | PG + Apache AGE som enhetlig graf og arkiv, SpacetimeDB som sanntidslag, CAS for binærdata | | [Datalaget](datalaget.md) | **Besluttet** | SpacetimeDB holder hele grafen, PG er persistent arkiv, CAS for binærdata, AGE ved behov |
## Format ## Format
- Hva er tesen? - Hva er tesen?

View file

@ -1,154 +1,327 @@
# Bruker, ikke workspace # Noder er sentrum
> Primærenheten er brukeren og brukerens edges. Workspaces er ikke **Status: Besluttet.**
> containere — de er frivillige samlings-noder som gir felles kontekst.
## Observasjoner > Alt er noder. En bruker er en node. Et team er en node. Et møte er
> en node. Sidelinja er en node. Relasjoner mellom dem er edges.
> Tilgangskontroll via materialisert tilgangsmatrise beregnet fra
> edge-grafen.
I dag er workspaces den organisatoriske enheten. Du "er i" et workspace, ## Beslutningen
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 1. **Alt er noder.** Brukere, team, prosjekter, innhold, møter —
— den har edges til ting. Og brukeren er ikke "i" et workspace — brukeren alt er rader i `nodes`-tabellen. En bruker er en node som
har edges til noder, personer, topics, samtaler. tilfeldigvis kan logge inn.
2. **Relasjoner er edges.** Vegard → Sidelinja (`owner`).
Trond → Sidelinja (`member`). Møtereferat → møte (`belongs_to`).
3. **Ingen containere.** Hva du ser er summen av dine edges.
4. **Samlings-noder gir struktur** — de er vanlige noder som
fungerer som gravitasjonspunkt.
5. **Privat er default** — en node uten edges til andre er kun din.
6. **Tilgangskontroll via `node_access`-matrise**, oppdatert ved
edge-endring, brukt av RLS ved lesing.
## Tesen ## Visibility
**Brukeren er sentrum.** Du logger inn og ser dine edges — alt du er Visibility er en egenskap på noden som definerer hva som gjelder
koblet til. Ikke "velg workspace" som første handling, men "her er alt for alle *uten* eksplisitt edge. Eksplisitte edges overrider alltid
ditt." oppover.
### Workspace som samlings-node, ikke container ```sql
CREATE TYPE visibility AS ENUM ('hidden', 'discoverable', 'readable', 'open');
```
Et workspace eksisterer fortsatt som konsept, men det er en node i | Nivå | Oppdagbar | Lesbar | Interagerbar |
grafen — ikke en organisatorisk boks: |------|-----------|--------|-------------|
| `hidden` | Nei | Nei | Nei — kun via eksplisitt edge |
| `discoverable` | Ja (søk/katalog) | Nei | Kontaktforespørsel |
| `readable` | Ja | Ja | Nei — krever edge |
| `open` | Ja | Ja | Ja |
- **Tradisjonell modell:** Workspace inneholder kanaler, boards, filer. Eksempler:
Brukeren er "i" workspacet. - Spøkelsesbruker: `hidden` — usynlig, kun invitasjon
- **Node-modell:** Workspace er en node. Noder har edge til den. Brukeren - Katalogbruker: `discoverable` — finnes i søk, profil krever edge
har edge til den. Workspace-noden bærer felles kontekst (tema, - Podcast-episode: `readable` — alle kan lytte, bare teamet kan redigere
pruning-profil, AI-konfig). - Kunnskapsgraf-entitet: `open` — alle kan se og bidra
En node kan ha edge til flere workspaces. En faktoid om en gjest er ### Traverseringsregelen
relevant for både podcast-prosjektet og research-samlingen. Ikke kopi —
samme node, to edges.
### Personlig er default **Visibility er en hard grense ved traversering.** Når du følger
edges i grafen, kan du bare se noder hvis deres visibility tillater
det — eller du har en eksplisitt edge.
Alt uten workspace-edge eller mottaker-edge er privat — tilhører bare Eksempel: Episode #42 (`readable`) har en `host`-edge til Peter
deg. "Personlig workspace" er ikke en egen ting du oppretter. Det er (`hidden`). Anonym bruker leser episoden, følger `host`-edge →
fravær av deling. Dine private notater, dagbok, voice memos — de har Peter er `hidden`**stopp, usynlig.** Trond (som har edge til
bare edges til deg. Peter) følger samme edge → **ser Peter.**
### Administrasjon er edges med roller Ingen transitivitet bryter denne regelen. Uansett hvor mange
offentlige noder som har edges til en `hidden` node, forblir den
skjult for de uten eksplisitt edge.
Brukerstyring forvaltes i nodene selv, ikke i et sentralt admin-panel: ## Brukere er noder
| Edge-type | Hva den gir | En brukernode er en node med en kobling til Authentik for
|-----------|------------| autentisering. Alt annet — navn, preferanser, roller, relasjoner —
| Eier-edge | Full kontroll — slette, endre tilgang, endre innstillinger | er noden og dens edges.
| 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 `users`-tabellen krymper til autentisering:
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 ```sql
organisasjon (samlings-node med mange admin- og deltaker-edges). CREATE TABLE auth_identities (
node_id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
authentik_sub TEXT UNIQUE NOT NULL, -- Authentik subject ID
email TEXT UNIQUE NOT NULL
);
```
### Brukeropplevelsen Brukerens profil, innstillinger og metadata lever på noden (JSONB)
eller som edges. Autentiseringstabellen er en tynn bro mellom
"denne HTTP-sesjonen" og "denne noden i grafen."
## Aliaser
En bruker kan opprette aliaser — separate noder som representerer
en offentlig persona eller anonym identitet.
```
Aleks (hidden) ──alias──→ Bjørn (readable)
```
Bjørn er en egen node med eget navn, egen profil, egen visibility.
Omverdenen ser bare Bjørn. `alias`-edgen er en **systemedge**
usynlig ved traversering, aldri eksponert utad. Ellers ville
"hvem kontrollerer Bjørn?" lekke Aleks' identitet.
### Én flate, kontekst bestemmer identitet
Aleks ser *alt* i sin mottaksflate — egne noder og alt som
kommer til Bjørn. Han svarer fra sin egen flate. Systemet
bestemmer `created_by` basert på konteksten:
- NN skrev til Bjørn → Aleks svarer → meldingen stemplet
med Bjørn som `created_by`. Automatisk, ingen bytte.
- Vegard skrev til Aleks direkte → svaret kommer fra Aleks.
**Ingen manuell identitetsbytte.** Aleks trenger aldri å "logge
inn som Bjørn" eller bytte modus. Konteksten (hvilken samtale,
hvilken kanal, hvem mottakeren kjenner) bestemmer hvilken node
som er avsender.
### Identitetsvalg ved tvetydighet
Noen ganger kjenner mottakeren *både* personen og aliaset.
Vegard kjenner Aleks og vet hvem Bjørn er. Hvis Vegard sender
en melding til Bjørn under en episode, og Aleks svarer:
- **Default:** svaret kommer fra Bjørn (konteksten er Bjørn).
- **Valg:** systemet vet at Vegard har edge til Aleks. Aleks
kan velge "svar som Aleks" — da startes en ny, separat
samtale mellom Vegard og Aleks.
**Alias-grensen brytes aldri automatisk.** Bare med eksplisitt
handling fra personen bak aliaset.
### Flere aliaser
Ingenting stopper en bruker fra å ha flere aliaser — Bjørn for
podcast, et anonymt alias for publisering, seg selv for privat.
Hver er en node med en `alias`-edge fra brukernoden. Alle samles
i én mottaksflate.
### Alias vs. owner
| Edge | Betydning | Eksempel |
|------|-----------|----------|
| `alias` | Jeg *er* denne noden. Usynlig systemedge. | Aleks → Bjørn |
| `owner` | Jeg *forvalter* denne noden. Synlig for medlemmer. | Vegard → Sidelinja |
`alias` = identitet. `owner` = ansvar. Aleks *er* Bjørn. Vegard
*eier* Sidelinja, men han *er* ikke Sidelinja.
## Team er noder
Et team er en node med `member`-edges til brukernoder. Gi teamet
tilgang til en samlings-node → alle teammedlemmer arver tilgang
via transitiv traversering. Ingen egen team-mekanisme.
```
Vegard (node) ──member──→ Podcastteamet (node) ──member──→ Sidelinja (node)
Trond (node) ──member──→ Podcastteamet (node)
```
Trond får tilgang til alt under Sidelinja fordi han er medlem av
Podcastteamet som er medlem av Sidelinja. Tilgangsmatrisen beregner
dette transitivt.
## Samlings-noder
En samlings-node er en vanlig node som fungerer som gravitasjonspunkt.
"Sidelinja" er en samlings-node Vegard oppretter og eier. Trond
kobles på — direkte eller via et team. Andre inviteres inn.
Det kan finnes mange samlings-noder — eller få. Et podcast-prosjekt,
en research-samling, en vennegjeng. De er ikke noe spesielt i
datamodellen — bare noder med edges til andre noder.
En innholdsnode kan ha edge til flere samlings-noder. En faktoid om
en gjest er relevant for både podcast-prosjektet og research-samlingen.
Ikke kopi — samme node, to edges.
### Felles kontekst
En samlings-node bærer kontekst (i JSONB) som arves av tilknyttede
noder:
- **Pruning-profil** — hvor aggressivt slettes binærdata?
- **Tema** — visuelt uttrykk
- **AI-konfigurasjon** — prompts, modell, regler
- **Default synlighet** — for nye noder opprettet i denne konteksten
- **Kapasitet** — ressursgrenser for maskinrommet
Konflikter (node med edge til to samlings-noder med ulike
pruning-profiler) løses med: mest konservativ vinner, eller
eier-edge bestemmer.
## Tilgangsroller
| Rolle | Hva den gir |
|-------|------------|
| `owner` | Full kontroll — slette, endre tilgang, endre innstillinger |
| `admin` | Kan invitere/fjerne andre, endre konfigurasjon |
| `member` | Kan gi input og motta |
| `reader` | Kan kun motta (observatør, lytter) |
Roller er en egenskap på edgen, ikke på noden. Vegard har `owner`-edge
til Sidelinja og `member`-edge til Research-gruppa. Samme node, ulike
roller i ulike kontekster.
## Tilgangsmatrise — spesifikasjon
### Skjema
```sql
CREATE TYPE access_level AS ENUM ('reader', 'member', 'admin', 'owner');
CREATE TABLE node_access (
subject_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
object_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
access access_level NOT NULL,
via_edge UUID REFERENCES edges(id) ON DELETE CASCADE,
PRIMARY KEY (subject_id, object_id)
);
CREATE INDEX idx_na_subject ON node_access (subject_id);
```
`subject_id` er noden som har tilgang (bruker eller team).
`object_id` er noden det gis tilgang til.
`via_edge` peker på edgen som ga tilgangen — for debugging og
deterministisk revokering.
### RLS-policy
```sql
CREATE POLICY node_read ON nodes FOR SELECT
USING (
created_by = current_node_id()
OR id IN (
SELECT object_id FROM node_access
WHERE subject_id = current_node_id()
)
OR visibility >= 'discoverable'
);
```
`current_node_id()` returnerer brukernodens id fra sesjonen.
Tre sjekker i prioritert rekkefølge:
1. Egne noder (`created_by`) — instant
2. Eksplisitt tilgang via matrisen — indeksert lookup
3. Offentlig synlige noder — kolonne-sjekk
Merk: `discoverable` gir kun at noden *finnes* i søkeresultater.
Innholdet filtreres i applikasjonslaget basert på visibility-nivå.
### Matrise-oppdatering
Matrisen oppdateres **når edges endres**, ikke ved lesing.
Én transaksjon: edge + matrise-oppdatering. Alltid synkront —
ingen vindu med stale tilgang.
**Transitiv tilgang:** Når Trond får `member`-edge til Sidelinja,
beregnes hans tilgang til alle noder med edge til Sidelinja.
```
Brukernode → samlings-node → innholdsnoder: 2 hopp
Brukernode → team → samlings-node → innholdsnoder: 3 hopp
Brukernode → team → samlings-node → komm.node → noder: 4 hopp
```
**Beregningsfunksjon:**
```sql
CREATE OR REPLACE FUNCTION recompute_access(
p_subject_id UUID, -- bruker- eller teamnode
p_root_node_id UUID, -- noden det gis tilgang til
p_access access_level,
p_via_edge UUID
) RETURNS void AS $$
BEGIN
-- Direkte tilgang til roten
INSERT INTO node_access (subject_id, object_id, access, via_edge)
VALUES (p_subject_id, p_root_node_id, p_access, p_via_edge)
ON CONFLICT (subject_id, object_id)
DO UPDATE SET access = GREATEST(node_access.access, p_access);
-- Transitiv: noder som tilhører roten
INSERT INTO node_access (subject_id, object_id, access, via_edge)
SELECT p_subject_id, e.source_id, p_access, p_via_edge
FROM edges e
WHERE e.target_id = p_root_node_id
AND e.edge_type = 'belongs_to'
ON CONFLICT (subject_id, object_id)
DO UPDATE SET access = GREATEST(node_access.access, p_access);
-- Hvis subject er et team: propager til alle teammedlemmer
INSERT INTO node_access (subject_id, object_id, access, via_edge)
SELECT e.source_id, na.object_id, na.access, na.via_edge
FROM node_access na
JOIN edges e ON e.target_id = p_subject_id
AND e.edge_type = 'member_of'
WHERE na.subject_id = p_subject_id
ON CONFLICT (subject_id, object_id)
DO UPDATE SET access = GREATEST(node_access.access, EXCLUDED.access);
END;
$$ LANGUAGE plpgsql;
```
Detaljer (hvilke edge-typer som gir transitiv tilgang, maks dybde)
avklares ved implementering.
### Matrisestørrelse
Sparse matrise. Typisk bruker med tilgang til 2-3 samlings-noder
med noen tusen noder hver: ~10k rader per bruker. Med 50 brukere:
~500k rader. Trivielt for PG.
## Brukeropplevelse
Når du logger inn ser du: Når du logger inn ser du:
- **Dine aktive samtaler** — kommunikasjonsnoder med edge til deg - **Dine aktive samtaler** — kommunikasjonsnoder med edge til deg
- **Dine noder** — alt du har skapt eller er koblet til - **Dine noder** — alt du har skapt eller er koblet til
- **Dine samlings-noder** (det som før var workspaces) — grupperer - **Dine samlings-noder** — grupperer kontekst, filtrering er frivillig
kontekst, men du trenger ikke "gå inn i" dem - **Din mottaksflate** — det som er relevant for deg nå
- **Din mottaksflate** — alt som er relevant for deg nå, vektet
Du kan filtrere etter samlings-node hvis du vil fokusere ("vis bare Å filtrere etter samlings-node ("vis bare podcast-prosjektet") er et
podcast-prosjektet"), men det er et filter — ikke en modebytte. 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 ## Forhold til andre retninger
- [Rom, ikke forum](rom_ikke_forum.md) — "rommet" er ikke et workspace, - [Rom, ikke forum](rom_ikke_forum.md) — "rommet" er summen av dine
det er summen av dine edges edges, ikke en container du går inn i
- [Universell input og mottak](universell_input.md) — mottaksflaten er - [Universell input og mottak](universell_input.md) — mottaksflaten
"noder med edge til *meg*", ikke "noder i *mitt workspace*" er "noder med edge til *meg*", filtrert og vektet
- [Maskinrommet](maskinrommet.md) — leser samlings-node-edges for - [Maskinrommet](maskinrommet.md) — eier matrise-oppdatering og leser
kontekst (pruning, kapasitet, AI-konfig) samlings-node-edges for kontekst (pruning, kapasitet, AI-konfig)
- [Datalaget](datalaget.md) — matrisen lever i PG, indeksert for
RLS-ytelse

View file

@ -1,182 +1,97 @@
# Datalaget — PG + AGE som enhetlig graf og arkiv # Datalaget
> PostgreSQL er den eneste databasen. Apache AGE gir Cypher-semantikk **Status: Besluttet.**
> for graftraverseringer. SpacetimeDB er sanntidslaget over samme data.
> Én database, to tilgangsmønstre, ingen eierskapskonflikt.
## Observasjoner > SpacetimeDB holder hele grafen i minne og mottar skrivinger først.
> PostgreSQL er persistent arkiv og backup. CAS lagrer binærdata.
Hele retningsarbeidet har bygget seg mot "alt er noder og edges." > Apache AGE legges til ved behov for Cypher-traverseringer.
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 ## Lagmodell
``` ```
┌─────────────────────────────────────┐ GUI (SvelteKit)
│ GUI (SvelteKit) │ │ skriv │ les (sanntid, WebSocket)
│ Primitiver, visninger │ ▼ ▼
└────────┬──────────────────┬─────────┘ Maskinrommet (Rust) SpacetimeDB ──→ GUI
│ skriv │ les (sanntid) │ validering ▲
│ │ direkte WebSocket ├──→ SpacetimeDB (først) ───┘
┌────────▼────────┐ ┌─────▼─────────┐ └──→ PostgreSQL (asynk, persistent)
│ Maskinrommet │ │ SpacetimeDB │──┐
│ (Rust) │ │ (STDB) │ │
│ Orkestrering │ └───────────────┘ │
└──┬─────┬─────┬──┘ │
│ │ │ sync ↕ │
▼ ▼ ▼ │
┌─────┐┌─────┐┌─────┐┌─────────────┐ │
│ PG ││STDB ││ CAS ││ Whisper, │ │
│+AGE ││(skr)││ ││ LiteLLM, │ │
│ ││ ││ ││ LiveKit ... │ │
└─────┘└─────┘└─────┘└─────────────┘ │
``` ```
### Skriv vs les — to stier med god grunn ### Skrivestien
GUI → Maskinrommet → validering → SpacetimeDB (instant) → PG (asynk).
Frontend oppdateres umiddelbart. PG persisterer i bakgrunnen.
**Skriv:** GUI → Maskinrommet (Rust) → tjenester (PG, STDB, CAS, ...) ### Lesestien (sanntid)
SpacetimeDB → GUI (direkte WebSocket, ~10μs).
Hele grafen er i SpacetimeDB. Frontend har alltid alt tilgjengelig.
All orkestrering, edge-logikk, ressursallokering og validering går ### Lesestien (tunge spørringer)
gjennom Rust. Maskinrommet bestemmer hva som skjer: hva som skrives GUI → Maskinrommet → PG.
til PG, hva som speiles til SpacetimeDB, hva som lagres i CAS, Fulltekstsøk, pgvector (semantisk søk), statistikk, AGE-traverseringer.
hvilke tjenester som trigges.
**Les (sanntid):** SpacetimeDB → GUI (direkte WebSocket) ## SpacetimeDB — hele grafen i minne
SpacetimeDB sitt klient-SDK kobler seg direkte via WebSocket, Node- og edge-skjemaet er minimalt (åtte kolonner hver). For en
definerer SQL-subscriptions, og synkroniserer automatisk med lokal liten brukerbase er hele grafen triviell å holde i minne. Fordeler:
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 - Ingen sync-logikk for å bestemme hva som er "aktivt"
- Ingen henting fra PG når noen åpner noe gammelt
- Frontend har alltid alt via WebSocket
- Én lesekilde — ingen tvetydighet
Søk, historikk, statistikk, arkiv — alt som ikke er sanntid går Når dette blir problematisk (hundretusenvis av noder), innføres
gjennom Rust og PG. AGE-spørringer for graftraversering, pgvector eviction. Det er en optimalisering, ikke en arkitekturendring.
for semantisk søk, SQL for aggregeringer.
### PG er arkivet og grafen ## PostgreSQL — arkiv og kraftspørringer
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 PG er persistent backup for hele grafen, pluss hjemsted for
Aktive noder og edges speilet til SpacetimeDB for live-oppdateringer. tunge operasjoner SpacetimeDB ikke er laget for:
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 - **Fulltekstsøk**`tsvector``nodes.content` og `nodes.title`
Lyd, bilde, video lagres content-addressable utenfor PG. Noder i - **Semantisk søk** — pgvector for embedding-basert likhet
PG peker på CAS-hasher. Pruning-regler basert på edges, aksesslog - **Graftraversering** — rekursive CTEs, Apache AGE ved behov
og modalitet (se maskinrommet). - **Statistikk** — aggregeringer, tidsserier
- **Tilgangsmatrise**`node_access` beregnes her, speiles til STDB
## Migreringsstrategi ### Apache AGE — ved behov
### Fase 1: AGE på eksisterende PG De fleste spørringer er grunne (1-3 hopp) og håndteres av CTEs.
Installer Apache AGE extension. Eksponer eksisterende nodes/edges- AGE legges til som PG-extension når Cypher-semantikk faktisk trengs:
tabeller som graf. Ingen endring for resten av systemet — bare en
ny måte å spørre på.
### Fase 2: Konsolider til node+edge-modellen 1. **Nå:** PG med nodes/edges-tabeller og CTEs
Migrer meldingsboks, kanban, kalender etc. til den universelle 2. **Når CTEs blir smertefulle:** Legg til AGE
node+edge-modellen gradvis. Gamle tabeller kan leve som views 3. **Usannsynlig:** Evaluer Neo4j hvis AGE ikke holder
over grafen i overgangsperioden.
### Fase 3: Parallell prototype AGE er en extension, ikke en migrering — boltes på uten å endre
Det gamle systemet kjører uforstyrret. En ny frontend-prototype eksisterende kode.
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 ## CAS — binærlagring
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 Lyd, bilde, video lagres content-addressable på disk. CAS-noder
i grafen bærer metadata (`cas_hash`, `mime`, `size_bytes`).
Selve biten lever utenfor PG.
- **AGE-modenhet.** Prosjektet er aktivt og støttet av Azure/EDB, Pruning-regler basert på modalitet, edges og aksessmønstre.
men community er mindre enn Neo4j. Risikoen er håndterbar fordi Se [maskinrommet](maskinrommet.md).
dataene alltid er PG-tabeller — AGE er bare et spørrelag.
- **SpacetimeDB-synk.** Hvordan synkes noder/edges mellom PG (AGE) ## SpacetimeDB som utbyttbar
og SpacetimeDB effektivt? Trenger en sync-mekanisme som forstår
"aktiv delmengde." SpacetimeDB er en sanntidscache, ikke en avhengighet. Hvis den
- **Skjemadesign.** Én node-tabell med alle typer innhold — hva er fjernes:
felles kolonner vs edge-metadata vs JSONB-payload? Trenger
gjennomtenkt skjema som balanserer fleksibilitet og spørrbarhet. - **Sanntid:** PG `LISTEN/NOTIFY` → SvelteKit SSE
- **Skriving:** Maskinrommet → PG direkte
- **Lesing:** Maskinrommet → PG → GUI
Trenger ikke implementeres, men arkitekturen skal aldri gjøre
det umulig. Se [synkronisering](../infra/synkronisering.md).
## Forhold til andre retninger ## Forhold til andre retninger
- [Noder er sentrum](bruker_ikke_workspace.md) — tilgangsmatrise
beregnet fra edge-grafen, speiles til SpacetimeDB
- [Universell input og mottak](universell_input.md) — noder og edges - [Universell input og mottak](universell_input.md) — noder og edges
er den underliggende datamodellen for alle tre primitiver er datamodellen for alle tre primitiver
- [Maskinrommet](maskinrommet.md) — CAS og pruning lever her, AGE - [Maskinrommet](maskinrommet.md) — CAS-pruning, edge-drevet
brukes for edge-drevet ressursorkestrering ressursorkestrering, validering før skriving
- [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

View file

@ -1,28 +1,13 @@
# Maskinrommet — teknisk tjenestelaget # Maskinrommet
> Én Rust-tjeneste med et fast grensesnitt. Alle tekniske tjenester beveger **Status: Besluttet.**
> seg gjennom dette laget. Fang, prosesser, lever.
## Observasjoner > Én Rust-tjeneste med et fast grensesnitt. Alt som krever tunge
> ressurser eller eksterne tjenester går gjennom dette laget.
> Fang, prosesser, lever. Maskinrommet er det eneste som skriver
> noder og edges.
I dag er tekniske tjenester spredt: ## Tre operasjoner
- **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) ### 1. Fang (input-absorpsjon)
Ta imot råmateriale i alle modaliteter: Ta imot råmateriale i alle modaliteter:
@ -36,9 +21,8 @@ Ta imot råmateriale i alle modaliteter:
Analyser, transformer, berik og systematiser: Analyser, transformer, berik og systematiser:
- **STT** — lyd → tekst (Whisper) - **STT** — lyd → tekst (Whisper)
- **TTS** — tekst → lyd (ElevenLabs / lokal modell) - **TTS** — tekst → lyd (ElevenLabs / lokal modell)
- **AI-analyse** — oppsummering, klassifisering, sentimentanalyse, - **AI-analyse** — oppsummering, klassifisering, edge-forslag
faktasjekk, edge-forslag - **Beriking** — URL → metadata, bilde → beskrivelse
- **Beriking** — URL → metadata, bilde → beskrivelse, lyd → segmenter
- **Søk** — fulltekst, semantisk (pgvector), graftraversering - **Søk** — fulltekst, semantisk (pgvector), graftraversering
- **Mediaprosessering** — transcode, thumbnail, waveform - **Mediaprosessering** — transcode, thumbnail, waveform
@ -46,24 +30,40 @@ Analyser, transformer, berik og systematiser:
Lever resultat i riktig modalitet til riktig mottaker: Lever resultat i riktig modalitet til riktig mottaker:
- Tekst (melding, notifikasjon, digest) - Tekst (melding, notifikasjon, digest)
- Lyd (TTS-opplesning, lydstream) - Lyd (TTS-opplesning, lydstream)
- Video/bilde (stream, thumbnail, snapshot) - Video/bilde (stream, thumbnail)
- Strukturert data (noder, edges, metadata tilbake i grafen) - Strukturert data (noder, edges tilbake i grafen)
- Push (webhook, SSE, SpacetimeDB-reducer) - Push (SpacetimeDB-reducer)
## Maskinrommet eier all skriving
Frontend sender intensjoner. Maskinrommet utfører.
```
Frontend: "legg til Trond i møtet"
→ Maskinrommet validerer
→ Skriver edge til SpacetimeDB (instant)
→ Oppdaterer tilgangsmatrise
→ Persisterer til PG (asynk)
→ Reagerer på konsekvensene (koble inn LiveKit, starte transkripsjon)
```
Alt i én operasjon. Maskinrommet er ikke reaktivt i en pub/sub-
forstand — det orkestrerer hele sekvensen. Enklere å forstå,
enklere å debugge.
Skrivestien: validering → SpacetimeDB (instant) → PG (asynk).
Se [synkronisering](../infra/synkronisering.md).
## Edge-drevet ressursorkestrering ## Edge-drevet ressursorkestrering
Nøkkelinnsikten: **maskinrommet leser edges for å vite hva det skal gjøre.** Maskinrommet leser edges for å vite hva det skal gjøre. Noden
Noden selv er alltid enkel. Det er edgene som bestemmer hvilke ressurser er alltid enkel. Edges bestemmer hvilke ressurser som spinnes opp.
som spinnes opp.
### Security by default ### Privat er default
Input uten mottaker-edge er automatisk privat. Du trenger ikke "velge Input uten mottaker-edge er automatisk privat. Ingen ressurser
privat" — det er utgangspunktet. Ingen ser det. Ingen ressurser kobles kobles inn utover det grunnleggende (fang + transkriber).
inn utover det grunnleggende (fang + transkriber). Privat er ikke en
innstilling, det er fravær av deling.
### Ressurser er proporsjonale med edges ### Ressurser er proporsjonale med edges
Samme nodetype, vilt forskjellig ressursbruk:
``` ```
Dagboknotat (privat voice memo): Dagboknotat (privat voice memo):
@ -81,242 +81,116 @@ Redaksjonsmøte (5 deltakere):
Ressurser: STT + levering til 5 + LLM Ressurser: STT + levering til 5 + LLM
Livesending (1000 lyttere): Livesending (1000 lyttere):
node + mottaker-edges(∞) + stream-edge + publiserings-edge node + mottaker-edges(∞) + stream-edge
→ fang lyd → transkriber → stream via LiveKit → distribuer → fang lyd → transkriber → stream via LiveKit → distribuer
→ generer segmenter → kjør live AI → publiser → generer segmenter → kjør live AI → publiser
Ressurser: STT + LiveKit + LLM + mediaprosessering 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 ### Naturlig eskalering
Du starter en privat voice-note. Bestemmer deg for å dele den med Trond Du starter en privat voice-note. Deler den med Trond → legg til
→ legg til mottaker-edge, maskinrommet begynner å levere. Trond foreslår edge, maskinrommet begynner å levere. Trond foreslår møte → flere
at dere tar det som et møte → legg til flere deltaker-edges, maskinrommet edges, maskinrommet kobler inn sanntidsstrømming. Møtet blir
kobler inn sanntidsstrømming. Møtet blir en innspilling → legg til innspilling → publiserings-edge, maskinrommet aktiverer
publiserings-edge, maskinrommet aktiverer produksjonspipeline. produksjonspipeline. Hvert steg er bare å legge til edges.
Hvert steg er bare å legge til edges. Maskinrommet reagerer og kobler
inn flere ressurser etter hvert. Ingen migrering, ingen modebytte.
## Grensesnittet ## Grensesnittet
Maskinrommet eksponerer et konsistent API — sannsynligvis en Rust trait
eller et sett traits:
``` ```
fang(input: RåInput) → NodeId fang(input: RåInput) → NodeId
prosesser(node: NodeId, operasjon: Operasjon) → Resultat prosesser(node: NodeId, operasjon: Operasjon) → Resultat
lever(node: NodeId, mottaker: Mottaker, format: Format) → Status lever(node: NodeId, mottaker: Mottaker, format: Format) → Status
``` ```
Men i praksis er mye av dette *reaktivt*: maskinrommet observerer I praksis er mye av dette implisitt: maskinrommet ser hvilke edges
edge-endringer og handler automatisk. Legger noen til en mottaker-edge som ble skrevet og handler deretter. Primitivene manipulerer edges
→ maskinrommet begynner å levere. Legger noen til en stream-edge → via maskinrommet, og maskinrommet kobler inn riktige ressurser.
maskinrommet kobler inn LiveKit. Primitivene trenger ikke eksplisitt
kalle `lever()` — de manipulerer edges, og maskinrommet reagerer.
## Hva dette gir ## CAS og intelligent pruning
### 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 ### CAS som lagringsprimitiv
All binærdata (lyd, bilde, video) lagres i et content-addressable store. All binærdata (lyd, bilde, video) lagres content-addressable.
Fordeler: CAS-noder i grafen bærer metadata (`cas_hash`, `mime`, `size`).
Selve biten lever på disk.
- **Deduplisering gratis** — samme fil delt i tre kontekster = én kopi - **Deduplisering gratis** — samme fil delt i tre kontekster = én kopi
- **Separasjon** — "innholdet eksisterer" er adskilt fra "innholdet er - **Separasjon** — "innholdet eksisterer" er adskilt fra "innholdet
tilgjengelig." Noden peker på en hash, CAS har filen (eller ikke). er tilgjengelig"
- **Enkel opprydning** — slett hashen fra CAS, alle noder som pekte - **Enkel opprydning** — slett filen fra CAS, noden beholder metadata
dit mister binærdataen men beholder metadata og transkripsjon.
### Lagringsregler per modalitet ### Lagringsregler per modalitet
| Modalitet | Default levetid | Begrunnelse | | Modalitet | Default levetid | Begrunnelse |
|-----------|----------------|-------------| |-----------|----------------|-------------|
| Tekst | Evig | Billig, er essensen av innholdet | | Tekst | Evig | Billig, essensen av innholdet |
| Transkripsjon | Evig | Tekstlig representasjon av lyd/video — tar vare på meningen | | Transkripsjon | Evig | Tekstlig representasjon — bevarer meningen |
| Lyd | 30 dager | Mellomkostnad, transkripsjon bevarer innholdet | | Lyd | 30 dager | Transkripsjon bevarer innholdet |
| Bilde | 30 dager | Mellomkostnad, beskrivelse/metadata bevarer kontekst | | Bilde | 30 dager | Beskrivelse/metadata bevarer kontekst |
| Video | 14 dager | Dyrest, transkripsjon + thumbnail bevarer det meste | | Video | 14 dager | Dyrest, transkripsjon + thumbnail bevarer det meste |
### Signaler som forlenger levetid ### Signaler som forlenger levetid
Default-TTL er bare utgangspunktet. Maskinrommet justerer basert på: - **Edges.** Lydfil med publiserings-edge = beholdes. Privat
voice-memo uten edges = 30-dagers TTL.
- **Edges.** En lydfil med edge til episoderegisteret = publisert - **Aksesslog.** Avspilt i løpet av TTL-perioden = forlenges.
podcast, beholdes. En privat voice-memo uten edges = 30-dagers TTL. - **Transkripsjonsstatus.** Utranskribert lyd kan trenge lengre TTL.
- **Aksesslog.** Hvis noen har spilt av lydfilen i løpet av TTL-perioden, - **Edge-type.** Publisert = behold. Arkivert møte = transkripsjon
forlenges den. Ingen aksess = ingen verdi i å beholde binærdataen. holder.
- **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 ### Generert innhold er en cache
TTS, thumbnails, AI-oppsummeringer, waveforms — alt som kan regenereres TTS, thumbnails, AI-oppsummeringer, waveforms — alt som kan
fra kildedata er i praksis en cache. Det lagres i CAS med samme TTL- regenereres er en cache i CAS med samme TTL-mekanisme.
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 ### Samlings-node-styrt aggressivitet
"generert lyd" (TTS) i pruning-logikken. Begge er binærdata i CAS med Hver samlings-node kan justere sin pruning-profil:
en TTL som forlenges ved aksess. Forskjellen er bare at generert - **Konservativt** — behold alt lenge (arkiv-node)
innhold alltid kan gjenskapes fra kilden — så det er tryggere å prune. - **Aggressivt** — tekst bevares, binærdata prunes raskt
### 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 - **Tilpasset** — egne regler per modalitet og edge-type
### Brukerens erfaringsbaserte meny ### Disk-nødventil
Over tid bruker du noen edges oftere enn andre, noen noder oftere enn Maskinrommet overvåker diskbruk:
andre. Maskinrommet observerer dette og tilbyr en erfaringsbasert meny: - **>85 %:** Genererte filer slettes (kan regenereres)
dine mest brukte koblinger, dine vanligste input-mønstre, dine - **>90 %:** Aggressiv pruning for alle samlings-noder
foretrukne modaliteter. Ikke som en rigid konfigurasjon — som en - **>95 %:** Kritisk alarm. Alt uten publiserings-edge slettes.
adaptiv overflate du kan aktivere og deaktivere fortløpende. Tekst og transkripsjoner bevares alltid.
Dette er ikke maskinlæring eller kompleks AI — det er frekvenstelling ## Isolasjon og observerbarhet
på edges og aksesslog. Enkelt å implementere, intuitivt for brukeren.
## Pragmatisk vei dit ### Isolasjon
Bytt Whisper med noe annet? Endre maskinrommet. Frontend vet
ingenting. Legg til bildegenerering? Ny operasjon. Primitivene
kaller den uten å vite hva som skjer under.
Ikke bygg dette fra scratch. Formaliser det som allerede finnes: ### Observerbarhet
Alt går gjennom ett punkt. Logging, metrikker, kostnadsrapportering
— alt på ett sted. "Hva bruker vi AI-ressurser på?" har ett svar.
1. **Worker + jobbkø er allerede kjernen.** De trenger et konsistent ### Kapasitetsstyring
API, ikke en omskriving. Prioritering, kø, rate limiting, fallback mellom leverandører.
2. **AI Gateway (LiteLLM) absorberes** — i stedet for en separat proxy, Live transkripsjon prioriteres over bakgrunns-oppsummering.
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 ## Compute-separasjon
Maskinrommet orkestrerer — men tunge jobber trenger ikke kjøre på Maskinrommet orkestrerer — tunge jobber trenger ikke kjøre på
samme maskin. Hetzner CPX42 (8 vCPU, 16 GB RAM) skal håndtere state samme maskin.
(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. - **Nå:** Alt på én VPS. Jobbkøen prioriterer sanntid over batch.
Whisper kjøres med lavere concurrency under live-sesjoner. - **Snart:** Trekk ut tunge workers til separat node (billig
- **Senere:** Trekk ut tunge workers til en separat node (billig ARM-instans) som poller jobbkøen. Maskinrommet ruter transparent.
ARM/Ampere-instans) som poller jobbkøen over internt nettverk. - **Kildevern-modus:** Lokal LLM krever dedikert compute med
Maskinrommet ruter transparent — primitivene merker ingenting. fysisk isolasjon.
- **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 å Compute-separasjon er en konfigurasjon, ikke en arkitekturendring.
*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 ## Forhold til andre retninger
Maskinrommet er infrastrukturen *under* de tre primitivene i Maskinrommet er infrastrukturen *under* de tre primitivene i
[universell input og mottak](universell_input.md): [universell input og mottak](universell_input.md):
- Input-primitiven kaller `fang()` + `prosesser()` - Input-primitiven → `fang()` + `prosesser()`
- Mottak-primitiven kaller `lever()` - Mottak-primitiven → `lever()`
- Kommunikasjonsnoden bruker alle tre (fang input fra deltakere, - Kommunikasjonsnoden → alle tre
prosesser sanntid, lever til mottakere)
Det er også det som gjør to-lags-modellen fra [rom, ikke forum](rom_ikke_forum.md) - [Noder er sentrum](bruker_ikke_workspace.md) — maskinrommet
praktisk: maskinrommet ruter til riktig lag (sanntid vs tradisjonelt) eier tilgangsmatrise-oppdatering
uten at primitivene trenger å vite forskjellen. - [Datalaget](datalaget.md) — maskinrommet skriver SpacetimeDB
først, PG asynk

View file

@ -104,7 +104,7 @@ 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. kanban, kalender er ikke apper — de er visninger av samme tilstandsrom.
### Privat og delt som lag, ikke separate systemer ### Privat og delt som lag, ikke separate systemer
"Personlig workspace" trenger ikke være en egen ting. Privat/delt er bare en Privat/delt er bare en
synlighetsbryter på alt du gjør. Du chatter med en kollega og samtidig skriver 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. du dagbok — begge er meldingsbokser, den ene er delt, den andre er privat.
De eksisterer side om side i samme rom. De eksisterer side om side i samme rom.

View file

@ -1,5 +1,10 @@
# Status quo — Hva Sidelinja er i dag # Status quo — Hva Sidelinja er i dag
> **Historisk dokument (v1).** Denne teksten beskriver tilstanden i v1 —
> workspace-basert arkitektur, CRUD-mønster, fragmentert navigasjon.
> Den er bevart som referanse for å forstå utgangspunktet for v2-retningene
> i `docs/retninger/`.
> En redaksjonell webapp med ambisiøse primitiver og tradisjonell overflate. > En redaksjonell webapp med ambisiøse primitiver og tradisjonell overflate.
## Hva fungerer ## Hva fungerer

View file

@ -1,299 +1,186 @@
# Universell input og mottak # Universell input og mottak
> Én multimodal input-primitiv. Én personlig mottaksflate. Alt som fanges **Status: Besluttet.**
> er samme type objekt. Hva det "er" bestemmes av edges, ikke av tabellen
> det ligger i. Hvordan det *presenteres* bestemmes av mottakeren.
## Observasjoner > Én multimodal input-primitiv. Én personlig mottaksflate. Alt som
> fanges er en node. Hva det "er" bestemmes av edges. Hvordan det
> presenteres bestemmes av mottakeren.
I dag har vi meldingsboksen som "universell primitiv" — men den er egentlig en ## Input-primitiven
*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 Én overflate som fanger alt:
(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 - **Tekst** — skriving, Markdown, kodeblokker
- **Lyd** — voice memo, diktering → automatisk transkribert - **Lyd** — voice memo, diktering → automatisk transkribert
- **Bilde** — foto, skjermbilde, tegning - **Bilde** — foto, skjermbilde, tegning
- **AI-støtte** — spør AI, få forslag, la den transformere input - **AI-støtte** — spør AI, få forslag, la den transformere input
- **Nettoppslag** — lim inn URL, den berikes automatisk - **URL** — lim inn lenke, 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 Brukeren gjør det samme uansett kontekst — skriver, snakker eller
et kanban-kort" er ikke *hva brukeren gjør* — det er hvilke edges som knyttes tegner i input-feltet. Forskjellen mellom "dagbok", "chatmelding"
til resultatet. og "kanban-kort" er ikke hva brukeren gjør — det er hvilke edges
som knyttes til resultatet.
## Én tabell, edges definerer alt ### Én pipeline
All output fra input-primitiven lander som noder i kunnskapsgrafen. Én tabell. All input går gjennom samme tekniske pipeline:
Ingen `messages`-tabell, ingen `cards`-tabell, ingen `notes`-tabell. maskinrommet → SpacetimeDB (instant) → PG (asynk).
Hva en node "er" bestemmes utelukkende av edges: Konteksten bestemmer routing, ikke en teknisk modus:
- 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 å - Du er i et møte → inputen streames live til andre deltakere
legge til en edge til et board og en status-edge. Fjerne fra chat er å fjerne - Du er alene på bussen → inputen lander som privat node
kanal-edgen. Ingen datamigrering, ingen transformasjon av innhold. Bare edges. - Du er i en podcast-kanal → inputen går inn i produksjonspipeline
**Multitype er naturlig.** En node kan være *både* et kanban-kort *og* en Samme pipeline. Ulike edges.
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 ### 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 Du kan snakke inn et kanban-kort. Du kan tegne en kalenderoppføring.
Lyd inn i input-primitiven kan routes helt forskjellig basert på edges: Input-primitiven bryr seg ikke om hva det *blir* — den fanger det
- Edge til et møterom → streames live til andre deltakere (sanntidslaget) som kommer inn. Alt etterpå er edges.
- 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 ### Én overflate å perfeksjonere
basert på kontekst og edges. Det er ingen "møte-app" eller "notat-app"
eller "meldings-app" — det er én input med ulike destinasjoner. All UX-investering konsentreres ett sted. Én perfekt input-opplevelse
— responsiv, multimodal, med god AI-støtte — i stedet for ti
middelmådige spesialgrensesnitt.
## Edges definerer alt
Hva en node "er" bestemmes utelukkende av edges:
- Node + `belongs_to` → kanal = chatmelding
- Node + `belongs_to` → board + `status` = kanban-kort
- Node + `scheduled` → tidspunkt = kalenderoppføring
- Node uten edges til andre = privat notat
- Node + `mentions` → topic = faktoid i kunnskapsgrafen
- Node uten edges = løs tanke, ennå uorganisert
**Retyping er trivielt.** Chatmelding → kanban-kort = legg til
board-edge og status-edge. Ingen datamigrering, bare edges.
**Multitype er naturlig.** En node kan være *både* kanban-kort
*og* kalenderoppføring *og* faktoid. Det er ikke en edge case —
det er arkitekturen.
### Edge-tildeling
Når du "bare sier noe" — hvem bestemmer edges?
1. **Kontekst gir det meste.** Du er i en samtale → `belongs_to`-
edge til samtalen. Du er alene → privat. Dekker 80%.
2. **Eksplisitt handling.** Du drar en node til kanban-brettet.
Du tagger noe. Du setter en dato.
3. **AI-foreslått.** Systemet foreslår `mentions`-edge når du
nevner en person. Foreslår kanban når noe ligner en oppgave.
Detaljer for AI-foreslåtte edges avklares ved implementering.
## Mottak-primitiven
Der input er "én overflate som fanger alt", er mottak "én overflate
som presenterer alt tilpasset *deg*."
### Mottaker bestemmer format ### Mottaker bestemmer format
All lyd transkriberes. All tekst kan leses opp (TTS). Noden har alltid
begge representasjoner. Mottaker setter sin preferanse: 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 - Trond snakker inn en tanke → node med lyd + transkripsjon
- Peter har tekst-preferanse → ser transkripsjonen - Peter har tekst-preferanse → ser transkripsjonen
- Vegard har lyd-preferanse → hører originallyd - 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 — Modalitet er ikke en egenskap ved meldingen, men ved *lesningen*.
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 ### Dimensjoner ved mottak
**Format.** Lyd, tekst, visuelt — mottaker bestemmer (allerede beskrevet **Format.** Lyd, tekst, visuelt — mottaker bestemmer.
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 **Filtrering.** Mottaksflaten filtrerer basert på dine edges:
basert på dine edges: hvilke kanaler du følger, hvilke personer du kanaler du følger, personer du samarbeider med, topics du er
samarbeider med, hvilke topics du er interessert i. Du kuraterer ikke interessert i.
manuelt — du justerer edges, og mottaksflaten oppdateres.
**Prioritering.** Hva er viktig *nå*? En AI-assistert redaksjonell flate **Prioritering.** AI-assistert vekting: ubesvarte meldinger,
som løfter frem det som trenger oppmerksomhet: ubesvarte meldinger, oppgaver med frist, noder endret siden sist. Ikke en
oppgaver med frist, noder som er endret siden sist, tråder med aktivitet. notifikasjonsliste — en vektet visning av det som er relevant.
Ikke en notifikasjonsliste — en *vektet visning* av det som er relevant.
**Tempo.** Sanntid eller asynkront. I sanntidslaget: ting streamer inn **Tempo.** Sanntid (ting streamer inn) eller asynkront (digest,
mens de skjer — en kollega snakker, du hører/leser live. I det oppsummering).
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 ### Mottaksflaten er en visning av grafen
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 "Noder med edge til *meg*, vektet på relevans og tid." Ikke en
egen mekanisme — en spørring mot grafen med deg som sentrum.
| 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 ## Kommunikasjonsnoden — den tredje primitiven
Input fanger. Mottak presenterer. Men det mangler noe: *stedet* der folk Input fanger. Mottak presenterer. Kommunikasjonsnoden er *stedet*
møtes. En kommunikasjonsnode er en node i grafen som samler deltakere, der folk møtes — en node som samler deltakere, definerer
definerer tilgangsregler, og fungerer som kontekst for input og mottak. tilgangsregler, og fungerer som kontekst.
### Én node, mange former ### Én node, mange former
En kommunikasjonsnode er konseptuelt identisk uansett skala: | Variant | Deltakere | Input | Mottak |
|---------|-----------|-------|--------|
| Én-til-én | 2 | Begge | Begge |
| Gruppechat | N | Alle | Alle |
| Møte | N | Alle | Alle |
| Allmøte | 1 + N | Leder | Alle lytter |
| Podcastinnspilling | 2-4 + N | Verter | Alle lytter |
| Livesending | 1-4 + ∞ | Verter | Streamet |
| Asynkron gjest | 1 + 1 | Gjest | Redaksjonen |
| Variant | Deltakere | Input-tilgang | Mottak-tilgang | Forskjellen er edge-konfigurasjoner, ikke ulike systemer:
|---------|-----------|---------------|----------------| - `owner`-edge — kontrollerer noden
| Én-til-én samtale | 2 | Begge | Begge | - `member`-edge — kan gi input og motta
| Gruppechat | N | Alle medlemmer | Alle medlemmer | - `reader`-edge — kan kun motta
| 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 ### Kontekst arves automatisk
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 Input i en kommunikasjonsnode arver kontekst-edges. Sier du noe
i et møte → noden får `belongs_to`-edge til møtet automatisk.
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 ### Livssyklus
En kommunikasjonsnode kan være: - **Live** — deltakere til stede, input streames
- **Live** — aktiv i sanntidslaget. Deltakere er til stede, input streames. - **Asynkron** — deltakere gir input i eget tempo
- **Asynkron** — aktiv i det tradisjonelle laget. Deltakere gir input i - **Avsluttet** — arkivert, alt som ble sagt er noder med edges
eget tempo (chat, asynkron gjest). - **Gjenåpnet** — reaktivert ("vi tar opp tråden fra forrige møte")
- **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 ### Skalering er edge-endring
En samtale mellom to blir et møte ved å legge til flere deltaker-edges. Samtale → møte = flere deltaker-edges.
Et møte blir en livesending ved å legge til offentlige mottak-edges. Møte → livesending = offentlige mottak-edges.
En livesending blir en podcast ved å legge til publiserings-edges på Livesending → podcast = publiserings-edges på arkivert innhold.
arkivert innhold. Ingen migrering, ingen konvertering — bare edges.
## Tekniske forutsetninger ## Tekniske forutsetninger
### STT (tale → tekst): løst ### STT (tale → tekst): løst
Faster-whisper kjører lokalt, god norsk kvalitet. Allerede i stacken. Faster-whisper kjører lokalt, god norsk kvalitet.
### TTS (tekst → tale): løsbart ### TTS (tekst → tale): løsbart
Norsk TTS lokalt er ikke godt nok ennå (Piper er "usable" men dårlig rytme, Start med ElevenLabs bak AI Gateway, bytt til lokal modell når
XTTS-v2 støtter ikke norsk). Kommersielt er det løst: kvaliteten holder. Backend-swap bak gatewayen — brukeren merker
- **ElevenLabs** — beste kvalitet, eksplisitt norsk med regionale aksenter ingenting.
- **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 ## Visninger er spørringer
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 Chat = noder med kanal-edge, sortert på tid.
støtte, 350M params, lovende for lokal kjøring. Kanban = noder med board-edge, gruppert på status.
Kalender = noder med dato-edge, på tidslinje.
Dagbok = private noder, sortert på tid.
Mottaksflate = noder med edge til deg, vektet.
## Spenninger og åpne spørsmål Alle leser fra samme graf. Ingen har "sin egen" data.
- **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 ## Forhold til andre retninger
Denne retningen konkretiserer [rom, ikke forum](rom_ikke_forum.md): - [Noder er sentrum](bruker_ikke_workspace.md) — visibility,
- "Formløs input, struktur etterpå" → universell input + edges tilgangsmatrise, aliaser
- "To lag" → sanntidsversjon + flat versjon - [Datalaget](datalaget.md) — SpacetimeDB holder hele grafen,
- "Privat/delt som lag" → synlighet som edge PG persisterer asynkront
- "Siloer forsvinner" → alt er noder, visninger er spørringer - [Maskinrommet](maskinrommet.md) — validering, routing, CAS,
- "Rommet som primitiv" → kommunikasjonsnoden tunge jobber (Whisper, TTS, AI)
- [Rom, ikke forum](rom_ikke_forum.md) — kommunikasjonsnoden
er den konkrete realiseringen av "rommet"

View file

@ -10,8 +10,8 @@ Det lokale miljøet er et **utviklingsmiljø for kode**. Frontend (SvelteKit) kj
| Skrive/teste kode (Rust, SvelteKit, TypeScript) | Lokalt | Rask iterasjon, HMR | | Skrive/teste kode (Rust, SvelteKit, TypeScript) | Lokalt | Rask iterasjon, HMR |
| PG-skjema og migrasjoner | Mot server-PG | Én sannhetskilde | | PG-skjema og migrasjoner | Mot server-PG | Én sannhetskilde |
| Whisper/AI-eksperimentering | Via AI Gateway på server | Felles tjeneste | | Whisper/AI-eksperimentering | Via AI Gateway på server | Felles tjeneste |
| Docker-compose endringer | Direkte i prod | Serveren er dev-miljø | | Docker-compose endringer | Direkte på server | Serveren er dev-miljø |
| Caddy/Authentik/Forgejo config | Direkte i prod | Avhenger av domener, sertifikater, SSO | | Caddy/Authentik/Forgejo config | Direkte på server | Avhenger av domener, sertifikater, SSO |
## 0. Forutsetninger ## 0. Forutsetninger
- Windows 11 med WSL2 (Ubuntu 24.04 LTS) - Windows 11 med WSL2 (Ubuntu 24.04 LTS)
@ -37,8 +37,8 @@ source ~/.cargo/env
```bash ```bash
cd ~ cd ~
git clone ssh://git@git.sidelinja.org:222/sidelinja/server.git sidelinja-v2 git clone ssh://git@git.sidelinja.org:222/vegard/synops.git
cd sidelinja-v2 cd synops
``` ```
## 3. Miljøvariabler (.env.local) ## 3. Miljøvariabler (.env.local)
@ -53,16 +53,13 @@ cp .env.example .env.local
## 4. Utviklingsflyt ## 4. Utviklingsflyt
```bash ```bash
# Start alt lokalt # SvelteKit med HMR
./dev.sh cd web && npm run dev
# Eller manuelt: # Rust maskinrom
cd web && npm run dev # SvelteKit med HMR cd rust && cargo run
cd rust && cargo run # Rust maskinrom
``` ```
`dev.sh` er kanonisk — oppdater alltid scriptet når nye steg oppdages.
## 5. Deploy ## 5. Deploy
```bash ```bash
@ -70,7 +67,7 @@ cd rust && cargo run # Rust maskinrom
git push forgejo main git push forgejo main
# 2. Deploy til prod (krever eksplisitt godkjenning) # 2. Deploy til prod (krever eksplisitt godkjenning)
ssh sidelinja@157.180.81.26 "cd /srv/sidelinja && git pull && docker compose up -d --build" ssh vegard@157.180.81.26 "cd /srv/synops && git pull && docker compose up -d --build"
``` ```
## 6. Forskjeller fra produksjon (bevisste) ## 6. Forskjeller fra produksjon (bevisste)

View file

@ -1,5 +1,14 @@
# Migration Safety Checklist # Migration Safety Checklist
> **Merk (v2):** Denne sjekklisten er skrevet for v1-arkitekturen der RLS var
> basert på `workspace_id`-kolonner og `SET app.current_workspace_id`. I v2 er
> workspace-modellen erstattet av en node-basert tilgangsmatrise (se
> `docs/retninger/bruker_ikke_workspace.md`). Sjekklisten må skrives om for
> det nye mønsteret: `node_access`-matrise, edge-basert tilgang, og
> RLS-policies som opererer på bruker→node-edges i stedet for workspace-scope.
>
> Seksjonene under er bevart som referanse for v1-mønsteret.
Sjekkliste for alle som kjører PostgreSQL-migrasjoner — lokalt eller i prod. Sjekkliste for alle som kjører PostgreSQL-migrasjoner — lokalt eller i prod.
## Før migrering ## Før migrering

View file

@ -1,7 +1,7 @@
# Oppsett: Produksjonsserver (Hetzner VPS) # Oppsett: Produksjonsserver (Hetzner VPS)
**Filsti:** `docs/setup/produksjon.md` **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. Denne oppskriften tar en fersk Ubuntu VPS fra null til en komplett Synops-installasjon. Hvert steg er sekvensielt — ikke hopp over noe.
## 0. Forutsetninger ## 0. Forutsetninger
- Hetzner VPS med Ubuntu 24.04 LTS (8 vCPU, 16 GB RAM minimum) - Hetzner VPS med Ubuntu 24.04 LTS (8 vCPU, 16 GB RAM minimum)
@ -58,17 +58,17 @@ newgrp docker
## 3. Opprett mappestruktur ## 3. Opprett mappestruktur
```bash ```bash
sudo mkdir -p /srv/sidelinja/{config,data,media,logs} sudo mkdir -p /srv/synops/{config,data,media,logs}
sudo mkdir -p /srv/sidelinja/config/{caddy,authentik} sudo mkdir -p /srv/synops/config/{caddy,authentik}
sudo mkdir -p /srv/sidelinja/data/{postgres,spacetimedb,forgejo,authentik} sudo mkdir -p /srv/synops/data/{postgres,spacetimedb,forgejo,authentik}
sudo mkdir -p /srv/sidelinja/media/podcast sudo mkdir -p /srv/synops/media/podcast
sudo mkdir -p /srv/sidelinja/logs/caddy sudo mkdir -p /srv/synops/logs/caddy
sudo chown -R sidelinja:sidelinja /srv/sidelinja sudo chown -R sidelinja:sidelinja /srv/synops
``` ```
Resultat: Resultat:
``` ```
/srv/sidelinja/ /srv/synops/
├── docker-compose.yml ├── docker-compose.yml
├── .env ├── .env
├── config/ ├── config/
@ -88,7 +88,7 @@ Resultat:
## 4. Miljøvariabler (.env) ## 4. Miljøvariabler (.env)
```bash ```bash
cat > /srv/sidelinja/.env << 'EOF' cat > /srv/synops/.env << 'EOF'
# === Domener === # === Domener ===
DOMAIN_SIDELINJA=sidelinja.org DOMAIN_SIDELINJA=sidelinja.org
DOMAIN_VEGARD=vegard.info DOMAIN_VEGARD=vegard.info
@ -122,7 +122,7 @@ OPENROUTER_API_KEY=<fra openrouter.ai>
# Ingen porter eksponeres utenom 80/443. Alt rutes internt via Docker-nettverket. # Ingen porter eksponeres utenom 80/443. Alt rutes internt via Docker-nettverket.
EOF EOF
chmod 600 /srv/sidelinja/.env chmod 600 /srv/synops/.env
``` ```
## 5. Tjeneste-installasjon (rekkefølge) ## 5. Tjeneste-installasjon (rekkefølge)
@ -153,7 +153,7 @@ Tjenestene startes i rekkefølge fordi noen avhenger av andre. Alle defineres i
# REGLER: # REGLER:
# - Ingen "ports:" mot host UTENOM Caddy (80, 443) # - Ingen "ports:" mot host UTENOM Caddy (80, 443)
# - Alle tjenester på samme interne nettverk (sidelinja-net) # - Alle tjenester på samme interne nettverk (sidelinja-net)
# - Volumer bruker bind mounts til /srv/sidelinja/ # - Volumer bruker bind mounts til /srv/synops/
# - .env-filen lastes automatisk av Docker Compose # - .env-filen lastes automatisk av Docker Compose
# - RESSURSGRENSER: Worker-containere (Whisper) MÅ ha deploy.resources.limits # - RESSURSGRENSER: Worker-containere (Whisper) MÅ ha deploy.resources.limits
# for å forhindre at de sultefôrer LiveKit og PostgreSQL. # for å forhindre at de sultefôrer LiveKit og PostgreSQL.
@ -165,10 +165,10 @@ networks:
services: services:
caddy: # Eneste tjeneste med eksponerte porter (80, 443) caddy: # Eneste tjeneste med eksponerte porter (80, 443)
postgres: # data:/srv/sidelinja/data/postgres postgres: # data:/srv/synops/data/postgres
authentik: # SSO for alle domener, på auth.sidelinja.org authentik: # SSO for alle domener, på auth.sidelinja.org
forgejo: # data:/srv/sidelinja/data/forgejo, på git.sidelinja.org forgejo: # data:/srv/synops/data/forgejo, på git.sidelinja.org
spacetimedb: # data:/srv/sidelinja/data/spacetimedb spacetimedb: # data:/srv/synops/data/spacetimedb
livekit: # Intern port, proxyet via Caddy livekit: # Intern port, proxyet via Caddy
sveltekit: # Intern port, proxyet via Caddy sveltekit: # Intern port, proxyet via Caddy
workers: # Rust job workers, ingen porter workers: # Rust job workers, ingen porter
@ -199,13 +199,13 @@ sidelinja.org {
# Podcast media (statiske filer med byte-range support) # Podcast media (statiske filer med byte-range support)
handle_path /media/* { handle_path /media/* {
root * /srv/sidelinja/media root * /srv/synops/media
file_server file_server
} }
# Podcast access log (kun media-forespørsler) # Podcast access log (kun media-forespørsler)
log { log {
output file /srv/sidelinja/logs/caddy/podcast_access.log output file /srv/synops/logs/caddy/podcast_access.log
format json format json
} }
} }
@ -261,10 +261,10 @@ Se `docs/arkitektur.md` seksjon 2.2 for full dataklassifisering. Kun kategori 1
```bash ```bash
# pg_dump er konsistent selv under last — ingen nedetid # pg_dump er konsistent selv under last — ingen nedetid
docker compose exec -T postgres pg_dump -U sidelinja -Fc sidelinja \ docker compose exec -T postgres pg_dump -U sidelinja -Fc sidelinja \
> /srv/sidelinja/backup/pg/sidelinja_$(date +%Y%m%d).dump > /srv/synops/backup/pg/sidelinja_$(date +%Y%m%d).dump
# Behold 30 dager, slett eldre # Behold 30 dager, slett eldre
find /srv/sidelinja/backup/pg/ -name "*.dump" -mtime +30 -delete find /srv/synops/backup/pg/ -name "*.dump" -mtime +30 -delete
``` ```
### 11.1b PostgreSQL WAL-arkivering (kontinuerlig, PITR) ### 11.1b PostgreSQL WAL-arkivering (kontinuerlig, PITR)
@ -307,21 +307,21 @@ repo1-retention-diff=14
### 11.2 Media-filer (daglig, 03:30) ### 11.2 Media-filer (daglig, 03:30)
```bash ```bash
# Inkrementell med rsync til lokal backup-disk eller ekstern lagring # Inkrementell med rsync til lokal backup-disk eller ekstern lagring
rsync -a --delete /srv/sidelinja/media/ /srv/sidelinja/backup/media/ rsync -a --delete /srv/synops/media/ /srv/synops/backup/media/
``` ```
### 11.3 Forgejo-data (daglig, 04:00) ### 11.3 Forgejo-data (daglig, 04:00)
```bash ```bash
# Forgejo-repos kan gjenskapes, men det er tidkrevende. # Forgejo-repos kan gjenskapes, men det er tidkrevende.
# Sikkerhetsnett-backup av hele data-mappen: # Sikkerhetsnett-backup av hele data-mappen:
rsync -a --delete /srv/sidelinja/data/forgejo/ /srv/sidelinja/backup/forgejo/ rsync -a --delete /srv/synops/data/forgejo/ /srv/synops/backup/forgejo/
``` ```
### 11.4 Hemmeligheter (.env) ### 11.4 Hemmeligheter (.env)
```bash ```bash
# Manuell kopi ved endring — ALDRI i Git # Manuell kopi ved endring — ALDRI i Git
cp /srv/sidelinja/.env /srv/sidelinja/backup/env_$(date +%Y%m%d) cp /srv/synops/.env /srv/synops/backup/env_$(date +%Y%m%d)
chmod 600 /srv/sidelinja/backup/env_* chmod 600 /srv/synops/backup/env_*
``` ```
### 11.5 Off-site backup (rclone → Hetzner Object Storage) ### 11.5 Off-site backup (rclone → Hetzner Object Storage)
@ -335,33 +335,33 @@ rclone config
# Opprett remote "hetzner-s3" med Hetzner Object Storage credentials # Opprett remote "hetzner-s3" med Hetzner Object Storage credentials
# (S3-kompatibelt, endpoint: fsn1.your-objectstorage.com eller nbg1) # (S3-kompatibelt, endpoint: fsn1.your-objectstorage.com eller nbg1)
# /srv/sidelinja/scripts/backup-offsite.sh # /srv/synops/scripts/backup-offsite.sh
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
BUCKET="s3:hetzner-s3/sidelinja-backup" BUCKET="s3:hetzner-s3/sidelinja-backup"
# PG-dump (siste lokale dump) # PG-dump (siste lokale dump)
LATEST_DUMP=$(ls -t /srv/sidelinja/backup/pg/*.dump 2>/dev/null | head -1) LATEST_DUMP=$(ls -t /srv/synops/backup/pg/*.dump 2>/dev/null | head -1)
if [ -n "$LATEST_DUMP" ]; then if [ -n "$LATEST_DUMP" ]; then
rclone copy "$LATEST_DUMP" "$BUCKET/pg/" rclone copy "$LATEST_DUMP" "$BUCKET/pg/"
fi fi
# Media (inkrementell sync) # Media (inkrementell sync)
rclone sync /srv/sidelinja/media/ "$BUCKET/media/" --transfers 4 rclone sync /srv/synops/media/ "$BUCKET/media/" --transfers 4
# Behold 90 dager PG-dumper off-site # Behold 90 dager PG-dumper off-site
rclone delete "$BUCKET/pg/" --min-age 90d rclone delete "$BUCKET/pg/" --min-age 90d
echo "$(date): Off-site backup ferdig" >> /srv/sidelinja/logs/backup-offsite.log echo "$(date): Off-site backup ferdig" >> /srv/synops/logs/backup-offsite.log
``` ```
### 11.6 Cron-oppsett ### 11.6 Cron-oppsett
```bash ```bash
# /etc/cron.d/sidelinja-backup # /etc/cron.d/sidelinja-backup
0 3 * * * sidelinja /srv/sidelinja/scripts/backup-pg.sh 0 3 * * * sidelinja /srv/synops/scripts/backup-pg.sh
30 3 * * * sidelinja /srv/sidelinja/scripts/backup-media.sh 30 3 * * * sidelinja /srv/synops/scripts/backup-media.sh
0 4 * * * sidelinja /srv/sidelinja/scripts/backup-forgejo.sh 0 4 * * * sidelinja /srv/synops/scripts/backup-forgejo.sh
30 4 * * * sidelinja /srv/sidelinja/scripts/backup-offsite.sh 30 4 * * * sidelinja /srv/synops/scripts/backup-offsite.sh
``` ```
### 11.7 Hva som IKKE backupes (bevisst) ### 11.7 Hva som IKKE backupes (bevisst)
@ -376,14 +376,14 @@ echo "$(date): Off-site backup ferdig" >> /srv/sidelinja/logs/backup-offsite.log
```bash ```bash
# 1. PostgreSQL # 1. PostgreSQL
docker compose exec -T postgres pg_restore -U sidelinja -d sidelinja --clean \ docker compose exec -T postgres pg_restore -U sidelinja -d sidelinja --clean \
< /srv/sidelinja/backup/pg/sidelinja_YYYYMMDD.dump < /srv/synops/backup/pg/sidelinja_YYYYMMDD.dump
# 2. Media # 2. Media
rsync -a /srv/sidelinja/backup/media/ /srv/sidelinja/media/ rsync -a /srv/synops/backup/media/ /srv/synops/media/
# 3. Forgejo # 3. Forgejo
docker compose down forgejo docker compose down forgejo
rsync -a /srv/sidelinja/backup/forgejo/ /srv/sidelinja/data/forgejo/ rsync -a /srv/synops/backup/forgejo/ /srv/synops/data/forgejo/
docker compose up -d forgejo docker compose up -d forgejo
# 4. Avledede data: trigges automatisk ved webhook eller manuelt # 4. Avledede data: trigges automatisk ved webhook eller manuelt
@ -399,7 +399,7 @@ git push forgejo main
# SSH inn til server: # SSH inn til server:
ssh sidelinja@<server-ip> ssh sidelinja@<server-ip>
cd /srv/sidelinja cd /srv/synops
git pull git pull
docker compose build --no-cache <tjeneste> docker compose build --no-cache <tjeneste>
docker compose up -d <tjeneste> docker compose up -d <tjeneste>

View file

@ -13,7 +13,7 @@ men ikke implementert.
## Sjekkliste ## Sjekkliste
### 1. Git-status ### 1. Git-status
- [ ] Er prod-server på siste commit? (`ssh sidelinja@157.180.81.26 'cd /srv/sidelinja/server && git log -1'`) - [ ] Er prod-server på siste commit? (`ssh vegard@157.180.81.26 'cd /srv/synops/app && git log -1'`)
- [ ] Er det ucommittede endringer lokalt som burde vært pushet? - [ ] Er det ucommittede endringer lokalt som burde vært pushet?
- [ ] Er det commits på Forgejo som ikke er deployet til prod? - [ ] Er det commits på Forgejo som ikke er deployet til prod?
@ -24,7 +24,6 @@ men ikke implementert.
### 3. Docker-tjenester ### 3. Docker-tjenester
- [ ] Kjører alle forventede containere i prod? (`docker compose ps`) - [ ] 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? - [ ] Er image-versjoner oppdatert?
### 4. Miljøvariabler ### 4. Miljøvariabler

View file

@ -55,10 +55,9 @@ fjerne utdaterte referanser, og sikre at dokumentasjon stemmer med virkeligheten
- [ ] Er det gjort arbeid nylig som mangler erfaringsdokumentasjon i `docs/erfaringer/`? - [ ] Er det gjort arbeid nylig som mangler erfaringsdokumentasjon i `docs/erfaringer/`?
- [ ] Er eksisterende erfaringsdokumenter fortsatt relevante og korrekte? - [ ] Er eksisterende erfaringsdokumenter fortsatt relevante og korrekte?
### 8. dev.sh og utviklermiljø ### 8. Utviklermiljø
- [ ] Fungerer `./dev.sh` fra scratch? - [ ] Fungerer lokal utvikling mot server (SvelteKit HMR, Rust build)?
- [ ] Er alle nødvendige tjenester dekket? - [ ] Er `docs/setup/lokal.md` oppdatert med eventuelle nye steg?
- [ ] Er det nye quirks eller workarounds som bør inn i scriptet?
## Sist kjørt ## Sist kjørt

View file

@ -1,7 +1,7 @@
# Server-tilstand ved v1→v2 overgang (mars 2025) # Server-tilstand ved overgang fra Sidelinja v1 (mars 2025)
Denne filen dokumenterer hva som kjører på serveren ved overgangen. Denne filen dokumenterer hva som kjører på serveren ved overgangen.
Slettes når v2 er oppe og stabilt. Slettes når Synops er oppe og stabilt.
## Kjørende containere (docker-compose) ## Kjørende containere (docker-compose)
@ -56,7 +56,7 @@ warmup_config, ai_config, ai_prompts, provider_extra_params, openrouter_only,
api_keys_toggle, api_keys_values, drop_priority_unique, workspace_ai_prompts, api_keys_toggle, api_keys_values, drop_priority_unique, workspace_ai_prompts,
usage_action_column. Pluss seed_dev.sql. usage_action_column. Pluss seed_dev.sql.
v2 starter med nytt skjema (noder+edges). Disse migreringene er irrelevante. Synops starter med nytt skjema (noder+edges). Disse migreringene er irrelevante.
## Crontab ## Crontab
Ingen crontab konfigurert (backup-strategi er dokumentert men ikke implementert). Ingen crontab konfigurert (backup-strategi er dokumentert men ikke implementert).

View file

@ -1,42 +1,76 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Samler all prosjektdokumentasjon til én fil for deling med AI-er etc. # Samler all prosjektdokumentasjon til én fil for deling med AI-er etc.
# Bruk: ./scripts/summary.sh → skriver scripts/summary.md # Bruk: ./scripts/summary.sh → skriver scripts/synops.md
# ./scripts/summary.sh - → skriver til stdout (for piping) # ./scripts/summary.sh - → skriver til stdout (for piping)
#
# Rekkefølge: overblikk → arkitektur → konsepter → tekniske detaljer → drift
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUT="$SCRIPT_DIR/summary.md" OUT="$SCRIPT_DIR/synops.md"
files=( # Prioritert rekkefølge av mapper under docs/
# Overblikk — visjon og retning først # Fra overblikk og visjon → ned til implementeringsdetaljer
"$ROOT/docs/arkitektur.md" DOC_ORDER=(
"$ROOT/CLAUDE.md" retninger # Arkitektoniske teser og vedtatte retninger
"$ROOT"/docs/retninger/*.md primitiver # Spesifikasjoner for kjerneprimitivene
concepts # Brukeropplevelser og produktområder
# Primitiver features # Tekniske byggeklosser
"$ROOT"/docs/primitiver/*.md infra # Infrastruktur og drift
setup # Oppsett og driftsprosedyrer
# Infrastruktur erfaringer # Lærdommer fra implementering
"$ROOT"/docs/infra/*.md proposals # Idébank (uimplementerte forslag)
# Erfaringer
"$ROOT"/docs/erfaringer/*.md
) )
collect() { collect() {
for f in "${files[@]}"; do # 1. Prosjektguide (CLAUDE.md) først — gir komplett overblikk
[[ -f "$f" ]] || continue if [[ -f "$ROOT/CLAUDE.md" ]]; then
rel="${f#"$ROOT/"}" emit "$ROOT/CLAUDE.md"
echo "================================================================" fi
echo "FILE: $rel"
echo "================================================================" # 2. Overordnet arkitektur
echo "" if [[ -f "$ROOT/docs/arkitektur.md" ]]; then
cat "$f" emit "$ROOT/docs/arkitektur.md"
echo "" fi
echo ""
# 3. Dokumentmapper i prioritert rekkefølge
for dir in "${DOC_ORDER[@]}"; do
[[ -d "$ROOT/docs/$dir" ]] || continue
for f in "$ROOT/docs/$dir"/*.md; do
[[ -f "$f" ]] || continue
emit "$f"
done
done done
# 4. Eventuelle docs/-mapper som ikke er i DOC_ORDER (fang nye mapper automatisk)
for dir in "$ROOT"/docs/*/; do
[[ -d "$dir" ]] || continue
dirname="$(basename "$dir")"
# Hopp over de vi allerede har tatt
skip=false
for known in "${DOC_ORDER[@]}"; do
[[ "$dirname" == "$known" ]] && skip=true && break
done
$skip && continue
for f in "$dir"*.md; do
[[ -f "$f" ]] || continue
emit "$f"
done
done
}
emit() {
local f="$1"
local rel="${f#"$ROOT/"}"
echo "================================================================"
echo "FILE: $rel"
echo "================================================================"
echo ""
cat "$f"
echo ""
echo ""
} }
if [[ "${1:-}" == "-" ]]; then if [[ "${1:-}" == "-" ]]; then