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:
@@ -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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import Card from '../components/ui/Card';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Trophy, Medal, Award, TrendingUp, Star, CheckSquare } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Card from '../components/ui/Card';
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BookOpen, CheckCircle, Loader, ArrowRight, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import Input from '../components/ui/Input';
|
||||
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { getAssignedTopic, generateLearningContent, getCachedContent, generateCustomTopic } from '../lib/learningService';
|
||||
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
|
||||
import { getUpcomingWeeks, getQuarterProgress, getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
|
||||
import * as db from '../lib/db';
|
||||
|
||||
@@ -27,7 +26,7 @@ const Leren = () => {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Custom Topic
|
||||
const [customTopicQuery, setCustomTopicQuery] = useState('');
|
||||
const [customTopicQuery] = useState('');
|
||||
|
||||
// Weekly status
|
||||
const [weeklyDone, setWeeklyDone] = useState(false);
|
||||
@@ -107,26 +106,6 @@ const Leren = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCustom = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!customTopicQuery.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setView('creating');
|
||||
setError(null);
|
||||
try {
|
||||
const newTopic = await generateCustomTopic(customTopicQuery);
|
||||
setAllTopics(await db.getTopics());
|
||||
setCustomTopicQuery('');
|
||||
handleOpenTopic(newTopic);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setView('overview');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doComplete = async () => {
|
||||
setSessionDone(true);
|
||||
if (!weeklyDone) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import Card from '../components/ui/Card';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
CheckSquare, Loader, AlertCircle, Trophy, ArrowRight,
|
||||
Clock, CheckCircle, XCircle, BarChart2
|
||||
Clock, CheckCircle, XCircle
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -9,7 +9,7 @@ import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { generateWeeklyQuiz, getCachedQuiz, saveTestResult, getTestResult } from '../lib/testService';
|
||||
import { generateWeeklyQuiz, saveTestResult, getTestResult } from '../lib/testService';
|
||||
import { storage } from '../lib/storage';
|
||||
|
||||
const TIMER_SECONDS = 300; // 5 minutes
|
||||
@@ -61,13 +61,15 @@ const Testen = () => {
|
||||
}, [currentUser, weekNumber]);
|
||||
|
||||
// ── Timer ──
|
||||
const finishQuizRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'quiz') {
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current);
|
||||
finishQuiz();
|
||||
finishQuizRef.current?.();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
@@ -164,6 +166,10 @@ const Testen = () => {
|
||||
setPhase('results');
|
||||
}, [quiz, answers, timeLeft, currentUser, weekNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
finishQuizRef.current = finishQuiz;
|
||||
}, [finishQuiz]);
|
||||
|
||||
// ─── Intro / Start screen ────────────────────────────────
|
||||
if (phase === 'intro') {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user