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

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare, CalendarDays } from 'lucide-react';
import Card from '../../components/ui/Card';
import Tag from '../../components/ui/Tag';
@@ -14,10 +14,18 @@ import TeamManager from '../../components/admin/TeamManager';
import CurriculumManager from '../../components/admin/CurriculumManager';
import { Trash2 } from 'lucide-react';
const TIER_PLACEHOLDERS = {
fast: 'claude-haiku-4-5-20251001',
standard: 'claude-sonnet-4-6',
reasoning: 'claude-opus-4-7',
};
const Admin = () => {
const [activeTab, setActiveTab] = useState('sources');
const [sources, setSources] = useState([]);
const [model, setModel] = useState('');
const [modelFast, setModelFast] = useState('');
const [modelStandard, setModelStandard] = useState('');
const [modelReasoning, setModelReasoning] = useState('');
const [useSimulation, setUseSimulation] = useState(false);
const [saveStatus, setSaveStatus] = useState(null);
@@ -31,14 +39,19 @@ const Admin = () => {
loadSources();
}
if (activeTab === 'settings') {
setModel(storage.get('admin:model', 'claude-sonnet-4-20250514'));
const legacyStandard = storage.get('admin:model', '');
setModelFast(storage.get('admin:model:fast', '') || '');
setModelStandard(storage.get('admin:model:standard', '') || legacyStandard || '');
setModelReasoning(storage.get('admin:model:reasoning', '') || '');
setUseSimulation(storage.get('admin:use_simulation', false));
}
}, [activeTab]);
const saveSettings = async (e) => {
e.preventDefault();
storage.set('admin:model', model.trim());
storage.set('admin:model:fast', modelFast.trim());
storage.set('admin:model:standard', modelStandard.trim());
storage.set('admin:model:reasoning', modelReasoning.trim());
storage.set('admin:use_simulation', useSimulation);
setSaveStatus('Saved!');
setTimeout(() => setSaveStatus(null), 3000);
@@ -177,14 +190,37 @@ const Admin = () => {
<form onSubmit={saveSettings} className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">AI Configuration</h3>
<div className="mt-4">
<Input
label="Model ID"
placeholder="claude-sonnet-4-20250514"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<p className="text-xs text-fg-muted mt-1">Leave blank for the default. Check your Anthropic Console for available models.</p>
<p className="text-sm text-fg-muted mb-4">
Models are split into three tiers. Leave a field blank to use its default. Check your Anthropic Console for available model IDs.
</p>
<div className="mt-4 space-y-4">
<div>
<Input
label="Fast tier (short, cheap calls)"
placeholder={TIER_PLACEHOLDERS.fast}
value={modelFast}
onChange={(e) => setModelFast(e.target.value)}
/>
<p className="text-xs text-fg-muted mt-1">Default: {TIER_PLACEHOLDERS.fast}</p>
</div>
<div>
<Input
label="Standard tier (most workloads)"
placeholder={TIER_PLACEHOLDERS.standard}
value={modelStandard}
onChange={(e) => setModelStandard(e.target.value)}
/>
<p className="text-xs text-fg-muted mt-1">Default: {TIER_PLACEHOLDERS.standard}</p>
</div>
<div>
<Input
label="Reasoning tier (complex graph + content tasks)"
placeholder={TIER_PLACEHOLDERS.reasoning}
value={modelReasoning}
onChange={(e) => setModelReasoning(e.target.value)}
/>
<p className="text-xs text-fg-muted mt-1">Default: {TIER_PLACEHOLDERS.reasoning}</p>
</div>
</div>
<p className="text-sm text-fg-muted mt-4">
Note: Your Anthropic API key is securely managed on the server side via environment variables.