From 1d47119b1e5728bd3bbfc769f46d7e8bde305f61 Mon Sep 17 00:00:00 2001 From: vegard Date: Sun, 15 Mar 2026 15:37:00 +0100 Subject: [PATCH] =?UTF-8?q?Kunnskapsgraf=20CRUD=20API:=20entities,=20edges?= =?UTF-8?q?,=20s=C3=B8k=20og=20traversering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Syv nye API-endepunkter for kunnskapsgrafen: Entities: - GET/POST /api/entities — list, søk (name+aliases), filtrer på type - GET/PATCH/DELETE /api/entities/:id — hent (m/edge_count), oppdater, slett - GET /api/entities/:id/edges — relasjoner med retningsfilter Graf: - POST /api/graph/edges — opprett relasjon (upsert) - DELETE /api/graph/edges/:id — slett relasjon - GET /api/graph/search — fulltekstsøk (entiteter + transkripsjoner FTS) - GET /api/graph/traverse/:nodeId — recursive CTE, D3.js/Vis.js-format Co-Authored-By: Claude Opus 4.6 --- web/src/routes/api/entities/+server.ts | 83 +++++++++++++++++++ .../routes/api/entities/[entityId]/+server.ts | 73 ++++++++++++++++ .../api/entities/[entityId]/edges/+server.ts | 58 +++++++++++++ web/src/routes/api/graph/edges/+server.ts | 50 +++++++++++ .../api/graph/edges/[edgeId]/+server.ts | 18 ++++ web/src/routes/api/graph/search/+server.ts | 53 ++++++++++++ .../api/graph/traverse/[nodeId]/+server.ts | 83 +++++++++++++++++++ 7 files changed, 418 insertions(+) create mode 100644 web/src/routes/api/entities/+server.ts create mode 100644 web/src/routes/api/entities/[entityId]/+server.ts create mode 100644 web/src/routes/api/entities/[entityId]/edges/+server.ts create mode 100644 web/src/routes/api/graph/edges/+server.ts create mode 100644 web/src/routes/api/graph/edges/[edgeId]/+server.ts create mode 100644 web/src/routes/api/graph/search/+server.ts create mode 100644 web/src/routes/api/graph/traverse/[nodeId]/+server.ts diff --git a/web/src/routes/api/entities/+server.ts b/web/src/routes/api/entities/+server.ts new file mode 100644 index 0000000..036a209 --- /dev/null +++ b/web/src/routes/api/entities/+server.ts @@ -0,0 +1,83 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** GET /api/entities?q=...&type=...&limit=... — List/søk entiteter */ +export const GET: RequestHandler = async ({ url, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const q = url.searchParams.get('q')?.trim(); + const type = url.searchParams.get('type'); + const limit = Math.min(Number(url.searchParams.get('limit') ?? 50), 100); + + let entities; + if (q) { + // Søk i name og aliases + entities = await sql` + SELECT e.id, e.name, e.type, e.aliases, e.avatar_url, n.created_at, n.updated_at + FROM entities e + JOIN nodes n ON n.id = e.id + WHERE n.workspace_id = ${locals.workspace.id} + AND ( + e.name ILIKE ${'%' + q + '%'} + OR EXISTS (SELECT 1 FROM unnest(e.aliases) AS a WHERE a ILIKE ${'%' + q + '%'}) + ) + ${type ? sql`AND e.type = ${type}` : sql``} + ORDER BY + CASE WHEN e.name ILIKE ${q + '%'} THEN 0 ELSE 1 END, + e.name + LIMIT ${limit} + `; + } else { + entities = await sql` + SELECT e.id, e.name, e.type, e.aliases, e.avatar_url, n.created_at, n.updated_at + FROM entities e + JOIN nodes n ON n.id = e.id + WHERE n.workspace_id = ${locals.workspace.id} + ${type ? sql`AND e.type = ${type}` : sql``} + ORDER BY e.name + LIMIT ${limit} + `; + } + + return json(entities); +}; + +/** POST /api/entities — Opprett entitet */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const body = await request.json(); + + if (!body.name?.trim()) error(400, 'name er påkrevd'); + if (!body.type?.trim()) error(400, 'type er påkrevd'); + + const validTypes = ['person', 'organisasjon', 'sted', 'tema', 'konsept']; + if (!validTypes.includes(body.type)) { + error(400, `type må være en av: ${validTypes.join(', ')}`); + } + + const [entity] = await sql` + WITH new_node AS ( + INSERT INTO nodes (workspace_id, node_type) + VALUES (${locals.workspace.id}, 'entitet') + RETURNING id, created_at, updated_at + ), + new_entity AS ( + INSERT INTO entities (id, name, type, aliases, avatar_url) + SELECT + new_node.id, + ${body.name.trim()}, + ${body.type}, + ${body.aliases ?? []}, + ${body.avatar_url ?? null} + FROM new_node + RETURNING id, name, type, aliases, avatar_url + ) + SELECT ne.*, nn.created_at, nn.updated_at + FROM new_entity ne + JOIN new_node nn ON nn.id = ne.id + `; + + return json(entity, { status: 201 }); +}; diff --git a/web/src/routes/api/entities/[entityId]/+server.ts b/web/src/routes/api/entities/[entityId]/+server.ts new file mode 100644 index 0000000..b115079 --- /dev/null +++ b/web/src/routes/api/entities/[entityId]/+server.ts @@ -0,0 +1,73 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** GET /api/entities/:entityId — Hent entitet med edge-count */ +export const GET: RequestHandler = async ({ params, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const [entity] = await sql` + SELECT e.id, e.name, e.type, e.aliases, e.avatar_url, + n.created_at, n.updated_at, + (SELECT COUNT(*) FROM graph_edges ge + WHERE ge.source_id = e.id OR ge.target_id = e.id) AS edge_count + FROM entities e + JOIN nodes n ON n.id = e.id + WHERE e.id = ${params.entityId} AND n.workspace_id = ${locals.workspace.id} + `; + if (!entity) error(404, 'Entitet ikke funnet'); + + return json(entity); +}; + +/** PATCH /api/entities/:entityId — Oppdater entitet */ +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const updates = await request.json(); + + const [existing] = await sql` + SELECT e.id FROM entities e + JOIN nodes n ON n.id = e.id + WHERE e.id = ${params.entityId} AND n.workspace_id = ${locals.workspace.id} + `; + if (!existing) error(404, 'Entitet ikke funnet'); + + if (updates.type) { + const validTypes = ['person', 'organisasjon', 'sted', 'tema', 'konsept']; + if (!validTypes.includes(updates.type)) { + error(400, `type må være en av: ${validTypes.join(', ')}`); + } + } + + const [updated] = await sql` + UPDATE entities SET + name = COALESCE(${updates.name?.trim() ?? null}, name), + type = COALESCE(${updates.type ?? null}, type), + aliases = CASE WHEN ${updates.aliases !== undefined} THEN ${updates.aliases ?? []} ELSE aliases END, + avatar_url = CASE WHEN ${updates.avatar_url !== undefined} THEN ${updates.avatar_url ?? null} ELSE avatar_url END + WHERE id = ${params.entityId} + RETURNING id, name, type, aliases, avatar_url, + (SELECT created_at FROM nodes WHERE id = entities.id) AS created_at, + (SELECT updated_at FROM nodes WHERE id = entities.id) AS updated_at + `; + + return json(updated); +}; + +/** DELETE /api/entities/:entityId — Slett entitet */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const [entity] = await sql` + SELECT e.id FROM entities e + JOIN nodes n ON n.id = e.id + WHERE e.id = ${params.entityId} AND n.workspace_id = ${locals.workspace.id} + `; + if (!entity) error(404, 'Entitet ikke funnet'); + + // Slett node (cascader til entities + graph_edges) + await sql`DELETE FROM nodes WHERE id = ${params.entityId}`; + + return new Response(null, { status: 204 }); +}; diff --git a/web/src/routes/api/entities/[entityId]/edges/+server.ts b/web/src/routes/api/entities/[entityId]/edges/+server.ts new file mode 100644 index 0000000..abf9e3f --- /dev/null +++ b/web/src/routes/api/entities/[entityId]/edges/+server.ts @@ -0,0 +1,58 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** GET /api/entities/:entityId/edges — Hent alle relasjoner for en entitet */ +export const GET: RequestHandler = async ({ params, url, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const entityId = params.entityId; + const direction = url.searchParams.get('direction'); // 'outgoing', 'incoming', null=both + const relationType = url.searchParams.get('relation_type'); + + // Verifiser tilgang + const [entity] = await sql` + SELECT e.id FROM entities e + JOIN nodes n ON n.id = e.id + WHERE e.id = ${entityId} AND n.workspace_id = ${locals.workspace.id} + `; + if (!entity) error(404, 'Entitet ikke funnet'); + + const edges = await sql` + SELECT + ge.id AS edge_id, + ge.source_id, + ge.target_id, + ge.relation_type, + ge.confidence, + ge.origin, + ge.created_at, + rt.label AS relation_label, + CASE + WHEN ge.source_id = ${entityId} THEN target_e.name + ELSE source_e.name + END AS connected_name, + CASE + WHEN ge.source_id = ${entityId} THEN target_e.type + ELSE source_e.type + END AS connected_type, + CASE + WHEN ge.source_id = ${entityId} THEN ge.target_id + ELSE ge.source_id + END AS connected_id + FROM graph_edges ge + JOIN relation_types rt ON rt.name = ge.relation_type + LEFT JOIN entities source_e ON source_e.id = ge.source_id + LEFT JOIN entities target_e ON target_e.id = ge.target_id + WHERE ge.workspace_id = ${locals.workspace.id} + AND ( + ${direction === 'outgoing' ? sql`ge.source_id = ${entityId}` : + direction === 'incoming' ? sql`ge.target_id = ${entityId}` : + sql`(ge.source_id = ${entityId} OR ge.target_id = ${entityId})`} + ) + ${relationType ? sql`AND ge.relation_type = ${relationType}` : sql``} + ORDER BY ge.created_at DESC + `; + + return json(edges); +}; diff --git a/web/src/routes/api/graph/edges/+server.ts b/web/src/routes/api/graph/edges/+server.ts new file mode 100644 index 0000000..674e273 --- /dev/null +++ b/web/src/routes/api/graph/edges/+server.ts @@ -0,0 +1,50 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** POST /api/graph/edges — Opprett graf-relasjon */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const body = await request.json(); + + if (!body.source_id || !body.target_id || !body.relation_type) { + error(400, 'source_id, target_id og relation_type er påkrevd'); + } + + if (body.source_id === body.target_id) { + error(400, 'En node kan ikke relateres til seg selv'); + } + + // Verifiser at begge noder tilhører workspace + const nodes = await sql` + SELECT id FROM nodes + WHERE id IN (${body.source_id}, ${body.target_id}) + AND workspace_id = ${locals.workspace.id} + `; + if (nodes.length !== 2) error(404, 'En eller begge noder ikke funnet i workspace'); + + // Verifiser at relation_type er gyldig + const [relType] = await sql` + SELECT name FROM relation_types WHERE name = ${body.relation_type} + `; + if (!relType) error(400, `Ugyldig relation_type: ${body.relation_type}`); + + const [edge] = await sql` + INSERT INTO graph_edges (workspace_id, source_id, target_id, relation_type, confidence, created_by, origin) + VALUES ( + ${locals.workspace.id}, + ${body.source_id}, + ${body.target_id}, + ${body.relation_type}, + ${body.confidence ?? null}, + ${locals.user.id}, + ${body.origin ?? 'user'} + ) + ON CONFLICT (source_id, target_id, relation_type) DO UPDATE SET + confidence = COALESCE(EXCLUDED.confidence, graph_edges.confidence) + RETURNING id, source_id, target_id, relation_type, confidence, origin, created_at + `; + + return json(edge, { status: 201 }); +}; diff --git a/web/src/routes/api/graph/edges/[edgeId]/+server.ts b/web/src/routes/api/graph/edges/[edgeId]/+server.ts new file mode 100644 index 0000000..3304be5 --- /dev/null +++ b/web/src/routes/api/graph/edges/[edgeId]/+server.ts @@ -0,0 +1,18 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** DELETE /api/graph/edges/:edgeId — Slett graf-relasjon */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const [edge] = await sql` + SELECT id FROM graph_edges + WHERE id = ${params.edgeId} AND workspace_id = ${locals.workspace.id} + `; + if (!edge) error(404, 'Relasjon ikke funnet'); + + await sql`DELETE FROM graph_edges WHERE id = ${params.edgeId}`; + + return new Response(null, { status: 204 }); +}; diff --git a/web/src/routes/api/graph/search/+server.ts b/web/src/routes/api/graph/search/+server.ts new file mode 100644 index 0000000..b88b629 --- /dev/null +++ b/web/src/routes/api/graph/search/+server.ts @@ -0,0 +1,53 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** + * GET /api/graph/search?q=...&limit=... + * Fulltekstsøk på tvers av entiteter og segmenter (transkripsjoner). + * Entiteter søkes via ILIKE (name + aliases). + * Segmenter søkes via norsk full-text search (to_tsvector). + */ +export const GET: RequestHandler = async ({ url, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const q = url.searchParams.get('q')?.trim(); + if (!q) error(400, 'q (søkeord) er påkrevd'); + + const limit = Math.min(Number(url.searchParams.get('limit') ?? 20), 50); + + // Søk entiteter + const entities = await sql` + SELECT e.id, e.name, e.type, e.aliases, 'entitet' AS result_type, + n.created_at + FROM entities e + JOIN nodes n ON n.id = e.id + WHERE n.workspace_id = ${locals.workspace.id} + AND ( + e.name ILIKE ${'%' + q + '%'} + OR EXISTS (SELECT 1 FROM unnest(e.aliases) AS a WHERE a ILIKE ${'%' + q + '%'}) + ) + ORDER BY + CASE WHEN e.name ILIKE ${q + '%'} THEN 0 ELSE 1 END, + e.name + LIMIT ${limit} + `; + + // Søk segmenter (transkripsjoner) med norsk FTS + const segments = await sql` + SELECT s.id, s.transcript, s.start_time, s.end_time, + ep.title AS episode_title, ep.id AS episode_id, + 'segment' AS result_type, + ts_headline('norwegian', s.transcript, plainto_tsquery('norwegian', ${q}), + 'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS highlight + FROM segments s + JOIN episodes ep ON ep.id = s.episode_id + JOIN nodes n ON n.id = s.id + WHERE n.workspace_id = ${locals.workspace.id} + AND to_tsvector('norwegian', s.transcript) @@ plainto_tsquery('norwegian', ${q}) + ORDER BY ts_rank(to_tsvector('norwegian', s.transcript), plainto_tsquery('norwegian', ${q})) DESC + LIMIT ${limit} + `; + + return json({ entities, segments }); +}; diff --git a/web/src/routes/api/graph/traverse/[nodeId]/+server.ts b/web/src/routes/api/graph/traverse/[nodeId]/+server.ts new file mode 100644 index 0000000..f3bff18 --- /dev/null +++ b/web/src/routes/api/graph/traverse/[nodeId]/+server.ts @@ -0,0 +1,83 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** + * GET /api/graph/traverse/:nodeId?depth=2 + * Traverser grafen fra en node og returner nettverket. + * Bruker recursive CTE for å følge edges opp til N ledd. + * Returformat: { nodes: [...], edges: [...] } (D3.js/Vis.js-kompatibelt) + */ +export const GET: RequestHandler = async ({ params, url, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const nodeId = params.nodeId; + const depth = Math.min(Number(url.searchParams.get('depth') ?? 2), 3); + + // Verifiser at startnoden tilhører workspace + const [startNode] = await sql` + SELECT id FROM nodes + WHERE id = ${nodeId} AND workspace_id = ${locals.workspace.id} + `; + if (!startNode) error(404, 'Node ikke funnet'); + + // Recursive CTE: finn alle noder innenfor N ledd + const result = await sql` + WITH RECURSIVE traversal AS ( + -- Startnode + SELECT ${nodeId}::uuid AS node_id, 0 AS depth + UNION + -- Følg edges i begge retninger + SELECT + CASE + WHEN ge.source_id = t.node_id THEN ge.target_id + ELSE ge.source_id + END AS node_id, + t.depth + 1 + FROM traversal t + JOIN graph_edges ge ON (ge.source_id = t.node_id OR ge.target_id = t.node_id) + WHERE t.depth < ${depth} + AND ge.workspace_id = ${locals.workspace.id} + ), + reachable_nodes AS ( + SELECT DISTINCT node_id, MIN(depth) AS depth FROM traversal GROUP BY node_id + ) + SELECT + rn.node_id AS id, + rn.depth, + n.node_type, + e.name, + e.type AS entity_type, + e.avatar_url + FROM reachable_nodes rn + JOIN nodes n ON n.id = rn.node_id + LEFT JOIN entities e ON e.id = rn.node_id + ORDER BY rn.depth, e.name NULLS LAST + `; + + // Hent alle edges mellom de nåbare nodene + const nodeIds = result.map((r) => r.id); + const edges = nodeIds.length > 0 + ? await sql` + SELECT ge.id, ge.source_id, ge.target_id, ge.relation_type, + ge.confidence, ge.origin, rt.label AS relation_label + FROM graph_edges ge + JOIN relation_types rt ON rt.name = ge.relation_type + WHERE ge.workspace_id = ${locals.workspace.id} + AND ge.source_id = ANY(${nodeIds}) + AND ge.target_id = ANY(${nodeIds}) + ` + : []; + + return json({ + nodes: result.map((r) => ({ + id: r.id, + depth: r.depth, + node_type: r.node_type, + name: r.name, + entity_type: r.entity_type, + avatar_url: r.avatar_url + })), + edges + }); +};