385 lines
12 KiB
JavaScript
385 lines
12 KiB
JavaScript
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<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 = [];
|
|
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 };
|
|
}
|