Files
learning-platform/src/lib/curriculumService.js

417 lines
14 KiB
JavaScript

import * as db from './db';
import { callLLM, cachedSystem } from './llm';
import { EMIT_CURRICULUM_SCHEDULE_TOOL, EMIT_TOPIC_ENRICHMENT_TOOL } from './llmTools';
/**
* Personal weeks since enrollment, starting at 1.
* Cycle is purely relative: week 1 = first 7 days after start_date.
* Returns 0 if the user has not yet enrolled.
*/
export function getPersonalWeekNumber(startedAt, now = new Date()) {
if (!startedAt) return 0;
const start = startedAt instanceof Date ? startedAt : new Date(startedAt);
if (Number.isNaN(start.getTime())) return 0;
const elapsedMs = now.getTime() - start.getTime();
if (elapsedMs < 0) return 0;
const days = Math.floor(elapsedMs / 86_400_000);
return Math.floor(days / 7) + 1;
}
/**
* Curriculum slot (1-26) for a given personal week number.
* weekNumber is the absolute counter from getPersonalWeekNumber — it loops
* through the 26-slot schedule indefinitely.
*/
export function getCurriculumWeek(weekNumber) {
if (!weekNumber || weekNumber < 1) return 0;
return ((weekNumber - 1) % 26) + 1;
}
/**
* Cycle (1, 2, 3...) for a given personal week number.
*/
export function getCurriculumCycle(weekNumber) {
if (!weekNumber || weekNumber < 1) return 0;
return Math.floor((weekNumber - 1) / 26) + 1;
}
/**
* Groups topics by their theme field and sorts them by complexity_weight ascending.
* Returns: Map<themeName, Topic[]>
*/
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 = []; // 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}.`);
}
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}`);
}
// 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) {
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}`);
}
}
}
}
// 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)) {
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, warnings };
}
/**
* 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 (${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${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 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 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 validationResult = { valid: false, errors: [], warnings: [] };
for (let attempt = 1; attempt <= 2; attempt++) {
let prompt = userPrompt;
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 {
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) {
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.');
}
continue;
}
schedule = emitted.weeks;
validationResult = validateSchedule(schedule, topics);
if (validationResult.valid) {
break; // Hard errors resolved — warnings are acceptable
}
}
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);
// 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 personal week number.
*/
export async function getCurrentWeekContent(personalWeekNumber) {
const activeVersion = await db.getActiveCurriculumVersion();
if (!activeVersion || !activeVersion.schedule) {
return null;
}
const weekNumber = getCurriculumWeek(personalWeekNumber);
const cycle = getCurriculumCycle(personalWeekNumber);
if (weekNumber < 1) return null;
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, personalWeekNumber) {
const activeVersion = await db.getActiveCurriculumVersion();
if (!activeVersion) {
return { completed: 0, total: 26, percentage: 0 };
}
const currentCycle = getCurriculumCycle(personalWeekNumber);
if (currentCycle < 1) return { completed: 0, total: 26, percentage: 0 };
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 };
}