When the KB has more than 26 themes the curriculum AI is required to merge — so theme labels not appearing as week names are expected, not warnings. The previous validation surfaced this as a console.warn that read like an error in the learning station console. - validateSchedule now only flags missing theme labels when themes_kb <= 26 - adds a real coverage check: warns when learning topics are absent from every week (the actual signal we care about) - adds vitest coverage for both behaviours Closes #12 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
440 lines
16 KiB
JavaScript
440 lines
16 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.
|
|
*
|
|
* Warning policy:
|
|
* - Theme names not appearing as a week label are NOT a warning when the KB
|
|
* has more than 26 themes — the AI is required to merge in that case, and
|
|
* topic_ids from the merged themes are carried through under the chosen
|
|
* week label. We only warn about missing themes when merging wasn't needed.
|
|
* - Real coverage is measured on TOPICS: a topic that exists in the KB but
|
|
* is absent from every week's topic_ids is a genuine gap and gets a warning.
|
|
*
|
|
* Returns { valid: boolean, errors: string[], warnings: 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 learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
|
const validThemes = new Set(learningTopics.map(t => t.theme || 'General'));
|
|
const validTopicIds = new Set(topics.map(t => t.id));
|
|
const learningTopicIds = new Set(learningTopics.map(t => t.id));
|
|
|
|
const scheduledThemes = new Set();
|
|
const scheduledTopicIds = 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}`);
|
|
}
|
|
scheduledTopicIds.add(tId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Theme coverage — only warn when merging wasn't required. When themes_kb > 26
|
|
// the AI is *required* to merge, so absent theme labels are expected.
|
|
if (validThemes.size <= 26) {
|
|
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 scheduled: ${missingThemes.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// Topic coverage — the real signal. Topics carried under a merged theme are
|
|
// still covered; topics absent from every week are not.
|
|
const missingTopicCount = [...learningTopicIds].filter(id => !scheduledTopicIds.has(id)).length;
|
|
if (missingTopicCount > 0) {
|
|
warnings.push(`${missingTopicCount} learning topic(s) not covered by any week of the schedule.`);
|
|
}
|
|
|
|
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. With themes_kb > 26 the AI must merge themes,
|
|
// so most "missing theme" noise is filtered upstream in validateSchedule —
|
|
// anything that lands here is a genuine coverage gap worth surfacing.
|
|
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 };
|
|
}
|