feat: implement R42 chat infrastructure with Anthropic API integration and custom design system
All checks were successful
On Push to Main / test (push) Successful in 30s
On Push to Main / publish (push) Successful in 57s
On Push to Main / deploy-dev (push) Successful in 1m31s

This commit is contained in:
RaymondVerhoef
2026-05-17 16:48:40 +02:00
parent 43d01dff58
commit 98e32d8ac0
21 changed files with 2631 additions and 6 deletions

127
src/components/chat/rag.js Normal file
View File

@@ -0,0 +1,127 @@
import * as db from '../../lib/db';
/**
* Build a compact knowledge-base context string to inject into the system prompt.
* Reads topics + relations from PocketBase via db.js.
* Topic-level content is loaded only when a topic id/label appears in the user's message.
*
* Returns { context: string, topics: Array } so callers can reuse the fetched topics
* for validateDelta without a second round-trip.
*/
export async function buildKbContext(userMessage = '') {
const [topics, relations] = await Promise.all([
db.getTopics(),
db.getRelations(),
]);
if (topics.length === 0) {
return {
context: 'KENNISGRAAF: (leeg — er zijn nog geen onderwerpen geëxtraheerd)',
topics: [],
};
}
const topicLines = topics.map(t => {
const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200);
return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`;
});
const relLines = relations.map(r => {
const src = typeof r.source === 'object' ? r.source.id : r.source;
const tgt = typeof r.target === 'object' ? r.target.id : r.target;
return `- ${src} --${r.type}--> ${tgt}`;
});
// Pull deep content for any topic explicitly mentioned in the user message.
const lowered = userMessage.toLowerCase();
const mentionedDeepContent = [];
for (const t of topics) {
const idHit = t.id && lowered.includes(t.id.toLowerCase());
const labelHit = t.label && lowered.includes(t.label.toLowerCase());
if (idHit || labelHit) {
const content = await db.getContent(t.id).catch(() => null);
if (content) {
let raw;
if (typeof content === 'string') raw = content;
else if (content.article) raw = content.article;
else raw = JSON.stringify(content);
const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200);
mentionedDeepContent.push(`### ${t.label}\n${snippet}`);
}
}
}
const context = [
`KENNISGRAAF — TOPICS:`,
topicLines.join('\n'),
``,
`KENNISGRAAF — RELATIES:`,
relLines.length ? relLines.join('\n') : '(geen relaties)',
mentionedDeepContent.length
? `\n\nDIEPERE INHOUD (voor genoemde topics):\n${mentionedDeepContent.join('\n\n')}`
: '',
].join('\n');
return { context, topics };
}
/**
* Validate a delta proposal against the current topic list (already fetched).
* Drops:
* - topics whose id already exists (by id or case-folded label)
* - relations whose source/target isn't in the current graph + this delta
* - self-referencing relations
* Caps to 3 topics + 5 relations.
*
* @param {object} delta - Raw input from the propose_graph_delta tool call
* @param {Array} existingTopics - Topics already fetched from PocketBase
*/
export function validateDelta(delta, existingTopics = []) {
if (!delta || typeof delta !== 'object') return null;
const existingIds = new Set(existingTopics.map(t => t.id));
const existingLabels = new Set(existingTopics.map(t => (t.label || '').toLowerCase()));
const safeTopics = [];
for (const t of Array.isArray(delta.topics) ? delta.topics : []) {
if (!t || typeof t.id !== 'string' || typeof t.label !== 'string') continue;
if (existingIds.has(t.id)) continue;
if (existingLabels.has(t.label.toLowerCase())) continue;
if (!['concept', 'role', 'process'].includes(t.type)) continue;
safeTopics.push({
id: t.id.trim(),
label: t.label.trim(),
type: t.type,
description: (t.description || '').trim(),
});
existingIds.add(t.id);
existingLabels.add(t.label.toLowerCase());
if (safeTopics.length >= 3) break;
}
const knownAfter = new Set([...existingIds, ...safeTopics.map(t => t.id)]);
const safeRelations = [];
for (const r of Array.isArray(delta.relations) ? delta.relations : []) {
if (!r || typeof r.source !== 'string' || typeof r.target !== 'string') continue;
if (r.source === r.target) continue;
if (!knownAfter.has(r.source) || !knownAfter.has(r.target)) continue;
if (!['related_to', 'depends_on', 'part_of', 'executed_by'].includes(r.type)) continue;
safeRelations.push({ source: r.source, target: r.target, type: r.type });
if (safeRelations.length >= 5) break;
}
if (safeTopics.length === 0 && safeRelations.length === 0) return null;
return {
reason: typeof delta.reason === 'string' ? delta.reason : '',
topics: safeTopics,
relations: safeRelations,
};
}
/** Stable key for a delta (used to dedupe within a thread). */
export function deltaKey(delta) {
const t = delta.topics.map(x => x.id).sort().join(',');
const r = delta.relations.map(x => `${x.source}-${x.type}->${x.target}`).sort().join(',');
return `t:${t}|r:${r}`;
}