feat: phase 5 of AI pipeline hardening — R42 retrieval & telemetry
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

- 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>
This commit is contained in:
RaymondVerhoef
2026-05-20 21:36:40 +02:00
parent 66e0c275da
commit 229246f7b6
11 changed files with 753 additions and 56 deletions

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { buildIndex, retrieveTopK, tokenize } from '../retrieval';
const sampleTopics = [
{ id: 'software-engineer', label: 'Software Engineer', description: 'Bouwt en onderhoudt applicaties; werkt in agile teams.' },
{ id: 'onboarding-buddy', label: 'Onboarding Buddy', description: 'Begeleidt nieuwe medewerkers in hun eerste weken.' },
{ id: 'kennisbeheer', label: 'Kennisbeheer', description: 'Het proces van het vastleggen en ontsluiten van organisatiekennis.' },
{ id: 'wekelijkse-sessie', label: 'Wekelijkse Leersessie', description: 'Microlearning sessie waarin medewerkers wekelijks leren via AI-gegenereerde quizzen.' },
];
describe('tokenize', () => {
it('lowercases and splits on non-alphanumeric', () => {
expect(tokenize('Hello, World!')).toEqual(['hello', 'world']);
});
it('drops stopwords and short tokens', () => {
expect(tokenize('de software engineer is hier')).toEqual(['software', 'engineer', 'hier']);
});
it('keeps hyphenated identifiers', () => {
expect(tokenize('software-engineer onboarding-buddy')).toEqual(['software-engineer', 'onboarding-buddy']);
});
});
describe('buildIndex / retrieveTopK', () => {
it('returns empty for empty topics', () => {
const idx = buildIndex([]);
expect(retrieveTopK(idx, 'anything')).toEqual([]);
});
it('returns empty for empty query', () => {
const idx = buildIndex(sampleTopics);
expect(retrieveTopK(idx, '')).toEqual([]);
});
it('ranks the most relevant topic first', () => {
const idx = buildIndex(sampleTopics);
const hits = retrieveTopK(idx, 'wat doet een onboarding buddy?', 2);
expect(hits[0].id).toBe('onboarding-buddy');
});
it('matches on description when label does not contain query terms', () => {
const idx = buildIndex(sampleTopics);
const hits = retrieveTopK(idx, 'microlearning quizzen', 3);
expect(hits.map(h => h.id)).toContain('wekelijkse-sessie');
});
it('returns no hits when no terms match', () => {
const idx = buildIndex(sampleTopics);
expect(retrieveTopK(idx, 'kwantumfysica raketten')).toEqual([]);
});
it('caches the index per topics array reference', () => {
const idx1 = buildIndex(sampleTopics);
const idx2 = buildIndex(sampleTopics);
expect(idx1).toBe(idx2);
});
});

View File

@@ -327,6 +327,17 @@ export async function bulkSetCurriculum(year, weeks) {
);
}
// ── LLM Call Telemetry ───────────────────────────────────────────────────────
export async function getRecentLlmCalls(limit = 100) {
try {
const r = await pb.collection('llm_calls').getList(1, limit, { sort: '-created' });
return r.items;
} catch {
return [];
}
}
// ── Handbook Sync State ───────────────────────────────────────────────────────
export async function getHandbookSyncStates() {

View File

@@ -236,7 +236,7 @@ function extractToolUses(content) {
if (!Array.isArray(content)) return [];
return content
.filter((b) => b?.type === 'tool_use')
.map((b) => ({ name: b.name, input: b.input }));
.map((b) => ({ id: b.id, name: b.name, input: b.input }));
}
function extractText(content) {

95
src/lib/retrieval.js Normal file
View File

@@ -0,0 +1,95 @@
/**
* Lightweight, dependency-free TF-IDF retrieval over the knowledge graph.
*
* `buildIndex(topics)` tokenises the `label + description` of each topic and
* computes document-frequency stats so queries can be scored with TF-IDF in
* `retrieveTopK`. The index is cached against the `topics` array reference,
* so repeated calls with the same array don't rebuild.
*
* Tokeniser: lowercase, split on `[^a-zA-Z0-9-]`, drop short tokens and a
* small Dutch/English stopword list.
*/
const STOPWORDS = new Set([
// English
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'have',
'how', 'i', 'in', 'is', 'it', 'its', 'of', 'on', 'or', 'than', 'that', 'the',
'this', 'to', 'was', 'were', 'what', 'when', 'where', 'which', 'who', 'why',
'with', 'you', 'your', 'do', 'does', 'did',
// Dutch
'de', 'het', 'een', 'en', 'of', 'in', 'op', 'aan', 'bij', 'voor', 'naar',
'met', 'uit', 'om', 'door', 'over', 'tegen', 'ook', 'er', 'is', 'zijn',
'was', 'waren', 'wat', 'wie', 'hoe', 'waar', 'wanneer', 'welke', 'die',
'dat', 'deze', 'dit', 'ik', 'jij', 'hij', 'zij', 'we', 'wij', 'jullie',
'als', 'dan', 'maar', 'want', 'omdat', 'niet', 'wel', 'heeft', 'hebben',
'word', 'wordt', 'worden', 'kan', 'kunnen', 'mag', 'moet', 'moeten',
'zal', 'zou', 'zouden', 'al', 'ook', 'nog', 'naar',
]);
export function tokenize(text) {
if (!text) return [];
return String(text)
.toLowerCase()
.split(/[^a-z0-9-]+/i)
.filter(t => t.length >= 2 && !STOPWORDS.has(t));
}
const indexCache = new WeakMap();
export function buildIndex(topics) {
if (!Array.isArray(topics) || topics.length === 0) {
return { topics: [], docFreq: new Map(), termsByDoc: [], N: 0 };
}
const cached = indexCache.get(topics);
if (cached) return cached;
const termsByDoc = topics.map(t => {
const text = `${t.label || ''} ${t.description || ''}`;
const tokens = tokenize(text);
const tf = new Map();
for (const tk of tokens) tf.set(tk, (tf.get(tk) || 0) + 1);
return tf;
});
const docFreq = new Map();
for (const tf of termsByDoc) {
for (const term of tf.keys()) {
docFreq.set(term, (docFreq.get(term) || 0) + 1);
}
}
const index = { topics, docFreq, termsByDoc, N: topics.length };
indexCache.set(topics, index);
return index;
}
export function retrieveTopK(index, query, k = 10) {
if (!index || !index.N || !query) return [];
const qTokens = tokenize(query);
if (qTokens.length === 0) return [];
const idf = (term) => {
const df = index.docFreq.get(term) || 0;
if (df === 0) return 0;
return Math.log((index.N + 1) / (df + 1)) + 1;
};
const scores = new Array(index.N);
for (let i = 0; i < index.N; i++) {
const tf = index.termsByDoc[i];
let s = 0;
for (const t of qTokens) {
const f = tf.get(t);
if (!f) continue;
s += (1 + Math.log(f)) * idf(t);
}
scores[i] = s;
}
const ranked = [];
for (let i = 0; i < index.N; i++) {
if (scores[i] > 0) ranked.push({ i, s: scores[i] });
}
ranked.sort((a, b) => b.s - a.s);
return ranked.slice(0, k).map(r => index.topics[r.i]);
}