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:
parent
1faef972dd
commit
1d47119b1e
7 changed files with 418 additions and 0 deletions
83
web/src/routes/api/entities/+server.ts
Normal file
83
web/src/routes/api/entities/+server.ts
Normal 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 });
|
||||
};
|
||||
73
web/src/routes/api/entities/[entityId]/+server.ts
Normal file
73
web/src/routes/api/entities/[entityId]/+server.ts
Normal 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 });
|
||||
};
|
||||
58
web/src/routes/api/entities/[entityId]/edges/+server.ts
Normal file
58
web/src/routes/api/entities/[entityId]/edges/+server.ts
Normal 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);
|
||||
};
|
||||
50
web/src/routes/api/graph/edges/+server.ts
Normal file
50
web/src/routes/api/graph/edges/+server.ts
Normal 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 });
|
||||
};
|
||||
18
web/src/routes/api/graph/edges/[edgeId]/+server.ts
Normal file
18
web/src/routes/api/graph/edges/[edgeId]/+server.ts
Normal 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 });
|
||||
};
|
||||
53
web/src/routes/api/graph/search/+server.ts
Normal file
53
web/src/routes/api/graph/search/+server.ts
Normal 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 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 });
|
||||
};
|
||||
83
web/src/routes/api/graph/traverse/[nodeId]/+server.ts
Normal file
83
web/src/routes/api/graph/traverse/[nodeId]/+server.ts
Normal 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
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue