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

@@ -1,17 +1,59 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { storage } from '../../lib/storage';
import { anthropicApi } from '../../lib/api';
import { callLLM } from '../../lib/llm';
import * as db from '../../lib/db';
import { buildKbContext, validateDelta, deltaKey } from './rag';
import { buildSystemPrompt, PROPOSE_GRAPH_DELTA_TOOL, STRINGS } from './prompts';
import {
buildSystemPrompt,
PROPOSE_GRAPH_DELTA_TOOL,
LOOKUP_TOPIC_TOOL,
STRINGS,
} from './prompts';
const MAX_HISTORY = 50;
const VERBATIM_TURNS = 12;
const MAX_LOOKUP_HOPS = 3;
const TRUNCATION_NOTICE = '(earlier conversation truncated)';
/** Trim API history to the last N turns and prepend a truncation notice. */
function truncateApiMessages(history) {
if (history.length <= VERBATIM_TURNS) return history;
const tail = history.slice(-VERBATIM_TURNS);
return [{ role: 'assistant', content: TRUNCATION_NOTICE }, ...tail];
}
async function resolveLookupTopic(id, allTopics) {
const topic = allTopics.find(t => t.id === id);
if (!topic) {
return { ok: false, payload: `Geen topic gevonden met id "${id}".` };
}
const content = await db.getContent(id).catch(() => null);
let contentSnippet = null;
if (content) {
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);
contentSnippet = raw.replace(/\s+/g, ' ').trim().slice(0, 2400);
}
return {
ok: true,
payload: {
id: topic.id,
label: topic.label,
type: topic.type || 'concept',
description: topic.description || '',
learning_content: contentSnippet,
},
};
}
/**
* Conversation hook for R42.
* Owns the message list, persists to chat:thread:{userId}, calls Anthropic,
* and surfaces validated graph delta suggestions inline.
*
* Note: buildKbContext is async (reads PocketBase), so send() is fully async.
* Owns the message list, persists to chat:thread:{userId}, calls Anthropic
* via the shared `callLLM` client (with a `lookup_topic` multi-hop loop and
* the `propose_graph_delta` tool), and surfaces validated graph delta
* suggestions inline.
*/
export function useChat({ user, isAdmin }) {
const threadKey = user ? `chat:thread:${user.id}` : null;
@@ -20,7 +62,6 @@ export function useChat({ user, isAdmin }) {
const [errored, setErrored] = useState(false);
const seenDeltaKeys = useRef(new Set());
// Load persisted thread + seed greeting
useEffect(() => {
if (!user) return;
const stored = storage.get(threadKey, null);
@@ -41,7 +82,6 @@ export function useChat({ user, isAdmin }) {
}
}, [user, threadKey]);
// Persist on change
useEffect(() => {
if (!threadKey) return;
const capped = messages.slice(-MAX_HISTORY);
@@ -68,29 +108,43 @@ export function useChat({ user, isAdmin }) {
setErrored(false);
try {
const { context: kbContext, topics: kbTopics } = await buildKbContext(trimmed);
const { context: kbContext, allTopics } = await buildKbContext(trimmed);
const systemPrompt = buildSystemPrompt({
userName: user.name || 'daar',
isAdmin,
kbContext,
});
// Strip UI-only fields before sending to Anthropic
const apiMessages = next
const historyMessages = next
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: m.content }));
const response = await anthropicApi.chat(systemPrompt, apiMessages, {
tools: [PROPOSE_GRAPH_DELTA_TOOL],
});
const apiMessages = truncateApiMessages(historyMessages);
let textOut = '';
let suggestion = null;
for (const block of response.content || []) {
if (block.type === 'text') {
textOut += (textOut ? '\n' : '') + (block.text || '');
} else if (block.type === 'tool_use' && block.name === PROPOSE_GRAPH_DELTA_TOOL.name) {
const validated = validateDelta(block.input, kbTopics);
let hops = 0;
while (true) {
const response = await callLLM({
task: 'chat.r42',
tier: 'standard',
system: systemPrompt,
messages: apiMessages,
tools: [LOOKUP_TOPIC_TOOL, PROPOSE_GRAPH_DELTA_TOOL],
maxTokens: 2048,
temperature: 0.3,
});
if (response.text) {
textOut += (textOut ? '\n' : '') + response.text;
}
const lookupCalls = response.toolUses.filter(tu => tu.name === LOOKUP_TOPIC_TOOL.name);
const deltaCall = response.toolUses.find(tu => tu.name === PROPOSE_GRAPH_DELTA_TOOL.name);
if (deltaCall && !suggestion) {
const validated = validateDelta(deltaCall.input, allTopics);
if (validated) {
const key = deltaKey(validated);
if (!seenDeltaKeys.current.has(key)) {
@@ -99,6 +153,33 @@ export function useChat({ user, isAdmin }) {
}
}
}
if (lookupCalls.length === 0 || hops >= MAX_LOOKUP_HOPS) break;
hops++;
const assistantBlocks = [];
if (response.text) assistantBlocks.push({ type: 'text', text: response.text });
for (const tu of response.toolUses) {
if (!tu.id) continue;
assistantBlocks.push({ type: 'tool_use', id: tu.id, name: tu.name, input: tu.input });
}
apiMessages.push({ role: 'assistant', content: assistantBlocks });
const toolResults = [];
for (const tu of lookupCalls) {
if (!tu.id) continue;
const topicId = String(tu.input?.id || '').trim();
const resolved = await resolveLookupTopic(topicId, allTopics);
toolResults.push({
type: 'tool_result',
tool_use_id: tu.id,
content: typeof resolved.payload === 'string'
? resolved.payload
: JSON.stringify(resolved.payload),
...(resolved.ok ? {} : { is_error: true }),
});
}
apiMessages.push({ role: 'user', content: toolResults });
}
const assistantMsg = {