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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user