- 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>
430 lines
15 KiB
JavaScript
430 lines
15 KiB
JavaScript
/**
|
|
* Single Anthropic client used by every service module.
|
|
*
|
|
* Centralises model selection, retry, timeout/abort, structured-output
|
|
* parsing, schema validation, and best-effort call telemetry. Callers
|
|
* import `callLLM` from here — they must not reach `/api/anthropic` on
|
|
* their own.
|
|
*/
|
|
|
|
import { storage } from './storage';
|
|
import { withRetry, RetryableError, parseRetryAfter, isRetryableStatus } from './llmRetry';
|
|
import { toolSchemaRegistry } from './llmSchemas';
|
|
import { pb } from './pb';
|
|
|
|
const ANTHROPIC_URL = '/api/anthropic/v1/messages';
|
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
|
|
const TIER_DEFAULTS = {
|
|
fast: 'claude-haiku-4-5-20251001',
|
|
standard: 'claude-sonnet-4-6',
|
|
reasoning: 'claude-opus-4-7',
|
|
};
|
|
|
|
export class LLMHttpError extends Error {
|
|
constructor(status, statusText, body) {
|
|
super(`API Error: ${status} ${statusText} - ${typeof body === 'string' ? body : JSON.stringify(body)}`);
|
|
this.name = 'LLMHttpError';
|
|
this.status = status;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
export class LLMTruncatedError extends Error {
|
|
constructor(task) {
|
|
super(`LLM response truncated (stop_reason: max_tokens) for task "${task}". Increase max_tokens or shorten the input.`);
|
|
this.name = 'LLMTruncatedError';
|
|
}
|
|
}
|
|
|
|
export class LLMOutputError extends Error {
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = 'LLMOutputError';
|
|
}
|
|
}
|
|
|
|
export class LLMValidationError extends Error {
|
|
constructor(task, zodError) {
|
|
super(`LLM output failed schema validation for task "${task}": ${zodError?.message ?? zodError}`);
|
|
this.name = 'LLMValidationError';
|
|
this.cause = zodError;
|
|
}
|
|
}
|
|
|
|
export function resolveModel(tier) {
|
|
const key = `admin:model:${tier}`;
|
|
const override = storage.get(key);
|
|
if (override) return String(override).trim();
|
|
if (tier === 'standard') {
|
|
const legacy = storage.get('admin:model');
|
|
if (legacy) return String(legacy).trim();
|
|
}
|
|
return TIER_DEFAULTS[tier] ?? TIER_DEFAULTS.standard;
|
|
}
|
|
|
|
/**
|
|
* Extract the outermost balanced JSON value (object or array) from arbitrary
|
|
* model output. Strips ```json fences first. Brace-matching ignores braces
|
|
* inside strings; escapes inside strings are skipped.
|
|
*/
|
|
export function parseStructuredText(raw) {
|
|
if (typeof raw !== 'string') throw new LLMOutputError('LLM returned no text.');
|
|
let text = raw.trim();
|
|
text = text.replace(/```(?:json)?\s*/gi, '').replace(/```/g, '');
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i];
|
|
if (ch !== '{' && ch !== '[') continue;
|
|
const open = ch;
|
|
const close = ch === '{' ? '}' : ']';
|
|
let depth = 0;
|
|
let inString = false;
|
|
for (let j = i; j < text.length; j++) {
|
|
const c = text[j];
|
|
if (inString) {
|
|
if (c === '\\') { j++; continue; }
|
|
if (c === '"') inString = false;
|
|
continue;
|
|
}
|
|
if (c === '"') { inString = true; continue; }
|
|
if (c === open) depth++;
|
|
else if (c === close) {
|
|
depth--;
|
|
if (depth === 0) {
|
|
const slice = text.slice(i, j + 1);
|
|
try {
|
|
return JSON.parse(slice);
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
throw new LLMOutputError('No balanced JSON value found in LLM output.');
|
|
}
|
|
|
|
function buildMessages({ messages, user }) {
|
|
if (Array.isArray(messages) && messages.length) return messages;
|
|
if (typeof user === 'string' && user.length) return [{ role: 'user', content: user }];
|
|
throw new Error('callLLM requires either `messages` or `user`.');
|
|
}
|
|
|
|
function logLlmCall(record) {
|
|
try {
|
|
pb.collection('llm_calls').create(record).catch(() => {});
|
|
} catch {
|
|
/* collection may not exist yet — swallow */
|
|
}
|
|
}
|
|
|
|
function isChatLikeTask(task) {
|
|
if (!task) return false;
|
|
return task === 'legacy.chat' || task.startsWith('chat.') || task.startsWith('r42.');
|
|
}
|
|
|
|
const SIMULATION_EXTRACTION_GRAPH = {
|
|
topics: [
|
|
{ id: 'radicale-transparantie', label: 'Radicale Transparantie', type: 'concept', description: 'De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is.', learning_relevance: 'core' },
|
|
{ id: 'kennisbeheer', label: 'Kennisbeheer', type: 'process', description: 'Het proces van het vastleggen en ontsluiten van organisatiekennis.', learning_relevance: 'standard' },
|
|
{ id: 'wekelijkse-sessie', label: 'Wekelijkse Leersessie', type: 'process', description: 'Elke week leren medewerkers via AI-gegenereerde vragen en quizzen.', learning_relevance: 'standard' },
|
|
],
|
|
relations: [
|
|
{ source: 'kennisbeheer', target: 'radicale-transparantie', type: 'depends_on' },
|
|
{ source: 'wekelijkse-sessie', target: 'kennisbeheer', type: 'part_of' },
|
|
],
|
|
};
|
|
|
|
const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify(SIMULATION_EXTRACTION_GRAPH);
|
|
|
|
const SIMULATION_CHAT_TEXT =
|
|
'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.';
|
|
|
|
const SIMULATION_ARTICLE = {
|
|
title: 'Voorbeeld leermodule',
|
|
intro: 'Dit is een simulatie. Schakel Simulation Mode uit om echte content te genereren.',
|
|
sections: [
|
|
{ heading: 'Wat dit is', body: 'Dit is een placeholder-sectie die alleen verschijnt wanneer simulatiemodus aan staat. Hij illustreert de structuur van het artikel zonder een echte API-aanroep te doen. Dat is handig voor UI-werk.' },
|
|
],
|
|
keyTakeaways: ['Simulatiemodus levert geen echte inhoud.', 'Schakel uit voor productie.'],
|
|
};
|
|
|
|
const SIMULATION_SLIDE = {
|
|
title: 'Voorbeeldslide',
|
|
bullets: ['Eerste punt', 'Tweede punt'],
|
|
speakerNote: 'Spreker-notitie ter illustratie.',
|
|
};
|
|
|
|
const SIMULATION_INFOGRAPHIC = {
|
|
headline: 'Simulatie',
|
|
tagline: 'Vervang door echte content',
|
|
stats: [{ value: '100%', label: 'simulatie', icon: '📊' }],
|
|
steps: [{ number: 1, title: 'Schakel uit', description: 'Zet simulatiemodus uit in Admin → Settings.', icon: '🔧' }],
|
|
quote: 'Een simulatie vertelt niets nieuws.',
|
|
colorTheme: 'teal',
|
|
};
|
|
|
|
const SIMULATION_TOOL_STUBS = {
|
|
emit_knowledge_graph: SIMULATION_EXTRACTION_GRAPH,
|
|
emit_handbook_delta: SIMULATION_EXTRACTION_GRAPH,
|
|
emit_learning_article: { article: SIMULATION_ARTICLE },
|
|
emit_learning_slides: { slides: [SIMULATION_SLIDE] },
|
|
emit_learning_infographic: { infographic: SIMULATION_INFOGRAPHIC },
|
|
emit_learning_all: { article: SIMULATION_ARTICLE, slides: [SIMULATION_SLIDE], infographic: SIMULATION_INFOGRAPHIC },
|
|
emit_custom_topic: { label: 'Simulatie onderwerp', type: 'concept', description: 'Een placeholder-onderwerp gegenereerd in simulatiemodus.' },
|
|
emit_quiz_questions: {
|
|
questions: [
|
|
{
|
|
id: 'sim-q1',
|
|
question: 'Wat doet simulatiemodus?',
|
|
topicLabel: 'Simulatie',
|
|
options: ['Echte API-aanroepen', 'Stub-data tonen', 'Niets', 'Crasht de app'],
|
|
correctIndex: 1,
|
|
explanation: 'Simulatiemodus retourneert vaste stub-data zonder de API te raken.',
|
|
},
|
|
],
|
|
},
|
|
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
|
|
set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
|
|
};
|
|
|
|
function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
|
|
return {
|
|
text,
|
|
toolUses,
|
|
stopReason,
|
|
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
requestId: null,
|
|
model: 'simulation',
|
|
durationMs: 400,
|
|
};
|
|
}
|
|
|
|
async function simulatedResponse({ task, toolChoice }) {
|
|
await new Promise((r) => setTimeout(r, 400));
|
|
|
|
if (toolChoice?.type === 'tool' && SIMULATION_TOOL_STUBS[toolChoice.name]) {
|
|
return stubResponse({
|
|
stopReason: 'tool_use',
|
|
toolUses: [{ name: toolChoice.name, input: SIMULATION_TOOL_STUBS[toolChoice.name] }],
|
|
});
|
|
}
|
|
|
|
if (isChatLikeTask(task)) {
|
|
return stubResponse({ text: SIMULATION_CHAT_TEXT });
|
|
}
|
|
return stubResponse({ text: SIMULATION_EXTRACTION_PAYLOAD });
|
|
}
|
|
|
|
function linkSignals(userSignal, timeoutSignal) {
|
|
const controller = new AbortController();
|
|
const abort = (reason) => controller.abort(reason);
|
|
if (userSignal) {
|
|
if (userSignal.aborted) controller.abort(userSignal.reason);
|
|
else userSignal.addEventListener('abort', () => abort(userSignal.reason), { once: true });
|
|
}
|
|
if (timeoutSignal) {
|
|
if (timeoutSignal.aborted) controller.abort(timeoutSignal.reason);
|
|
else timeoutSignal.addEventListener('abort', () => abort(timeoutSignal.reason), { once: true });
|
|
}
|
|
return controller.signal;
|
|
}
|
|
|
|
function extractToolUses(content) {
|
|
if (!Array.isArray(content)) return [];
|
|
return content
|
|
.filter((b) => b?.type === 'tool_use')
|
|
.map((b) => ({ id: b.id, name: b.name, input: b.input }));
|
|
}
|
|
|
|
function extractText(content) {
|
|
if (!Array.isArray(content)) return '';
|
|
return content
|
|
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
|
.map((b) => b.text)
|
|
.join('');
|
|
}
|
|
|
|
function validateToolInputs(toolUses, task, toolSchemas) {
|
|
const registry = { ...toolSchemaRegistry, ...(toolSchemas || {}) };
|
|
for (const tu of toolUses) {
|
|
const schema = registry[tu.name];
|
|
if (!schema) continue;
|
|
const result = schema.safeParse(tu.input);
|
|
if (!result.success) throw new LLMValidationError(`${task}:${tu.name}`, result.error);
|
|
tu.input = result.data;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef {Object} CallLLMOptions
|
|
* @property {string} task Logging label, e.g. 'extract.source'.
|
|
* @property {'fast'|'standard'|'reasoning'} [tier='standard']
|
|
* @property {string|Array<{type:'text',text:string,cache_control?:{type:'ephemeral'}}>} [system]
|
|
* @property {Array<{role:'user'|'assistant',content:any}>} [messages]
|
|
* @property {string} [user] Shorthand for a single user message.
|
|
* @property {Array<object>} [tools] Anthropic tool definitions.
|
|
* @property {object} [toolChoice] e.g. { type: 'tool', name: 'emit_knowledge_graph' }.
|
|
* @property {import('zod').ZodTypeAny} [schema] For text→JSON validation.
|
|
* @property {Record<string, import('zod').ZodTypeAny>} [toolSchemas] Overrides for tool_use input validation.
|
|
* @property {number} [maxTokens=4096]
|
|
* @property {number} [temperature=0]
|
|
* @property {AbortSignal} [signal]
|
|
* @property {{ acquire: (opts?:{signal?:AbortSignal}) => Promise<void>, pauseUntil: (untilMs:number) => void }} [limiter]
|
|
*/
|
|
|
|
/**
|
|
* @param {CallLLMOptions} options
|
|
*/
|
|
export async function callLLM(options) {
|
|
const {
|
|
task,
|
|
tier = 'standard',
|
|
system,
|
|
messages,
|
|
user,
|
|
tools,
|
|
toolChoice,
|
|
schema,
|
|
toolSchemas,
|
|
maxTokens = 4096,
|
|
temperature = 0,
|
|
signal,
|
|
limiter,
|
|
} = options;
|
|
if (!task) throw new Error('callLLM requires a `task` label.');
|
|
|
|
const useSimulation = storage.get('admin:use_simulation') === true;
|
|
if (useSimulation) return simulatedResponse({ task, toolChoice });
|
|
|
|
const model = resolveModel(tier);
|
|
const messagesPayload = buildMessages({ messages, user });
|
|
|
|
const body = {
|
|
model,
|
|
max_tokens: maxTokens,
|
|
messages: messagesPayload,
|
|
};
|
|
// Temperature is not supported for reasoning tier models
|
|
if (tier !== 'reasoning') {
|
|
body.temperature = temperature;
|
|
}
|
|
if (system !== undefined) body.system = system;
|
|
if (tools && tools.length) body.tools = tools;
|
|
if (toolChoice) body.tool_choice = toolChoice;
|
|
|
|
const start = Date.now();
|
|
let result;
|
|
try {
|
|
result = await withRetry(
|
|
async () => {
|
|
if (limiter) await limiter.acquire({ signal });
|
|
const timeoutCtl = signal ? null : new AbortController();
|
|
const timer = timeoutCtl ? setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), DEFAULT_TIMEOUT_MS) : null;
|
|
const fetchSignal = linkSignals(signal, timeoutCtl?.signal);
|
|
|
|
try {
|
|
const response = await fetch(ANTHROPIC_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'anthropic-version': ANTHROPIC_VERSION,
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: fetchSignal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errBody = await response.json().catch(() => ({}));
|
|
if (isRetryableStatus(response.status)) {
|
|
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
|
|
if (response.status === 429 && retryAfterMs != null && limiter) {
|
|
limiter.pauseUntil(Date.now() + retryAfterMs);
|
|
}
|
|
throw new RetryableError(response.status, retryAfterMs, `HTTP ${response.status}`);
|
|
}
|
|
throw new LLMHttpError(response.status, response.statusText, errBody);
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type') || '';
|
|
if (!contentType.includes('application/json')) {
|
|
throw new Error('Your session has expired. Please refresh the page and log in again.');
|
|
}
|
|
|
|
return await response.json();
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
} catch (err) {
|
|
logLlmCall({
|
|
task,
|
|
model,
|
|
tier,
|
|
duration_ms: Date.now() - start,
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
cache_read_tokens: 0,
|
|
cache_create_tokens: 0,
|
|
stop_reason: '',
|
|
ok: false,
|
|
error_msg: String(err?.message ?? err).slice(0, 500),
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
const stopReason = result.stop_reason || '';
|
|
const toolUses = extractToolUses(result.content);
|
|
const text = extractText(result.content);
|
|
const usage = result.usage || {};
|
|
|
|
const truncationRequiresFailure =
|
|
stopReason === 'max_tokens' && (Boolean(schema) || Boolean(toolChoice));
|
|
|
|
logLlmCall({
|
|
task,
|
|
model,
|
|
tier,
|
|
duration_ms: Date.now() - start,
|
|
input_tokens: usage.input_tokens ?? 0,
|
|
output_tokens: usage.output_tokens ?? 0,
|
|
cache_read_tokens: usage.cache_read_input_tokens ?? 0,
|
|
cache_create_tokens: usage.cache_creation_input_tokens ?? 0,
|
|
stop_reason: stopReason,
|
|
ok: !truncationRequiresFailure,
|
|
error_msg: truncationRequiresFailure ? 'max_tokens' : '',
|
|
});
|
|
|
|
if (truncationRequiresFailure) throw new LLMTruncatedError(task);
|
|
|
|
if (toolUses.length) validateToolInputs(toolUses, task, toolSchemas);
|
|
|
|
let parsedFromText;
|
|
if (schema && !toolUses.length) {
|
|
const value = parseStructuredText(text);
|
|
const parsed = schema.safeParse(value);
|
|
if (!parsed.success) throw new LLMValidationError(task, parsed.error);
|
|
parsedFromText = parsed.data;
|
|
}
|
|
|
|
return {
|
|
text,
|
|
toolUses,
|
|
stopReason,
|
|
usage: {
|
|
input_tokens: usage.input_tokens ?? 0,
|
|
output_tokens: usage.output_tokens ?? 0,
|
|
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
|
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
|
|
},
|
|
requestId: result.id ?? null,
|
|
model: result.model ?? model,
|
|
durationMs: Date.now() - start,
|
|
parsed: parsedFromText,
|
|
};
|
|
}
|