diff --git a/src/lib/curriculumService.js b/src/lib/curriculumService.js index 37d5335..3444f1d 100644 --- a/src/lib/curriculumService.js +++ b/src/lib/curriculumService.js @@ -45,7 +45,9 @@ export function buildThemeTopicMap(topics) { * Returns { valid: boolean, errors: string[] } */ 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) { 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) { errors.push(`Week ${week.week_number} has out-of-range duration: ${week.estimated_duration}`); } - if (!validThemes.has(week.theme)) { - errors.push(`Week ${week.week_number} references unknown theme: ${week.theme}`); - } + // Allow AI-merged theme names — only flag truly unknown themes as warnings scheduledThemes.add(week.theme); 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) { 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}`); } - 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. You receive a knowledge base snapshot organized by themes, each containing an ordered list of topics. Produce a 26-week learning schedule. Rules: -- Exactly 26 week slots, numbered 1-26 -- Every theme must appear at least once +- Exactly 26 week slots, numbered 1-26${mergeInstruction} - Themes with more topics may span multiple weeks - Introductory themes in the first half, advanced in the second half - 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 -- 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`; // Try generation with a retry mechanism if validation fails let result; let schedule; - let validationErrors = []; + let validationResult = { valid: false, errors: [], warnings: [] }; for (let attempt = 1; attempt <= 2; attempt++) { let prompt = userPrompt; - if (attempt > 1) { - prompt = `${userPrompt}\n\nWARNING: The previous generation attempt failed validation with the following errors. Please correct them:\n- ${validationErrors.join('\n- ')}`; + 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- ${validationResult.errors.join('\n- ')}`; } try { @@ -178,7 +187,7 @@ Rules: const emitted = result.toolUses[0]?.input; 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) { throw new Error('The AI did not emit a valid curriculum schedule.'); } @@ -186,17 +195,19 @@ Rules: } schedule = emitted.weeks; - const validation = validateSchedule(schedule, topics); - if (validation.valid) { - validationErrors = []; - break; - } else { - validationErrors = validation.errors; + validationResult = validateSchedule(schedule, topics); + if (validationResult.valid) { + break; // Hard errors resolved — warnings are acceptable } } - if (validationErrors.length > 0) { - throw new Error(`Generated schedule failed validation after retry:\n- ${validationErrors.join('\n- ')}`); + if (!validationResult.valid) { + 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);