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);
});
});