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:
160
src/lib/api.js
160
src/lib/api.js
@@ -1,135 +1,39 @@
|
||||
import { storage } from './storage';
|
||||
|
||||
/**
|
||||
* Anthropic API Service
|
||||
* Handles communication with the /v1/messages endpoint via Nginx proxy.
|
||||
* Back-compatibility shim for the legacy `anthropicApi` interface.
|
||||
*
|
||||
* All real work lives in `./llm.js`. Existing callers (extractionPipeline,
|
||||
* learningService, testService, KnowledgeGraph, useChat) keep working
|
||||
* unchanged; new code should import `callLLM` from `./llm.js` directly.
|
||||
*/
|
||||
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
|
||||
import { callLLM } from './llm';
|
||||
|
||||
export const anthropicApi = {
|
||||
async generateContent(systemPrompt, userMessage, maxRetries = 1) {
|
||||
// Check if simulation mode is on
|
||||
const useSimulation = storage.get('admin:use_simulation') === true;
|
||||
if (useSimulation) {
|
||||
console.log('[API] Simulation mode active. Mock data will be returned.');
|
||||
return await simulateResponse();
|
||||
}
|
||||
async generateContent(systemPrompt, userMessage /*, maxRetries */) {
|
||||
const { text } = await callLLM({
|
||||
task: 'legacy.generateContent',
|
||||
tier: 'standard',
|
||||
system: systemPrompt,
|
||||
user: userMessage,
|
||||
maxTokens: 8192,
|
||||
temperature: 0,
|
||||
});
|
||||
return text;
|
||||
},
|
||||
|
||||
// The API key is now securely injected by the Caddy reverse proxy via environment variables.
|
||||
|
||||
// Model is configurable from Admin > Settings, defaults to the original spec model
|
||||
const model = storage.get('admin:model') || DEFAULT_MODEL;
|
||||
console.log(`[API] Calling with model: ${model}`);
|
||||
|
||||
let retries = 0;
|
||||
while (retries <= maxRetries) {
|
||||
try {
|
||||
const response = await fetch('/api/anthropic/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
max_tokens: 8192,
|
||||
temperature: 0,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
console.error('[API] Error response:', errData);
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errData)}`);
|
||||
}
|
||||
|
||||
// Detect auth portal session expiry: the portal returns HTML instead of JSON
|
||||
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.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.content[0].text;
|
||||
} catch (error) {
|
||||
console.error('API call failed:', error);
|
||||
retries++;
|
||||
if (retries > maxRetries) throw error;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
async chat(systemPrompt, messages, opts = {}) {
|
||||
const r = await callLLM({
|
||||
task: 'legacy.chat',
|
||||
tier: 'standard',
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
tools: opts.tools,
|
||||
maxTokens: 1024,
|
||||
temperature: 0.3,
|
||||
});
|
||||
const content = [];
|
||||
if (r.text) content.push({ type: 'text', text: r.text });
|
||||
for (const tu of r.toolUses) content.push({ type: 'tool_use', name: tu.name, input: tu.input });
|
||||
return { content, stop_reason: r.stopReason };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-turn chat with optional tool use.
|
||||
* Returns the raw Anthropic response so callers can read both `text` and
|
||||
* `tool_use` content blocks.
|
||||
*
|
||||
* @param {string} systemPrompt
|
||||
* @param {Array<{role: 'user'|'assistant', content: string}>} messages
|
||||
* @param {{tools?: Array}} opts
|
||||
* @returns {Promise<{content: Array, stop_reason: string}>}
|
||||
*/
|
||||
anthropicApi.chat = async function chat(systemPrompt, messages, opts = {}) {
|
||||
const useSimulation = storage.get('admin:use_simulation') === true;
|
||||
if (useSimulation) {
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.',
|
||||
}],
|
||||
stop_reason: 'end_turn',
|
||||
};
|
||||
}
|
||||
|
||||
const model = storage.get('admin:model') || DEFAULT_MODEL;
|
||||
|
||||
const body = {
|
||||
model,
|
||||
max_tokens: 1024,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
};
|
||||
if (opts.tools && opts.tools.length) body.tools = opts.tools;
|
||||
|
||||
const response = await fetch('/api/anthropic/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errData)}`);
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
async function simulateResponse() {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
return JSON.stringify({
|
||||
topics: [
|
||||
{ id: "radicale-transparantie", label: "Radicale Transparantie", type: "concept", description: "De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is." },
|
||||
{ id: "kennisbeheer", label: "Kennisbeheer", type: "process", description: "Het proces van het vastleggen en ontsluiten van organisatiekennis." },
|
||||
{ id: "wekelijkse-sessie", label: "Wekelijkse Leersessie", type: "process", description: "Elke week leren medewerkers via AI-gegenereerde vragen en quizzen." }
|
||||
],
|
||||
relations: [
|
||||
{ source: "kennisbeheer", target: "radicale-transparantie", type: "depends_on" },
|
||||
{ source: "wekelijkse-sessie", target: "kennisbeheer", type: "part_of" }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user