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>
207 lines
6.7 KiB
JavaScript
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;
|
|
}
|