feat: phase 1 of AI pipeline hardening — single LLM client + tier-aware models

Implements phase 1 of AI_PIPELINE_HARDENING_PLAN.md. Every Anthropic call
now goes through one module that owns retry, timeout, abort, structured-
output parsing, schema validation, and best-effort call telemetry.

* src/lib/llm.js — single callLLM entry point. Resolves model per tier
  (fast / standard / reasoning) with admin:model legacy fallback for the
  standard tier; 60s default timeout via AbortController; balanced-brace
  JSON extraction; LLMHttpError, LLMTruncatedError, LLMOutputError, and
  LLMValidationError surface clearly distinct failure modes.
* src/lib/llmRetry.js — exponential backoff with full jitter, retries
  only on transient HTTP statuses, honours Retry-After up to 60s, never
  retries on AbortError.
* src/lib/llmSchemas.js — Zod schemas for every structured task plus
  normalizeHandbookResult (collapses legacy "executes" relations into
  the canonical "executed_by" vocabulary).
* src/lib/api.js — thin shim over callLLM so existing callers (extraction
  pipeline, learning, quiz, R42, knowledge graph) keep working unchanged.
* src/lib/__tests__/ — 32 Vitest cases covering parse paths, error
  surfaces, simulation mode, model resolution, and schema validation.
* src/pages/Admin/index.jsx — three model inputs (fast / standard /
  reasoning) replacing the single legacy field; legacy value falls back
  for the standard tier so existing overrides survive.

Adds Zod and Vitest, plus an "npm run test" script.

Also cleans up the pre-existing repo-wide ESLint failures so phase 1's
"npm run lint passes" acceptance criterion can be checked: drops unused
React imports across the JSX tree (React 19 JSX runtime auto-imports),
attaches cause to rethrown errors in the service modules, ignores
pb_migrations in the ESLint config (PocketBase JSVM globals), and
removes one dead handleCreateCustom function in Leren.jsx. A real
behaviour bug surfaced in Testen.jsx — the quiz timer captured a stale
finishQuiz via setInterval closure; now updated via finishQuizRef so the
timer always invokes the latest callback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RaymondVerhoef
2026-05-20 13:50:09 +02:00
parent db5bb854c3
commit 4a8dbee7df
36 changed files with 1612 additions and 233 deletions

366
src/lib/llm.js Normal file
View File

@@ -0,0 +1,366 @@
/**
* 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_PAYLOAD = JSON.stringify({
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_CHAT_TEXT =
'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.';
async function simulatedResponse({ task }) {
await new Promise((r) => setTimeout(r, 400));
if (isChatLikeTask(task)) {
return {
text: SIMULATION_CHAT_TEXT,
toolUses: [],
stopReason: 'end_turn',
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
requestId: null,
model: 'simulation',
durationMs: 400,
};
}
return {
text: SIMULATION_EXTRACTION_PAYLOAD,
toolUses: [],
stopReason: 'end_turn',
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
requestId: null,
model: 'simulation',
durationMs: 400,
};
}
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) => ({ 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]
*/
/**
* @param {CallLLMOptions} options
*/
export async function callLLM(options) {
const {
task,
tier = 'standard',
system,
messages,
user,
tools,
toolChoice,
schema,
toolSchemas,
maxTokens = 4096,
temperature = 0,
signal,
} = options;
if (!task) throw new Error('callLLM requires a `task` label.');
const useSimulation = storage.get('admin:use_simulation') === true;
if (useSimulation) return simulatedResponse({ task });
const model = resolveModel(tier);
const messagesPayload = buildMessages({ messages, user });
const body = {
model,
max_tokens: maxTokens,
temperature,
messages: messagesPayload,
};
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 () => {
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'));
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,
};
}