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>
This commit is contained in:
58
src/lib/__tests__/retrieval.test.js
Normal file
58
src/lib/__tests__/retrieval.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
95
src/lib/retrieval.js
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user