feat: implement curriculum management system including automated generation, enrichment, and versioning workflows

This commit is contained in:
RaymondVerhoef
2026-05-24 19:50:20 +02:00
parent 8e01b21a50
commit c5e23c77cd
15 changed files with 1354 additions and 623 deletions

View File

@@ -1,250 +1,352 @@
import * as db from './db';
import { callLLM, cachedSystem } from './llm';
import { EMIT_CURRICULUM_SCHEDULE_TOOL, EMIT_TOPIC_ENRICHMENT_TOOL } from './llmTools';
/**
* Default quarterly theme structure for auto-generating a curriculum.
* Each quarter has a name and a list of thematic blocks.
* Get the current curriculum week (1-26) based on an ISO week number.
*/
const DEFAULT_QUARTERS = [
{
quarter: 1,
name: 'Foundation & Governance',
themes: [
'Company Purpose & Values',
'Governance',
'People & Culture',
'Recruitment',
],
},
{
quarter: 2,
name: 'Compliance, Legal & Finance',
themes: [
'Privacy',
'Compliance',
'Quality',
'Finance',
],
},
{
quarter: 3,
name: 'Technology & Operations',
themes: [
'Strategy',
'Infrastructure',
'Workplace',
'Service Management',
'Software Delivery',
],
},
{
quarter: 4,
name: 'Business, Marketing & Sustainability',
themes: [
'Marketing',
'Networking',
'Events',
'Sustainability',
'Year Wrap-up',
],
},
];
/**
* Get the current curriculum year based on a date.
* Uses the calendar year.
*/
export function getCurriculumYear(date = new Date()) {
return date.getFullYear();
export function getCurriculumWeek(isoWeekNumber) {
return ((isoWeekNumber - 1) % 26) + 1;
}
/**
* Get the quarter number (1-4) for a given ISO week number.
* Get the current curriculum cycle (1, 2, 3...) based on an ISO week number.
*/
export function getQuarterForWeek(weekNumber) {
if (weekNumber <= 13) return 1;
if (weekNumber <= 26) return 2;
if (weekNumber <= 39) return 3;
return 4;
export function getCurriculumCycle(isoWeekNumber) {
return Math.floor((isoWeekNumber - 1) / 26) + 1;
}
/**
* Get the quarter name for a given week number.
* Groups topics by their theme field and sorts them by complexity_weight ascending.
* Returns: Map<themeName, Topic[]>
*/
export function getQuarterName(weekNumber) {
const q = getQuarterForWeek(weekNumber);
return DEFAULT_QUARTERS[q - 1]?.name || `Quarter ${q}`;
}
/**
* Get the assigned topic for a given week from the curriculum.
* Returns { topic, curriculumEntry } or { topic: null } if no curriculum exists.
*/
export async function getCurriculumTopic(weekNumber, year) {
const currYear = year ?? getCurriculumYear();
const entry = await db.getCurriculumWeek(currYear, weekNumber);
if (!entry || !entry.topic_id) {
return { topic: null, curriculumEntry: entry || null };
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);
}
// Resolve the topic from the topics collection (ensure it is not excluded)
const topics = await db.getTopics();
const topic = topics.find(t => t.id === entry.topic_id && t.learning_relevance !== 'exclude') || null;
return { topic, curriculumEntry: entry };
}
/**
* Get the full curriculum for a year, with resolved topic labels.
*/
export async function getFullCurriculum(year) {
const currYear = year ?? getCurriculumYear();
const entries = await db.getCurriculum(currYear);
const topics = await db.getTopics();
const topicMap = Object.fromEntries(topics.map(t => [t.id, t]));
return entries.map(entry => ({
...entry,
topic: topicMap[entry.topic_id] || null,
}));
}
/**
* Get progress for a user in a given quarter.
* Returns { completed, total, percentage }.
*/
export async function getQuarterProgress(userId, quarter, year) {
const currYear = year ?? getCurriculumYear();
const curriculum = await db.getCurriculum(currYear);
const quarterWeeks = curriculum.filter(w => w.quarter === quarter);
let completed = 0;
for (const week of quarterWeeks) {
const done = await db.getLearnDone(userId, week.week_number);
if (done) completed++;
// 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 {
completed,
total: quarterWeeks.length,
percentage: quarterWeeks.length > 0
? Math.round((completed / quarterWeeks.length) * 100)
: 0,
};
return map;
}
/**
* Get overall annual progress for a user.
* Returns { completed, total, percentage }.
* 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 async function getYearProgress(userId, year) {
const currYear = year ?? getCurriculumYear();
const curriculum = await db.getCurriculum(currYear);
if (curriculum.length === 0) {
return { completed: 0, total: 52, percentage: 0 };
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}.`);
}
let completed = 0;
for (const week of curriculum) {
const done = await db.getLearnDone(userId, week.week_number);
if (done) completed++;
}
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));
return {
completed,
total: curriculum.length,
percentage: Math.round((completed / curriculum.length) * 100),
};
}
const scheduledThemes = new Set();
/**
* Get upcoming weeks from the curriculum (next N weeks after current).
*/
export async function getUpcomingWeeks(currentWeek, count = 4, year) {
const currYear = year ?? getCurriculumYear();
const curriculum = await getFullCurriculum(currYear);
return curriculum
.filter(w => w.week_number > currentWeek && w.week_number <= currentWeek + count)
.sort((a, b) => a.week_number - b.week_number);
}
/**
* Auto-generate a 52-week curriculum from available topics.
* Distributes topics evenly across quarters, adds review weeks at 13, 26, 39, 52.
*/
export async function autoGenerateCurriculum(year) {
const currYear = year ?? getCurriculumYear();
const topics = await db.getTopics();
// Filter out 'fact' type topics and 'exclude' relevance topics
const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
const weeks = [];
const reviewWeeks = [13, 26, 39, 52];
// Distribute topics across the 48 non-review weeks.
let topicIndex = 0;
for (let w = 1; w <= 52; w++) {
const quarter = getQuarterForWeek(w);
if (reviewWeeks.includes(w)) {
// Review / recap week
weeks.push({
week_number: w,
topic_id: '',
theme: `Q${quarter} Review`,
quarter,
is_review_week: true,
sort_order: w,
});
} else if (topicIndex < learningTopics.length) {
const topic = learningTopics[topicIndex];
weeks.push({
week_number: w,
topic_id: topic.id,
theme: topic.type || 'General',
quarter,
is_review_week: false,
sort_order: w,
});
topicIndex++;
} else if (learningTopics.length > 0) {
// If we have more weeks than topics, cycle through topics again
const topic = learningTopics[topicIndex % learningTopics.length];
weeks.push({
week_number: w,
topic_id: topic.id,
theme: `${topic.type || 'General'} (Deep Dive)`,
quarter,
is_review_week: false,
sort_order: w,
});
topicIndex++;
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 {
// No topics at all
weeks.push({
week_number: w,
topic_id: '',
theme: 'Unassigned',
quarter,
is_review_week: false,
sort_order: w,
});
for (const tId of week.topic_ids) {
if (!validTopicIds.has(tId)) {
errors.push(`Week ${week.week_number} references unknown topic_id: ${tId}`);
}
}
}
}
await db.bulkSetCurriculum(currYear, weeks);
return weeks;
// 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 };
}
/**
* Check if a curriculum exists for the given year.
* Computes coverage stats for a schedule.
*/
export async function hasCurriculum(year) {
const currYear = year ?? getCurriculumYear();
const entries = await db.getCurriculum(currYear);
return entries.length > 0;
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
let result;
try {
result = await callLLM({
task: 'curriculum.generate',
tier: 'standard',
system: cachedSystem(SYSTEM_PROMPT),
user: userPrompt,
tools: [EMIT_CURRICULUM_SCHEDULE_TOOL],
toolChoice: { type: 'tool', name: EMIT_CURRICULUM_SCHEDULE_TOOL.name },
maxTokens: 8192,
temperature: 0,
});
} catch (err) {
throw new Error(`AI generation failed: ${err.message}`);
}
const emitted = result.toolUses[0]?.input;
if (!emitted || !emitted.weeks) {
throw new Error('The AI did not emit a valid curriculum schedule.');
}
const schedule = emitted.weeks;
// Validate
const validation = validateSchedule(schedule, topics);
if (!validation.valid) {
throw new Error(`Generated schedule failed validation:\n- ${validation.errors.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();
if (currentActive) {
await db.updateCurriculumVersion(currentActive.id, { status: 'superseded' });
}
return db.updateCurriculumVersion(versionId, {
status: 'active',
confirmed_by: adminUserId,
confirmed_at: new Date().toISOString(),
});
}
/**
* 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 weekTopics = scheduleWeek.topic_ids
.map(id => topics.find(t => t.id === 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.saveTopics([{
...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 };
}