# Feature Spec: Kunnskapsgraf og Relasjoner (Logseq-modell) **Filsti:** `docs/features/kunnskapsgraf_og_relasjoner.md` ## 1. Konsept Inspirert av verktøy som Logseq og Obsidian, bygger vi databasen som en toveis-lenket graf. Målet er å skape "serendipity" (lykketreff) i research-fasen ved å synliggjøre uventede forbindelser. Hvis Aktør A og Aktør B begge er nevnt i samme chat-tråd eller knyttet til samme Tema over tid, skal systemet kunne visualisere denne røde tråden for programlederne. ## 2. Arkitektur og Teknologivalg Vi unngår tunge, dedikerte grafdatabaser (som Neo4j) for å holde infrastrukturen og ressursbruken (RAM) minimal. * **Valgt teknologi:** Vanilla PostgreSQL. * **Mekanisme:** En "Nodes and Edges" (Noder og Kanter) tabellstruktur kombinert med Recursive CTEs (Common Table Expressions) i SQL for å traversere grafen. Dette er mer enn raskt nok for redaksjonelle datamengder (100k+ noder). ## 3. Datastruktur ### 3.1 Supertabell: `nodes` Alle entiteter i systemet arver sin UUID fra én sentral tabell. Dette gir ekte Foreign Key-integritet på `graph_edges` uten applikasjonslogikk-hacks. ```sql CREATE TYPE node_type AS ENUM ( 'entitet', -- person, organisasjon, sted, tema, konsept (erstatter 'aktør' og 'tema') 'episode', -- podcast-episode 'segment', -- tidsavgrenset del av episode 'melding', -- meldingsboks (chat, kanban-kort, kalenderhendelse, faktoide, notat) 'channel', -- gruppering av meldinger 'kanban_board', -- strukturelt 'calendar', -- strukturelt 'meeting' -- LiveKit-møte ); CREATE TABLE nodes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), node_type node_type NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ``` **Merk:** Gamle enum-verdier (`'aktør'`, `'tema'`, `'faktoide'`, `'note'`, `'kanban_card'`, `'calendar_event'`) kan ikke fjernes fra PostgreSQL ENUM, men skal aldri brukes i ny kode. ### 3.2 Detailtabeller Hver nodetype har sin egen tabell med FK til `nodes`. #### Entiteter (erstatter `actors` og `topics`) Alt som kan nevnes med `#` i chat er en entitet — personer, organisasjoner, steder, temaer, konsepter. Én tabell, én autocomplete, én `#`-mekanisme. ```sql CREATE TABLE entities ( id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, name TEXT NOT NULL, -- Autoritativ skrivemåte type TEXT NOT NULL, -- 'person', 'organisasjon', 'sted', 'tema', 'konsept' aliases TEXT[] DEFAULT '{}', -- Forkortelser, kallenavn, vanlige feilstavinger avatar_url TEXT -- Portrett, flagg, logo, kommunevåpen ); CREATE INDEX idx_entities_name ON entities(name); CREATE INDEX idx_entities_aliases ON entities USING GIN(aliases); ``` Autocomplete søker i både `name` og `aliases`. `name` er den autoritative formen som vises i UI. Eksempler: - `name: 'Jonas Gahr Støre'`, `aliases: {'JGS', 'Støre'}`, `type: 'person'` - `name: 'Arbeiderpartiet'`, `aliases: {'AP', 'Ap', 'DNA'}`, `type: 'organisasjon'` - `name: 'Lørenskog'`, `aliases: {'Lørenskog kommune'}`, `type: 'sted'` - `name: 'Skolepolitikk'`, `aliases: {}`, `type: 'tema'` #### Episoder og segmenter ```sql CREATE TABLE episodes ( id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, title TEXT NOT NULL, published_at TIMESTAMPTZ, guid TEXT UNIQUE NOT NULL -- RSS , aldri endres ); CREATE TABLE segments ( id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, episode_id UUID NOT NULL REFERENCES episodes(id) ON DELETE CASCADE, start_time INTERVAL NOT NULL, end_time INTERVAL NOT NULL, transcript TEXT, -- Segmentets transkripsjon CONSTRAINT valid_timerange CHECK (end_time > start_time) ); CREATE INDEX idx_segments_episode ON segments(episode_id); CREATE INDEX idx_segments_transcript_fts ON segments USING GIN (to_tsvector('norwegian', transcript)); ``` ### 3.3 Kantene: `graph_edges` All kobling skjer i én sentral tabell med ekte FK-integritet: ```sql CREATE TABLE graph_edges ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), source_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, target_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, relation_type TEXT NOT NULL, -- 'MENTIONS', 'CONTRADICTS', 'WORKS_FOR', 'PART_OF', 'DISCUSSED_IN' context_id UUID REFERENCES nodes(id) ON DELETE SET NULL, confidence REAL CHECK (confidence BETWEEN 0.0 AND 1.0), created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, origin TEXT NOT NULL DEFAULT 'system', -- 'system', 'user', 'ai' created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT no_self_reference CHECK (source_id != target_id), CONSTRAINT unique_edge UNIQUE (source_id, target_id, relation_type) ); CREATE INDEX idx_edges_source ON graph_edges(source_id); CREATE INDEX idx_edges_target ON graph_edges(target_id); CREATE INDEX idx_edges_relation ON graph_edges(relation_type); ``` **Merk:** `UNIQUE(source_id, target_id, relation_type)` forhindrer duplikate relasjoner. Bruk `ON CONFLICT DO NOTHING` eller `ON CONFLICT DO UPDATE SET confidence = ...` ved upsert. **Merk:** `node_type`-enumet kan utvides av feature-spesifikke migrasjoner (f.eks. `valgomat_question`, `valgomat_axis`). Se `docs/concepts/valgomaten.md` for eksempel. ## 4. Segmenter og Transkripsjoner ### 4.1 Segment som grafnode Episoder deles i **segmenter** — tidsavgrensede deler med egen transkripsjon. Hvert segment er en node i grafen og kan kobles til Temaer, Aktører og Faktoider. Dette muliggjør presise oppslag som: "I Episode 42, fra 14:23 til 21:07, diskuterte dere Skolepolitikk og sa følgende..." ``` Episode 42 (node: episode) ├── Segment 00:00-14:22 (node: segment) ──DISCUSSED_IN──► Tema: Mediepolitikk ├── Segment 14:23-21:07 (node: segment) ──DISCUSSED_IN──► Tema: Skolepolitikk │ ──MENTIONS──────► Aktør: Støre └── Segment 21:08-45:00 (node: segment) ──DISCUSSED_IN──► Tema: Kommuneøkonomi ``` Kapitler i RSS-feeden genereres fra segmentene, men er et eget konsern (se `docs/concepts/podcastfabrikken.md`). ### 4.2 Transkripsjoner: Git som master, PG som søkeindeks Transkripsjoner lever i **to steder** med klart eierskap: | Sted | Rolle | Format | |---|---|---| | **Git (Forgejo)** | Kilde til sannhet. Redigerbar, sporbar, diffbar | Markdown med tidsstempler | | **PostgreSQL** | Søkeindeks. Full-text search, koblet til grafen | Segmentert i `segments`-tabellen | **Flyt:** ``` Whisper → Git (rå transkripsjon med tidsstempler) → Redaksjonen korrigerer manuelt ved behov → Push til Forgejo → Forgejo webhook trigger 'transcript_reimport'-jobb i jobbkøen → Rust-worker parser filen, splitter i segmenter → DELETE + INSERT i én PG-transaksjon (idempotent reimport) → Grafkoblinger bevares (segment-UUID deterministisk fra episode-UUID + tidsstempel) ``` **Deterministisk UUID for segmenter:** `UUID = uuid_v5(episode_uuid, start_time_ms)`. Dette sikrer at samme segment alltid får samme UUID, selv ved reimport. Grafkoblinger som peker på segmentet overlever dermed en full reimport. ## 5. Arbeidsflyt: Hvordan grafen vokser Grafen bygger seg opp organisk gjennom daglig bruk av Sidelinja-suiten: 1. **Chat & Notater:** En bruker skriver: *"Apropos #Hans_Petter_Sjøli, hva var greia med #Arbeiderpartiet?"* 2. **Parsing (Svelte/Rust):** Systemet fanger opp de to `#`-taggene (som allerede har UUIDs i `Aktør`-tabellen). 3. **Edge Creation:** SvelteKit server-side oppretter automatisk to nye oppføringer i `graph_edges`-tabellen: * [Melding UUID] -> `MENTIONS` -> [Sjøli UUID] * [Melding UUID] -> `MENTIONS` -> [Arbeiderpartiet UUID] 4. **Indirekte relasjon:** Fordi begge aktørene nå deler samme `context_id` (meldingen), vet Kunnskapsgrafen at det finnes en tematisk kobling mellom Sjøli og Ap. 5. **Publisering:** Når en episode publiseres, kobles segmentene automatisk til relevante Temaer og Aktører basert på AI-analyse av transkripsjonen. ## 6. Instruks for Claude Code * **Tilgangsstyring:** Tilgang til noder styres via `node_access`-matrisen (materialized view). Spørringer mot detailtabeller (entities, etc.) filtrerer via JOIN med `node_access`. * **`nodes`-tabellen er obligatorisk.** Opprett alltid en rad i `nodes` før du inserter i en detailtabell. Bruk en hjelpefunksjon som gjør begge i én transaksjon. * **`graph_edges`-tilgang.** Tilgang til edges avledes fra tilgang til kilde- og målnoder via `node_access`. `UNIQUE(source_id, target_id, relation_type)` hindrer duplikater — bruk `ON CONFLICT` ved upsert. * **Graf-spørringer:** Bruk `WITH RECURSIVE` i PostgreSQL når du bygger endepunkter som skal hente ut "Linked Mentions" eller nettverket rundt en spesifikk Aktør opp til 2-3 ledd ut. * **Fremtidssikring for UI:** Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (som D3.js eller Vis.js) i Svelte-frontenden. Formatet bør være `{ "nodes": [...], "edges": [...] }`. * **Transkripsjon-reimport:** Workeren må være idempotent. Bruk `uuid_v5(episode_uuid, start_time_ms)` for deterministiske segment-UUIDs. Slett og gjenopprett segmenter i én transaksjon, men **ikke** slett edges som peker til segmentene — de overlever fordi UUID-en er stabil. **Merk:** Hvis manuelle korrigeringer endrer segment-grenser (start_time), endres UUID-en. Løsning: automatisk flytt eksisterende edges til nærmeste nye segment basert på tidsintervall-overlapp. * **Full-text search:** Bruk `to_tsvector('norwegian', transcript)` for norsk språkstøtte i søk.