Files
learning-platform/src/components/chat/rag.js
RaymondVerhoef 229246f7b6
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 32s
On Pull Request to Main / publish (pull_request) Successful in 57s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m34s
feat: phase 5 of AI pipeline hardening — R42 retrieval & telemetry
- Add dependency-free TF-IDF retrieval (src/lib/retrieval.js) with NL+EN
  stopwords and a WeakMap-cached index.
- Rewrite buildKbContext to ship the top-K relevant topics + verbatim-
  mentioned ids only, filter relations to the included set, and append a
  [kb_hash: <8 hex>] suffix so the ephemeral prompt cache busts when the
  graph changes. Returns { context, retrievedTopics, allTopics }.
- Add LOOKUP_TOPIC_TOOL and drive useChat through callLLM directly with a
  multi-hop tool_result loop capped at 3 hops; preserve Anthropic-provided
  tool_use ids through callLLM so the loop can echo correct tool_use_id.
- Truncate R42 history to the last 12 turns and prepend a single
  "(earlier conversation truncated)" assistant message.
- Set R42 chat defaults: temperature 0.3, maxTokens 2048.
- Add pb_migrations/1780500002_created_llm_calls.js (the best-effort
  logger in callLLM was already wired) and a new Admin → Diagnostics
  view showing the last 100 calls with token usage, cache-hit rate, and
  USD cost from a local Anthropic price table.
- Finalize AI_PIPELINE_HARDENING_PLAN.md: mark Phases 1–5 shipped and
  Phase 6 (eval harness) explicitly out of scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:36:40 +02:00

179 lines
6.6 KiB
JavaScript

import * as db from '../../lib/db';
import { buildIndex, retrieveTopK } from '../../lib/retrieval';
const TOP_K = 10;
async function sha256Hex(input) {
const enc = new TextEncoder().encode(input);
if (globalThis.crypto?.subtle?.digest) {
const buf = await globalThis.crypto.subtle.digest('SHA-256', enc);
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
let h = 2166136261 >>> 0;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0).toString(16).padStart(8, '0');
}
/**
* Build a retrieval-scoped KB context. Instead of dumping the whole graph,
* we pick the top-K topics by TF-IDF over `userMessage`, plus any topic
* whose id or label appears verbatim in the message. Relations are filtered
* to those that touch the included set.
*
* A `[kb_hash: …]` suffix is appended so the Anthropic ephemeral prompt
* cache automatically busts when topics are added/removed.
*
* Returns:
* { context, retrievedTopics, allTopics }
* — `allTopics` is the full PocketBase list so callers can still run
* `validateDelta` against the entire current graph.
*/
export async function buildKbContext(userMessage = '') {
const [allTopics, allRelations] = await Promise.all([
db.getTopics(),
db.getRelations(),
]);
const sortedIds = allTopics.map(t => t.id).sort().join('|');
const fullHash = await sha256Hex(sortedIds);
const kbHash = fullHash.slice(0, 8);
if (allTopics.length === 0) {
return {
context: `KENNISGRAAF: (leeg — er zijn nog geen onderwerpen geëxtraheerd)\n[kb_hash: ${kbHash}]`,
retrievedTopics: [],
allTopics: [],
};
}
const lowered = userMessage.toLowerCase();
const mentionedIds = new Set();
for (const t of allTopics) {
const idHit = t.id && lowered.includes(t.id.toLowerCase());
const labelHit = t.label && lowered.includes(t.label.toLowerCase());
if (idHit || labelHit) mentionedIds.add(t.id);
}
const index = buildIndex(allTopics);
const retrieved = retrieveTopK(index, userMessage, TOP_K);
const includedById = new Map();
for (const id of mentionedIds) {
const t = allTopics.find(x => x.id === id);
if (t) includedById.set(id, t);
}
for (const t of retrieved) {
if (!includedById.has(t.id)) includedById.set(t.id, t);
}
const included = [...includedById.values()];
const topicLines = included.map(t => {
const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200);
return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`;
});
const includedIds = new Set(included.map(t => t.id));
const relLines = [];
for (const r of allRelations) {
const src = typeof r.source === 'object' ? r.source.id : r.source;
const tgt = typeof r.target === 'object' ? r.target.id : r.target;
if (includedIds.has(src) && includedIds.has(tgt)) {
relLines.push(`- ${src} --${r.type}--> ${tgt}`);
}
}
const mentionedDeepContent = [];
for (const id of mentionedIds) {
const t = includedById.get(id);
if (!t) continue;
const content = await db.getContent(t.id).catch(() => null);
if (!content) continue;
let raw;
if (typeof content === 'string') raw = content;
else if (content.article) raw = typeof content.article === 'string' ? content.article : JSON.stringify(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 — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`,
topicLines.join('\n'),
``,
`KENNISGRAAF — RELATIES (binnen deze selectie):`,
relLines.length ? relLines.join('\n') : '(geen relaties binnen deze selectie)',
mentionedDeepContent.length
? `\n\nDIEPERE INHOUD (voor genoemde topics):\n${mentionedDeepContent.join('\n\n')}`
: '',
``,
`Als de relevante context hierboven te beperkt is, gebruik dan de tool "lookup_topic" om de volledige beschrijving en eventuele leerinhoud van een specifiek topic op te halen.`,
`[kb_hash: ${kbHash}]`,
].join('\n');
return { context, retrievedTopics: included, allTopics };
}
/**
* 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}`;
}