Files
learning-platform/src/lib/llm.js
RaymondVerhoef 25cae2fea9 fix: speed up handbook sync and stop llm_calls 404 noise
Handbook sync ran files sequentially under a 5 req/min limiter with a
hardcoded 60s LLM timeout, causing long syncs and AbortError timeouts on
large files. Now: limiter at 20 req/min, files processed with concurrency
4, handbook extraction timeout raised to 180s, and near-empty files skip
the LLM call.

callLLM gains a timeoutMs option; passing a signal no longer silently
disables the per-request timeout.

llm_calls telemetry self-disables after the first 404 so deploys without
the migration applied don't spam the console.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:35:56 +02:00

439 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`.');
}
// Telemetry collection is optional. If the migration hasn't been applied on a
// given deploy, the first POST returns 404; we then disable further attempts
// to keep the console clean and avoid wasted round-trips.
let llmCallsDisabled = false;
function logLlmCall(record) {
if (llmCallsDisabled) return;
try {
pb.collection('llm_calls').create(record).catch((err) => {
if (err?.status === 404) llmCallsDisabled = true;
});
} 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 {number} [timeoutMs=60000] Per-request timeout in ms. Increase for large structured extractions.
* @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,
timeoutMs = DEFAULT_TIMEOUT_MS,
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 = new AbortController();
const timer = setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), timeoutMs);
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,
};
}