feat: implement curriculum management system including automated generation, enrichment, and versioning workflows
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -246,8 +246,55 @@ export function setSetting(key, value) {
|
||||
return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(value) });
|
||||
}
|
||||
|
||||
// ── Curriculum ────────────────────────────────────────────────────────────────
|
||||
// ── Curriculum Versions (v2) ──────────────────────────────────────────────────
|
||||
|
||||
export async function getCurriculumVersions(status) {
|
||||
try {
|
||||
const opts = { sort: '-version_number' };
|
||||
if (status) opts.filter = `status="${status}"`;
|
||||
return await pb.collection('curriculum_versions').getFullList(opts);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function getCurriculumVersion(id) {
|
||||
try {
|
||||
return await pb.collection('curriculum_versions').getOne(id);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function getActiveCurriculumVersion() {
|
||||
try {
|
||||
return await pb.collection('curriculum_versions').getFirstListItem('status="active"');
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function getDraftCurriculumVersion() {
|
||||
try {
|
||||
return await pb.collection('curriculum_versions').getFirstListItem('status="draft"');
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function createCurriculumVersion(data) {
|
||||
return pb.collection('curriculum_versions').create(data);
|
||||
}
|
||||
|
||||
export async function updateCurriculumVersion(id, data) {
|
||||
return pb.collection('curriculum_versions').update(id, data);
|
||||
}
|
||||
|
||||
export async function getNextVersionNumber() {
|
||||
try {
|
||||
const latest = await pb.collection('curriculum_versions').getFirstListItem('', {
|
||||
sort: '-version_number',
|
||||
fields: 'version_number',
|
||||
});
|
||||
return (latest?.version_number || 0) + 1;
|
||||
} catch { return 1; }
|
||||
}
|
||||
|
||||
// ── Curriculum (legacy, v1 — deprecated) ──────────────────────────────────────
|
||||
|
||||
/** @deprecated Use curriculum_versions (v2) instead. */
|
||||
export async function getCurriculum(year) {
|
||||
try {
|
||||
return await pb.collection('curriculum').getFullList({
|
||||
@@ -257,6 +304,7 @@ export async function getCurriculum(year) {
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** @deprecated Use curriculum_versions (v2) instead. */
|
||||
export async function getCurriculumWeek(year, weekNumber) {
|
||||
try {
|
||||
return await pb.collection('curriculum').getFirstListItem(
|
||||
@@ -265,11 +313,13 @@ export async function getCurriculumWeek(year, weekNumber) {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** @deprecated Use curriculum_versions (v2) instead. */
|
||||
export function setCurriculumWeek(year, weekNumber, data) {
|
||||
return pbUpsert('curriculum', `year=${year} && week_number=${weekNumber}`,
|
||||
data, { year, week_number: weekNumber, ...data });
|
||||
}
|
||||
|
||||
/** @deprecated Use curriculum_versions (v2) instead. */
|
||||
export async function deleteCurriculumWeek(year, weekNumber) {
|
||||
try {
|
||||
const r = await pb.collection('curriculum').getFirstListItem(
|
||||
@@ -279,6 +329,7 @@ export async function deleteCurriculumWeek(year, weekNumber) {
|
||||
} catch { /* nothing to delete */ }
|
||||
}
|
||||
|
||||
/** @deprecated Use curriculum_versions (v2) instead. */
|
||||
export async function bulkSetCurriculum(year, weeks) {
|
||||
// Delete all existing entries for this year first
|
||||
const existing = await getCurriculum(year);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ARTICLE_PATCH_TOOLS,
|
||||
} from './llmTools';
|
||||
import { applyAndValidate } from './articlePatches';
|
||||
import { getCurriculumTopic } from './curriculumService';
|
||||
import { getCurrentWeekContent } from './curriculumService';
|
||||
|
||||
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
|
||||
You write training material for employees based on knowledge topics.
|
||||
@@ -32,23 +32,27 @@ const INSTRUCTIONS_BY_TYPE = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the assigned topic for a given week.
|
||||
* Curriculum-first: checks the curriculum collection for the current year.
|
||||
* Get the assigned primary topic for a given week.
|
||||
* Curriculum v2: checks the active curriculum version for the given ISO week.
|
||||
* Falls back to hash-based assignment if no curriculum is configured.
|
||||
*/
|
||||
export async function getAssignedTopic(userId, weekNumber) {
|
||||
export async function getAssignedTopic(userId, isoWeekNumber) {
|
||||
try {
|
||||
const { topic } = await getCurriculumTopic(weekNumber);
|
||||
if (topic && topic.learning_relevance !== 'exclude') return topic;
|
||||
const weekContent = await getCurrentWeekContent(isoWeekNumber);
|
||||
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
|
||||
// For single-topic compatibility, return the first topic
|
||||
return weekContent.topics[0];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
|
||||
}
|
||||
|
||||
// Fallback hash-based assignment
|
||||
const allTopics = await db.getTopics();
|
||||
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||
if (!topics || topics.length === 0) return null;
|
||||
|
||||
const str = `${userId}:${weekNumber}`;
|
||||
const str = `${userId}:${isoWeekNumber}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
@@ -58,6 +62,24 @@ export async function getAssignedTopic(userId, weekNumber) {
|
||||
return topics[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all assigned topics for a given week.
|
||||
*/
|
||||
export async function getAssignedTopics(userId, isoWeekNumber) {
|
||||
try {
|
||||
const weekContent = await getCurrentWeekContent(isoWeekNumber);
|
||||
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
|
||||
return weekContent.topics;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
|
||||
}
|
||||
|
||||
// Fallback hash-based assignment
|
||||
const topic = await getAssignedTopic(userId, isoWeekNumber);
|
||||
return topic ? [topic] : [];
|
||||
}
|
||||
|
||||
export async function getCachedContent(topicId) {
|
||||
return db.getContent(topicId);
|
||||
}
|
||||
|
||||
@@ -177,6 +177,22 @@ const SIMULATION_INFOGRAPHIC = {
|
||||
|
||||
const SIMULATION_TOOL_STUBS = {
|
||||
emit_knowledge_graph: SIMULATION_EXTRACTION_GRAPH,
|
||||
emit_curriculum_schedule: {
|
||||
weeks: Array.from({ length: 26 }, (_, i) => ({
|
||||
week_number: i + 1,
|
||||
theme: i < 13 ? 'Privacy' : 'Governance',
|
||||
topic_ids: ['sim-topic'],
|
||||
estimated_duration: 30,
|
||||
week_rationale: `Simulated rationale for week ${i + 1}.`
|
||||
}))
|
||||
},
|
||||
emit_topic_enrichment: {
|
||||
topics: [
|
||||
{ id: 'radicale-transparantie', theme: 'Culture', complexity_weight: 2, difficulty: 'introductory' },
|
||||
{ id: 'kennisbeheer', theme: 'Process', complexity_weight: 4, difficulty: 'advanced' },
|
||||
{ id: 'wekelijkse-sessie', theme: 'Process', complexity_weight: 3, difficulty: 'intermediate' },
|
||||
]
|
||||
},
|
||||
|
||||
emit_learning_article: { article: SIMULATION_ARTICLE },
|
||||
emit_learning_slides: { slides: [SIMULATION_SLIDE] },
|
||||
|
||||
@@ -30,6 +30,29 @@ export const extractionResultSchema = z.object({
|
||||
relations: z.array(extractionRelationSchema),
|
||||
});
|
||||
|
||||
const curriculumWeekSchema = z.object({
|
||||
week_number: z.number().int().min(1).max(26),
|
||||
theme: z.string().min(1),
|
||||
topic_ids: z.array(z.string().min(1)).min(1),
|
||||
estimated_duration: z.number().int().min(15).max(45),
|
||||
week_rationale: z.string().min(1),
|
||||
});
|
||||
|
||||
export const curriculumScheduleSchema = z.object({
|
||||
weeks: z.array(curriculumWeekSchema).length(26),
|
||||
});
|
||||
|
||||
const topicEnrichmentSchemaDef = z.object({
|
||||
id: z.string().min(1),
|
||||
theme: z.string().min(1),
|
||||
complexity_weight: z.number().int().min(1).max(5),
|
||||
difficulty: z.enum(['introductory', 'intermediate', 'advanced']),
|
||||
});
|
||||
|
||||
export const topicEnrichmentSchema = z.object({
|
||||
topics: z.array(topicEnrichmentSchemaDef).min(1),
|
||||
});
|
||||
|
||||
|
||||
const articleSectionSchema = z.object({
|
||||
heading: z.string().min(1),
|
||||
@@ -185,6 +208,8 @@ export const replaceTakeawaysPatchSchema = z.object({
|
||||
*/
|
||||
export const toolSchemaRegistry = {
|
||||
emit_knowledge_graph: extractionResultSchema,
|
||||
emit_curriculum_schedule: curriculumScheduleSchema,
|
||||
emit_topic_enrichment: topicEnrichmentSchema,
|
||||
emit_learning_article: learningArticleSchema,
|
||||
emit_learning_slides: learningSlidesSchema,
|
||||
emit_learning_infographic: learningInfographicSchema,
|
||||
|
||||
@@ -46,6 +46,58 @@ export const EMIT_KNOWLEDGE_GRAPH_TOOL = {
|
||||
},
|
||||
};
|
||||
|
||||
export const EMIT_CURRICULUM_SCHEDULE_TOOL = {
|
||||
name: 'emit_curriculum_schedule',
|
||||
description: 'Emit a 26-week curriculum schedule. One theme per week, with an ordered subset of topics from that theme.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
weeks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
week_number: { type: 'integer', minimum: 1, maximum: 26 },
|
||||
theme: { type: 'string' },
|
||||
topic_ids: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
||||
estimated_duration: { type: 'integer', minimum: 15, maximum: 45 },
|
||||
week_rationale: { type: 'string' },
|
||||
},
|
||||
required: ['week_number', 'theme', 'topic_ids', 'estimated_duration', 'week_rationale'],
|
||||
},
|
||||
minItems: 26,
|
||||
maxItems: 26,
|
||||
},
|
||||
},
|
||||
required: ['weeks'],
|
||||
},
|
||||
};
|
||||
|
||||
export const EMIT_TOPIC_ENRICHMENT_TOOL = {
|
||||
name: 'emit_topic_enrichment',
|
||||
description: 'Enrich a batch of topics with a theme, complexity_weight, and difficulty.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topics: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
theme: { type: 'string' },
|
||||
complexity_weight: { type: 'integer', minimum: 1, maximum: 5 },
|
||||
difficulty: { type: 'string', enum: ['introductory', 'intermediate', 'advanced'] },
|
||||
},
|
||||
required: ['id', 'theme', 'complexity_weight', 'difficulty'],
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
},
|
||||
required: ['topics'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const articleSectionSchema = {
|
||||
type: 'object',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as db from './db';
|
||||
import { callLLM, cachedSystem } from './llm';
|
||||
import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools';
|
||||
import { getCurriculumTopic, getQuarterForWeek } from './curriculumService';
|
||||
import { getCurrentWeekContent } from './curriculumService';
|
||||
import { shuffle, sample } from './random';
|
||||
|
||||
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
||||
@@ -85,38 +85,26 @@ Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and
|
||||
return emitted.questions;
|
||||
}
|
||||
|
||||
async function selectTestTopics(userId, weekNumber) {
|
||||
async function selectTestTopics(userId, isoWeekNumber) {
|
||||
const allTopics = await db.getTopics();
|
||||
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
||||
|
||||
try {
|
||||
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
|
||||
const weekContent = await getCurrentWeekContent(isoWeekNumber);
|
||||
|
||||
if (curriculumEntry?.is_review_week) {
|
||||
const quarter = getQuarterForWeek(weekNumber);
|
||||
const curriculum = await db.getCurriculum(new Date().getFullYear());
|
||||
const quarterTopicIds = curriculum
|
||||
.filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week)
|
||||
.map(w => w.topic_id);
|
||||
const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id));
|
||||
return {
|
||||
primaryTopic: quarterTopics[0] || topics[0],
|
||||
reviewTopics: quarterTopics.slice(1),
|
||||
isReviewWeek: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic) {
|
||||
const others = topics.filter(t => t.id !== topic.id);
|
||||
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
|
||||
const primaryTopic = weekContent.topics[0]; // Use first topic as primary for now
|
||||
const others = topics.filter(t => t.id !== primaryTopic.id);
|
||||
const reviewTopics = sample(others, Math.min(5, others.length));
|
||||
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
|
||||
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
||||
}
|
||||
|
||||
const str = `${userId}:${weekNumber}`;
|
||||
// Fallback hash-based assignment
|
||||
const str = `${userId}:${isoWeekNumber}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
|
||||
Reference in New Issue
Block a user