Files
learning-platform/src/pages/Leren.jsx
RaymondVerhoef 4a8dbee7df 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>
2026-05-20 13:50:09 +02:00

450 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 LearningContentViewer from '../components/ui/LearningContentViewer';
import { useApp } from '../store/AppContext';
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';
const Leren = () => {
const { state } = useApp();
const [assignedTopic, setAssignedTopic] = useState(null);
const [allTopics, setAllTopics] = useState([]);
// View state
const [view, setView] = useState('overview'); // overview, detail, creating
const [activeTopic, setActiveTopic] = useState(null);
const [content, setContent] = useState(null);
// Loading & Error
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Custom Topic
const [customTopicQuery] = useState('');
// Weekly status
const [weeklyDone, setWeeklyDone] = useState(false);
const [sessionDone, setSessionDone] = useState(false);
// Feedback
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [feedbackText, setFeedbackText] = useState('');
const [feedbackPrompted, setFeedbackPrompted] = useState(false);
// Curriculum state
const [hasCurriculum, setHasCurriculum] = useState(false);
const [upcoming, setUpcoming] = useState([]);
const [quarterProgress, setQuarterProgress] = useState(null);
const [yearProgress, setYearProgress] = useState(null);
useEffect(() => {
if (state.currentUser) {
const load = async () => {
const [assigned, topics, done] = await Promise.all([
getAssignedTopic(state.currentUser.id, state.weekNumber),
db.getTopics(),
db.getLearnDone(state.currentUser.id, state.weekNumber),
]);
setAssignedTopic(assigned);
setAllTopics(topics);
if (done) setWeeklyDone(true);
// Load curriculum data
try {
const currExists = await checkHasCurriculum();
setHasCurriculum(currExists);
if (currExists) {
const [upcomingData, qProgress, yProgress] = await Promise.all([
getUpcomingWeeks(state.weekNumber, 4),
getQuarterProgress(state.currentUser.id, getQuarterForWeek(state.weekNumber)),
getYearProgress(state.currentUser.id),
]);
setUpcoming(upcomingData);
setQuarterProgress(qProgress);
setYearProgress(yProgress);
}
} catch (e) {
console.warn('[Learn] Could not load curriculum data:', e.message);
}
};
load();
}
}, [state.currentUser, state.weekNumber]);
const handleOpenTopic = async (topic) => {
setActiveTopic(topic);
setView('detail');
setSessionDone(false);
setError(null);
setFeedbackText('');
setFeedbackPrompted(false);
const cached = await getCachedContent(topic.id);
if (cached) {
setContent(cached);
} else {
setContent(null);
}
};
const loadContent = async (selectedType = 'article') => {
if (!activeTopic) return;
setIsLoading(true);
setError(null);
try {
const generated = await generateLearningContent(activeTopic, false, selectedType);
setContent(generated);
} catch (e) {
setError(e.message);
} finally {
setIsLoading(false);
}
};
const doComplete = async () => {
setSessionDone(true);
if (!weeklyDone) {
setWeeklyDone(true);
await db.setLearnDone(state.currentUser.id, state.weekNumber);
}
};
const handleComplete = () => {
if (!feedbackPrompted) {
setFeedbackPrompted(true);
setShowFeedbackModal(true);
return;
}
doComplete();
};
const handleSubmitFeedback = async () => {
if (feedbackText.trim()) {
await db.setSetting(
`feedback:${state.currentUser.id}:${activeTopic.id}:${state.weekNumber}`,
feedbackText.trim()
);
}
setShowFeedbackModal(false);
doComplete();
};
const handleSkipFeedback = () => {
setShowFeedbackModal(false);
doComplete();
};
// ── Detail View ──────────────────────────────────────────
if (view === 'detail' && activeTopic) {
if (sessionDone) {
return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
<p className="text-fg-muted mb-8">
You have successfully reviewed "<strong>{activeTopic.label}</strong>".
{weeklyDone && ' Your weekly minimum is met.'}
</p>
<div className="flex justify-center gap-4">
<Button variant="outline" onClick={() => setView('overview')}>Learn Another</Button>
<Link to="/test">
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
</Link>
</div>
</div>
);
}
return (
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
<AnimatePresence>
{showFeedbackModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
>
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 16 }}
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
className="w-full max-w-lg"
>
<Card className="border border-bg-warm shadow-xl">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-teal/10 flex items-center justify-center flex-shrink-0">
<MessageSquare size={20} className="text-teal" />
</div>
<h2 className="text-xl font-bold">How was this content?</h2>
</div>
<p className="text-fg-muted text-sm mb-5">
Your feedback helps improve future learning content. This is optional you can skip if you prefer.
</p>
<textarea
autoFocus
value={feedbackText}
onChange={e => setFeedbackText(e.target.value)}
placeholder="What was clear? What could be improved? Anything missing?"
rows={4}
className="w-full rounded-[var(--r-sm)] border border-bg-warm bg-bg p-3 text-sm resize-none focus:outline-none focus:border-teal transition-colors"
/>
<div className="flex justify-end gap-3 mt-4">
<Button variant="outline" onClick={handleSkipFeedback}>Skip</Button>
<Button onClick={handleSubmitFeedback}>Submit Feedback</Button>
</div>
</Card>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<button onClick={() => setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
<ChevronLeft size={16} /> Back to overview
</button>
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Tag variant="dark" className="font-mono text-xs">{activeTopic.type}</Tag>
{activeTopic.id === assignedTopic?.id && <Tag variant="accent">Weekly Required</Tag>}
</div>
<h1 className="text-3xl md:text-4xl font-bold text-teal">{activeTopic.label}</h1>
<p className="text-fg-muted mt-2">{activeTopic.description}</p>
</div>
{!content && !isLoading && (
<Card className="border border-bg-warm text-center py-16">
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
<p className="text-fg-muted mb-6">Choose how you want to learn this topic.</p>
<div className="flex flex-wrap justify-center gap-4">
<Button onClick={() => loadContent('article')}>Article</Button>
<Button onClick={() => loadContent('slides')}>Slides</Button>
<Button onClick={() => loadContent('infographic')}>Infographic</Button>
</div>
</Card>
)}
{isLoading && (
<Card className="border border-bg-warm text-center py-16">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<p className="font-medium">AI is generating your learning module...</p>
<p className="text-fg-muted text-sm mt-2">This may take 1030 seconds.</p>
</Card>
)}
{error && (
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
<p className="font-bold mb-1">Generation failed</p>
<p className="text-sm">{error}</p>
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">Try again</Button>
</Card>
)}
{content && <LearningContentViewer content={content} topic={activeTopic} onGenerate={loadContent} isLoading={isLoading} />}
{content && (
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
<Button onClick={handleComplete}>
<CheckCircle size={18} className="mr-2" /> Complete Session
</Button>
</div>
)}
</div>
);
}
// ── Creating Topic Loading ─────────────────────────────────
if (view === 'creating') {
return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<h1 className="text-2xl font-bold text-teal mb-2">Architecting New Topic</h1>
<p className="text-fg-muted">The AI is creating a structure for "{customTopicQuery}"...</p>
</div>
);
}
// ── Overview ──────────────────────────────────────────────
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact' && t.learning_relevance !== 'exclude');
const currentQuarter = getQuarterForWeek(state.weekNumber);
const currentQuarterName = getQuarterName(state.weekNumber);
return (
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
<div className="mb-10">
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
<p className="text-fg-muted text-lg">
{hasCurriculum
? `Week ${state.weekNumber} · ${currentQuarterName}`
: 'Complete at least 1 topic per week. Explore more from the library!'}
</p>
</div>
{error && (
<div className="mb-6 bg-red-50 text-red-800 p-4 rounded-[var(--r-sm)] border border-red-200">
{error}
</div>
)}
{/* Progress Cards (only shown when curriculum exists) */}
{hasCurriculum && yearProgress && quarterProgress && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{/* Year Progress */}
<Card className="border border-bg-warm text-center p-4">
<div className="relative w-16 h-16 mx-auto mb-2">
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
<circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-bg-warm)" strokeWidth="3" />
<circle
cx="18" cy="18" r="15.5" fill="none"
stroke="var(--color-teal)" strokeWidth="3"
strokeDasharray={`${yearProgress.percentage} 100`}
strokeLinecap="round"
className="transition-all duration-700"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
{yearProgress.percentage}%
</span>
</div>
<div className="text-xs text-fg-muted">Annual Progress</div>
<div className="text-[10px] text-fg-muted">{yearProgress.completed}/{yearProgress.total} weeks</div>
</Card>
{/* Quarter Progress */}
<Card className="border border-bg-warm text-center p-4">
<div className="relative w-16 h-16 mx-auto mb-2">
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
<circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-bg-warm)" strokeWidth="3" />
<circle
cx="18" cy="18" r="15.5" fill="none"
stroke="#7c3aed" strokeWidth="3"
strokeDasharray={`${quarterProgress.percentage} 100`}
strokeLinecap="round"
className="transition-all duration-700"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
{quarterProgress.percentage}%
</span>
</div>
<div className="text-xs text-fg-muted">Q{currentQuarter} Progress</div>
<div className="text-[10px] text-fg-muted">{quarterProgress.completed}/{quarterProgress.total} weeks</div>
</Card>
{/* Current Week */}
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
<Calendar size={24} className="text-teal mb-1" />
<div className="text-2xl font-bold text-teal">{state.weekNumber}</div>
<div className="text-xs text-fg-muted">Current Week</div>
</Card>
{/* Status */}
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
<TrendingUp size={24} className={`mb-1 ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`} />
<div className={`text-lg font-bold ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`}>
{weeklyDone ? 'Complete' : 'In Progress'}
</div>
<div className="text-xs text-fg-muted">This Week</div>
</Card>
</div>
)}
{/* Required Topic */}
{assignedTopic && (
<div className="mb-8">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
This Week's Topic {weeklyDone && <CheckCircle size={20} className="text-teal" />}
</h2>
<Card
hoverable
className={`border-2 cursor-pointer transition-all ${weeklyDone ? 'border-teal/30 bg-teal/5' : 'border-teal shadow-md'}`}
onClick={() => handleOpenTopic(assignedTopic)}
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-2">
<Tag variant={weeklyDone ? 'success' : 'accent'} className="text-xs">
{weeklyDone ? 'Completed' : 'Required'}
</Tag>
{hasCurriculum && (
<Tag variant="dark" className="text-[10px]">Week {state.weekNumber}</Tag>
)}
</div>
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
<p className="text-fg-muted mt-1">{assignedTopic.description}</p>
</div>
<Button className="whitespace-nowrap flex-shrink-0">
{weeklyDone ? 'Review' : 'Start Learning'} <ArrowRight size={18} className="ml-2" />
</Button>
</div>
</Card>
</div>
)}
{/* Upcoming Schedule (only when curriculum exists) */}
{hasCurriculum && upcoming.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<Calendar size={20} className="text-fg-muted" /> Coming Up
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{upcoming.map(week => (
<Card key={week.week_number} className="border border-bg-warm p-4">
<div className="flex items-center justify-between mb-2">
<Tag variant="dark" className="text-[10px] font-mono">Week {week.week_number}</Tag>
{week.is_review_week && <Tag variant="accent" className="text-[10px]">Review</Tag>}
</div>
{week.topic ? (
<>
<h4 className="font-medium text-sm leading-tight">{week.topic.label}</h4>
<p className="text-xs text-fg-muted mt-1">{week.theme}</p>
</>
) : week.is_review_week ? (
<h4 className="font-medium text-sm text-purple-600">{week.theme}</h4>
) : (
<h4 className="text-sm text-fg-muted italic">Unassigned</h4>
)}
</Card>
))}
</div>
</div>
)}
{/* Other Available Topics */}
{otherTopics.length > 0 && (
<div>
<h2 className="text-xl font-bold mb-4">Knowledge Base Library</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{otherTopics.map(topic => (
<Card
key={topic.id}
hoverable
className="border border-bg-warm cursor-pointer flex flex-col h-full"
onClick={() => handleOpenTopic(topic)}
>
<Tag variant="dark" className="self-start text-[10px] mb-2">{topic.type}</Tag>
<h3 className="font-bold text-lg mb-1">{topic.label}</h3>
<p className="text-sm text-fg-muted line-clamp-2 mb-4 flex-1">{topic.description}</p>
<div className="flex items-center text-teal font-medium text-sm mt-auto group">
Learn <ArrowRight size={14} className="ml-1 transition-transform group-hover:translate-x-1" />
</div>
</Card>
))}
</div>
</div>
)}
</div>
);
};
export default Leren;