feat: implement curriculum service for auto-generating, validating, and managing 26-week learning schedules
All checks were successful
On Push to Main / test (push) Successful in 32s
On Push to Main / publish (push) Successful in 1m2s
On Push to Main / deploy-dev (push) Successful in 1m32s

This commit is contained in:
RaymondVerhoef
2026-05-24 23:16:01 +02:00
parent 967c68d27d
commit f55ec950aa

View File

@@ -45,7 +45,9 @@ export function buildThemeTopicMap(topics) {
* Returns { valid: boolean, errors: string[] } * Returns { valid: boolean, errors: string[] }
*/ */
export function validateSchedule(schedule, topics) { export function validateSchedule(schedule, topics) {
const errors = []; const errors = []; // Hard errors — schedule is unusable
const warnings = []; // Soft warnings — schedule is usable but imperfect
if (!Array.isArray(schedule) || schedule.length !== 26) { if (!Array.isArray(schedule) || schedule.length !== 26) {
errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`); errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`);
} }
@@ -63,9 +65,7 @@ export function validateSchedule(schedule, topics) {
if (week.estimated_duration < 15 || week.estimated_duration > 45) { if (week.estimated_duration < 15 || week.estimated_duration > 45) {
errors.push(`Week ${week.week_number} has out-of-range duration: ${week.estimated_duration}`); errors.push(`Week ${week.week_number} has out-of-range duration: ${week.estimated_duration}`);
} }
if (!validThemes.has(week.theme)) { // Allow AI-merged theme names — only flag truly unknown themes as warnings
errors.push(`Week ${week.week_number} references unknown theme: ${week.theme}`);
}
scheduledThemes.add(week.theme); scheduledThemes.add(week.theme);
if (!week.topic_ids || week.topic_ids.length === 0) { if (!week.topic_ids || week.topic_ids.length === 0) {
@@ -79,14 +79,19 @@ export function validateSchedule(schedule, topics) {
} }
} }
// Check coverage // Theme coverage — soft warnings, not hard errors
// When there are more themes than 26 weeks, the AI must merge some.
const missingThemes = [];
for (const t of validThemes) { for (const t of validThemes) {
if (!scheduledThemes.has(t)) { if (!scheduledThemes.has(t)) {
errors.push(`Theme '${t}' is missing from the schedule.`); missingThemes.push(t);
} }
} }
if (missingThemes.length > 0) {
warnings.push(`${missingThemes.length} theme(s) not directly scheduled (topics may appear under merged themes): ${missingThemes.join(', ')}`);
}
return { valid: errors.length === 0, errors }; return { valid: errors.length === 0, errors, warnings };
} }
/** /**
@@ -131,31 +136,35 @@ export async function generateCurriculumDraft(reason) {
contextParts.push(`Theme "${theme}" (${themeTopics.length} topics, avg complexity ${avgWeight.toFixed(1)}):\n${listStr}`); contextParts.push(`Theme "${theme}" (${themeTopics.length} topics, avg complexity ${avgWeight.toFixed(1)}):\n${listStr}`);
} }
const userPrompt = `KB Snapshot:\n${contextParts.join('\n\n')}\n\nGeneration reason: "${reason || 'Initial curriculum generation'}"`; const userPrompt = `KB Snapshot (${themeMap.size} themes, ${topics.length} total topics):\n${contextParts.join('\n\n')}\n\nGeneration reason: "${reason || 'Initial curriculum generation'}"`;
// If there are more themes than 26 weeks, the AI must merge related themes
const mergeInstruction = themeMap.size > 26
? `\n- IMPORTANT: There are ${themeMap.size} themes but only 26 weeks. You MUST merge closely related themes into combined weeks. For example, combine "Data Privacy" and "Legal Compliance" into one week. Use any theme name from the merged themes, and include topic_ids from both themes in that week.`
: `\n- Every theme must appear at least once`;
const SYSTEM_PROMPT = `You are a curriculum architect for Respellion's internal learning platform. const SYSTEM_PROMPT = `You are a curriculum architect for Respellion's internal learning platform.
You receive a knowledge base snapshot organized by themes, each containing an ordered list of topics. Produce a 26-week learning schedule. You receive a knowledge base snapshot organized by themes, each containing an ordered list of topics. Produce a 26-week learning schedule.
Rules: Rules:
- Exactly 26 week slots, numbered 1-26 - Exactly 26 week slots, numbered 1-26${mergeInstruction}
- Every theme must appear at least once
- Themes with more topics may span multiple weeks - Themes with more topics may span multiple weeks
- Introductory themes in the first half, advanced in the second half - Introductory themes in the first half, advanced in the second half
- Complexity should increase progressively across the 26 weeks - Complexity should increase progressively across the 26 weeks
- Each week: one theme, 1+ topic IDs (from that theme only), duration 15-45 min - Each week: one theme name, 1+ topic IDs (topics may come from the named theme or a closely related merged theme), duration 15-45 min
- Include a one-sentence rationale per week explaining its position - Include a one-sentence rationale per week explaining its position
- Do NOT invent theme or topic references — use only the provided values - Do NOT invent topic IDs — use only the provided topic IDs
- Emit via emit_curriculum_schedule tool — no prose`; - Emit via emit_curriculum_schedule tool — no prose`;
// Try generation with a retry mechanism if validation fails // Try generation with a retry mechanism if validation fails
let result; let result;
let schedule; let schedule;
let validationErrors = []; let validationResult = { valid: false, errors: [], warnings: [] };
for (let attempt = 1; attempt <= 2; attempt++) { for (let attempt = 1; attempt <= 2; attempt++) {
let prompt = userPrompt; let prompt = userPrompt;
if (attempt > 1) { if (attempt > 1 && validationResult.errors.length > 0) {
prompt = `${userPrompt}\n\nWARNING: The previous generation attempt failed validation with the following errors. Please correct them:\n- ${validationErrors.join('\n- ')}`; prompt = `${userPrompt}\n\nWARNING: The previous generation attempt failed validation with the following errors. Please correct them:\n- ${validationResult.errors.join('\n- ')}`;
} }
try { try {
@@ -178,7 +187,7 @@ Rules:
const emitted = result.toolUses[0]?.input; const emitted = result.toolUses[0]?.input;
if (!emitted || !emitted.weeks) { if (!emitted || !emitted.weeks) {
validationErrors = ['The AI did not emit a valid curriculum schedule structure.']; validationResult = { valid: false, errors: ['The AI did not emit a valid curriculum schedule structure.'], warnings: [] };
if (attempt === 2) { if (attempt === 2) {
throw new Error('The AI did not emit a valid curriculum schedule.'); throw new Error('The AI did not emit a valid curriculum schedule.');
} }
@@ -186,17 +195,19 @@ Rules:
} }
schedule = emitted.weeks; schedule = emitted.weeks;
const validation = validateSchedule(schedule, topics); validationResult = validateSchedule(schedule, topics);
if (validation.valid) { if (validationResult.valid) {
validationErrors = []; break; // Hard errors resolved — warnings are acceptable
break;
} else {
validationErrors = validation.errors;
} }
} }
if (validationErrors.length > 0) { if (!validationResult.valid) {
throw new Error(`Generated schedule failed validation after retry:\n- ${validationErrors.join('\n- ')}`); throw new Error(`Generated schedule failed validation after retry:\n- ${validationResult.errors.join('\n- ')}`);
}
// Log warnings but don't fail
if (validationResult.warnings.length > 0) {
console.warn('[Curriculum] Schedule generated with warnings:', validationResult.warnings);
} }
const stats = computeCoverageStats(schedule, topics); const stats = computeCoverageStats(schedule, topics);