Kunnskapsgraf CRUD API: entities, edges, søk og traversering

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 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-15 15:37:00 +01:00
parent 1faef972dd
commit 1d47119b1e
7 changed files with 418 additions and 0 deletions

View file

@ -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 });
};

View file

@ -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 });
};

View file

@ -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);
};

View file

@ -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 });
};

View file

@ -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 });
};

View file

@ -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 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 });
};

View file

@ -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
});
};