Files
learning-platform/src/lib/learningService.js
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

207 lines
6.7 KiB
JavaScript

import { anthropicApi } from './api';
import * as db from './db';
import { getCurriculumTopic } from './curriculumService';
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
You write training material for employees based on knowledge topics.
Always write in clear, professional English.
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
const CONTENT_SCHEMA_ARTICLE = `{
"article": {
"title": "Article title",
"intro": "Short intro of 1-2 sentences",
"sections": [
{ "heading": "Section title", "body": "Section text of at least 3 sentences." }
],
"keyTakeaways": ["Takeaway 1", "Takeaway 2", "Takeaway 3"]
}
}`;
const CONTENT_SCHEMA_SLIDES = `{
"slides": [
{ "title": "Slide title", "bullets": ["Point 1", "Point 2", "Point 3"], "speakerNote": "Speaker note for this slide." }
]
}`;
const CONTENT_SCHEMA_INFOGRAPHIC = `{
"infographic": {
"headline": "A short, punchy headline summarizing the topic (max 8 words)",
"tagline": "A subtitle of max 15 words",
"stats": [
{ "value": "Number or %", "label": "Short description", "icon": "📊" }
],
"steps": [
{ "number": 1, "title": "Step title", "description": "One-sentence description.", "icon": "🔑" }
],
"quote": "An inspiring or insightful quote about the topic.",
"colorTheme": "teal"
}
}`;
const CONTENT_SCHEMA_ALL = `{
"article": ${CONTENT_SCHEMA_ARTICLE.replace(/^\{|\}$/g, '').trim()},
"slides": ${CONTENT_SCHEMA_SLIDES.replace(/^\{|\}$/g, '').trim()},
"infographic": ${CONTENT_SCHEMA_INFOGRAPHIC.replace(/^\{|\}$/g, '').trim()}
}`;
/**
* Get the assigned topic for a given week.
* Curriculum-first: checks the curriculum collection for the current year.
* Falls back to hash-based assignment if no curriculum is configured.
*/
export async function getAssignedTopic(userId, weekNumber) {
// Try curriculum first
try {
const { topic } = await getCurriculumTopic(weekNumber);
if (topic && topic.learning_relevance !== 'exclude') return topic;
} catch (e) {
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
}
// Fallback: hash-based assignment (backwards compatible)
const allTopics = await db.getTopics();
// Filter out 'fact' type topics and 'exclude' relevance topics
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
if (!topics || topics.length === 0) return null;
const str = `${userId}:${weekNumber}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
const index = Math.abs(hash) % topics.length;
return topics[index];
}
export async function getCachedContent(topicId) {
return db.getContent(topicId);
}
export async function getAllGeneratedContent() {
const topics = await db.getTopics();
const results = await Promise.all(
topics.map(async topic => {
const content = await db.getContent(topic.id);
return { topic, content, hasContent: !!content };
})
);
return results.filter(item => item.hasContent);
}
export async function generateLearningContent(topic, force = false, selectedType = 'article') {
let cached = null;
if (!force) {
cached = await db.getContent(topic.id);
if (cached) {
if (cached[selectedType]) {
console.log(`[Learn] Cache hit for topic: ${topic.id} (${selectedType})`);
return cached;
}
}
}
let schema = '';
let instructions = '';
if (selectedType === 'all') {
schema = CONTENT_SCHEMA_ALL;
instructions = 'Provide at least 3 article sections, 4 slides, 3 stats, and 3-5 steps in the infographic.';
} else if (selectedType === 'article') {
schema = CONTENT_SCHEMA_ARTICLE;
instructions = 'Provide at least 3 article sections.';
} else if (selectedType === 'slides') {
schema = CONTENT_SCHEMA_SLIDES;
instructions = 'Provide at least 4 slides.';
} else if (selectedType === 'infographic') {
schema = CONTENT_SCHEMA_INFOGRAPHIC;
instructions = 'Provide at least 3 stats, and 3-5 steps in the infographic.';
}
const prompt = `Generate a learning module piece for the following topic:
Label: ${topic.label}
Type: ${topic.type}
Description: ${topic.description}
Return ONLY a JSON object with the following structure:
${schema}
${instructions}`;
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
let newContent;
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
newContent = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
} catch (e) {
throw new Error('AI could not generate valid learning content. Please try again.', { cause: e });
}
const mergedContent = { ...(cached || {}), ...newContent };
await db.setContent(topic.id, mergedContent);
return mergedContent;
}
export async function refineLearningContent(topic, refinementInstruction) {
const existing = await db.getContent(topic.id);
const prompt = `You have previously generated the following learning module for the topic "${topic.label}":
${JSON.stringify(existing, null, 2)}
The admin has requested the following refinement:
"${refinementInstruction}"
Apply the refinement and return the complete updated JSON object using the same structure. Return ONLY valid JSON.`;
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
let content;
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
} catch (e) {
throw new Error('AI could not process the refinement. Please try a different instruction.', { cause: e });
}
await db.setContent(topic.id, content);
return content;
}
export async function deleteCachedContent(topicId) {
return db.deleteContent(topicId);
}
export async function generateCustomTopic(label) {
const prompt = `A user wants to learn about "${label}".
Create a short description (2-3 sentences) and categorize it.
Return ONLY a JSON object with this structure:
{
"label": "Polished topic title",
"type": "concept", // one of: concept, role, process
"description": "Short description"
}`;
const responseText = await anthropicApi.generateContent(
"You are a knowledge graph AI categorizing topics.",
prompt
);
let newTopic;
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
newTopic = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
newTopic.id = 'custom_' + Date.now().toString(36);
} catch (e) {
throw new Error('Could not process custom topic. Please try again.', { cause: e });
}
await db.upsertTopic(newTopic);
return newTopic;
}