import * as db from './db'; import { callLLM, cachedSystem } from './llm'; import { EMIT_CURRICULUM_SCHEDULE_TOOL, EMIT_TOPIC_ENRICHMENT_TOOL } from './llmTools'; /** * Get the current curriculum week (1-26) based on an ISO week number. */ export function getCurriculumWeek(isoWeekNumber) { return ((isoWeekNumber - 1) % 26) + 1; } /** * Get the current curriculum cycle (1, 2, 3...) based on an ISO week number. */ export function getCurriculumCycle(isoWeekNumber) { return Math.floor((isoWeekNumber - 1) / 26) + 1; } /** * Groups topics by their theme field and sorts them by complexity_weight ascending. * Returns: Map */ export function buildThemeTopicMap(topics) { const map = new Map(); for (const topic of topics) { if (topic.type === 'fact' || topic.learning_relevance === 'exclude') continue; const theme = topic.theme || 'General'; if (!map.has(theme)) { map.set(theme, []); } map.get(theme).push(topic); } // Sort within each theme by complexity_weight ascending for (const [theme, themeTopics] of map.entries()) { themeTopics.sort((a, b) => (a.complexity_weight || 3) - (b.complexity_weight || 3)); } return map; } /** * Validates a 26-week schedule against the provided topics. * Checks for exactly 26 weeks, duration range, theme existence, and topic existence. * Returns { valid: boolean, errors: string[] } */ export function validateSchedule(schedule, topics) { const errors = []; if (!Array.isArray(schedule) || schedule.length !== 26) { errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`); } const validThemes = new Set(topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude').map(t => t.theme || 'General')); const validTopicIds = new Set(topics.map(t => t.id)); const scheduledThemes = new Set(); for (let i = 0; i < (schedule || []).length; i++) { const week = schedule[i]; if (week.week_number !== i + 1) { errors.push(`Week ${i + 1} has incorrect week_number: ${week.week_number}`); } 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}`); } scheduledThemes.add(week.theme); if (!week.topic_ids || week.topic_ids.length === 0) { errors.push(`Week ${week.week_number} has no topic_ids.`); } else { for (const tId of week.topic_ids) { if (!validTopicIds.has(tId)) { errors.push(`Week ${week.week_number} references unknown topic_id: ${tId}`); } } } } // Check coverage for (const t of validThemes) { if (!scheduledThemes.has(t)) { errors.push(`Theme '${t}' is missing from the schedule.`); } } return { valid: errors.length === 0, errors }; } /** * Computes coverage stats for a schedule. */ export function computeCoverageStats(schedule, topics) { const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'); const kbThemes = new Set(learningTopics.map(t => t.theme || 'General')); const scheduledThemes = new Set(); const scheduledTopics = new Set(); for (const w of schedule || []) { scheduledThemes.add(w.theme); (w.topic_ids || []).forEach(t => scheduledTopics.add(t)); } return { themes_kb: kbThemes.size, themes_scheduled: scheduledThemes.size, topics_kb: learningTopics.length, topics_scheduled: scheduledTopics.size, }; } /** * Auto-generate a 26-week curriculum draft. */ export async function generateCurriculumDraft(reason) { const topics = await db.getTopics(); const themeMap = buildThemeTopicMap(topics); if (themeMap.size === 0) { throw new Error('No valid topics or themes found to generate a curriculum.'); } // Build the prompt context let contextParts = []; for (const [theme, themeTopics] of themeMap.entries()) { const avgWeight = themeTopics.reduce((sum, t) => sum + (t.complexity_weight || 3), 0) / themeTopics.length; let listStr = themeTopics.map((t, idx) => ` ${idx + 1}. ${t.id} (weight: ${t.complexity_weight || 3}, ${t.difficulty || 'intermediate'})`).join('\n'); 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 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 - 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 - Include a one-sentence rationale per week explaining its position - Do NOT invent theme or topic references — use only the provided values - Emit via emit_curriculum_schedule tool — no prose`; // Try generation with a retry mechanism if validation fails let result; let schedule; let validationErrors = []; 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- ')}`; } try { result = await callLLM({ task: 'curriculum.generate', tier: 'standard', system: cachedSystem(SYSTEM_PROMPT), user: prompt, tools: [EMIT_CURRICULUM_SCHEDULE_TOOL], toolChoice: { type: 'tool', name: EMIT_CURRICULUM_SCHEDULE_TOOL.name }, maxTokens: 8192, temperature: 0, }); } catch (err) { if (attempt === 2) { throw new Error(`AI generation failed: ${err.message}`); } continue; } const emitted = result.toolUses[0]?.input; if (!emitted || !emitted.weeks) { validationErrors = ['The AI did not emit a valid curriculum schedule structure.']; if (attempt === 2) { throw new Error('The AI did not emit a valid curriculum schedule.'); } continue; } schedule = emitted.weeks; const validation = validateSchedule(schedule, topics); if (validation.valid) { validationErrors = []; break; } else { validationErrors = validation.errors; } } if (validationErrors.length > 0) { throw new Error(`Generated schedule failed validation after retry:\n- ${validationErrors.join('\n- ')}`); } const stats = computeCoverageStats(schedule, topics); // Reject any existing draft to enforce single-draft rule const existingDraft = await db.getDraftCurriculumVersion(); if (existingDraft) { await db.updateCurriculumVersion(existingDraft.id, { status: 'superseded' }); } const nextVersionNum = await db.getNextVersionNumber(); return db.createCurriculumVersion({ version_number: nextVersionNum, status: 'draft', generation_reason: reason || '', schedule: schedule, coverage_stats: stats, }); } /** * Confirm a draft curriculum version, making it active. */ export async function confirmVersion(versionId, adminUserId) { const version = await db.getCurriculumVersion(versionId); if (!version || version.status !== 'draft') { throw new Error('Invalid version or not a draft.'); } const currentActive = await db.getActiveCurriculumVersion(); // Set the new version to active first to ensure we never have zero active versions const updated = await db.updateCurriculumVersion(versionId, { status: 'active', confirmed_by: adminUserId, confirmed_at: new Date().toISOString(), }); // Supercede old active version gracefully if (currentActive) { try { await db.updateCurriculumVersion(currentActive.id, { status: 'superseded' }); } catch (e) { console.warn('[Curriculum] Failed to supersede old active version, but new version was successfully activated:', e.message); } } return updated; } /** * Reject a draft curriculum version. */ export async function rejectVersion(versionId) { const version = await db.getCurriculumVersion(versionId); if (!version || version.status !== 'draft') { throw new Error('Invalid version or not a draft.'); } return db.updateCurriculumVersion(versionId, { status: 'superseded' }); } export async function getActiveVersion() { return db.getActiveCurriculumVersion(); } export async function getDraftVersion() { return db.getDraftCurriculumVersion(); } export async function getVersionHistory() { return db.getCurriculumVersions(); } /** * Get the assigned topics and metadata for a given ISO week number. */ export async function getCurrentWeekContent(isoWeekNumber) { const activeVersion = await db.getActiveCurriculumVersion(); if (!activeVersion || !activeVersion.schedule) { return null; } const weekNumber = getCurriculumWeek(isoWeekNumber); const cycle = getCurriculumCycle(isoWeekNumber); const scheduleWeek = activeVersion.schedule.find(w => w.week_number === weekNumber); if (!scheduleWeek) return null; const topics = await db.getTopics(); const topicMap = new Map(topics.map(t => [t.id, t])); const weekTopics = scheduleWeek.topic_ids .map(id => topicMap.get(id)) .filter(Boolean); return { cycle, weekNumber, theme: scheduleWeek.theme, topics: weekTopics, estimatedDuration: scheduleWeek.estimated_duration, rationale: scheduleWeek.week_rationale }; } /** * Track progress for the current cycle based on completed weeks. */ export async function getYearProgress(userId, isoWeekNumber) { const activeVersion = await db.getActiveCurriculumVersion(); if (!activeVersion) { return { completed: 0, total: 26, percentage: 0 }; } const currentCycle = getCurriculumCycle(isoWeekNumber); const cycleStartWeek = (currentCycle - 1) * 26 + 1; const cycleEndWeek = currentCycle * 26; let completed = 0; for (let w = cycleStartWeek; w <= cycleEndWeek; w++) { const done = await db.getLearnDone(userId, w); if (done) completed++; } return { completed, total: 26, percentage: Math.round((completed / 26) * 100), }; } /** * One-off AI backfill for theme, complexity_weight, difficulty. */ export async function enrichTopicsForCurriculum() { const allTopics = await db.getTopics(); const unenriched = allTopics.filter(t => !t.theme && t.type !== 'fact' && t.learning_relevance !== 'exclude'); if (unenriched.length === 0) { return { enriched: 0, skipped: allTopics.length }; } const BATCH_SIZE = 20; // enrich in batches to avoid token limits let totalEnriched = 0; const SYSTEM = `You are an AI knowledge categorizer. Your task is to enrich a batch of topics with a theme (subject domain), complexity_weight (1-5), and difficulty (introductory, intermediate, advanced). Return the enriched data via emit_topic_enrichment tool.`; for (let i = 0; i < unenriched.length; i += BATCH_SIZE) { const batch = unenriched.slice(i, i + BATCH_SIZE); const batchJson = JSON.stringify(batch.map(t => ({ id: t.id, label: t.label, description: t.description }))); try { const result = await callLLM({ task: 'topic.enrich', tier: 'standard', system: cachedSystem(SYSTEM), user: `Enrich these topics:\n${batchJson}`, tools: [EMIT_TOPIC_ENRICHMENT_TOOL], toolChoice: { type: 'tool', name: EMIT_TOPIC_ENRICHMENT_TOOL.name }, maxTokens: 4096, }); const enrichedBatch = result.toolUses[0]?.input?.topics; if (enrichedBatch && Array.isArray(enrichedBatch)) { for (const update of enrichedBatch) { const original = allTopics.find(t => t.id === update.id); if (original) { await db.upsertTopic({ ...original, theme: update.theme, complexity_weight: update.complexity_weight, difficulty: update.difficulty }); totalEnriched++; } } } } catch (err) { console.warn('Batch enrichment failed:', err.message); } } return { enriched: totalEnriched, skipped: allTopics.length - totalEnriched }; }