feat: implement 52-week annual curriculum system with admin management and automated topic progression
This commit is contained in:
253
src/lib/curriculumService.js
Normal file
253
src/lib/curriculumService.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import * as db from './db';
|
||||
|
||||
/**
|
||||
* Default quarterly theme structure for auto-generating a curriculum.
|
||||
* Each quarter has a name and a list of thematic blocks.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the quarter number (1-4) for a given ISO week number.
|
||||
*/
|
||||
export function getQuarterForWeek(weekNumber) {
|
||||
if (weekNumber <= 13) return 1;
|
||||
if (weekNumber <= 26) return 2;
|
||||
if (weekNumber <= 39) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the quarter name for a given week number.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
// Resolve the topic from the topics collection
|
||||
const topics = await db.getTopics();
|
||||
const topic = topics.find(t => t.id === entry.topic_id) || 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++;
|
||||
}
|
||||
|
||||
return {
|
||||
completed,
|
||||
total: quarterWeeks.length,
|
||||
percentage: quarterWeeks.length > 0
|
||||
? Math.round((completed / quarterWeeks.length) * 100)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall annual progress for a user.
|
||||
* Returns { completed, total, percentage }.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
for (const week of curriculum) {
|
||||
const done = await db.getLearnDone(userId, week.week_number);
|
||||
if (done) completed++;
|
||||
}
|
||||
|
||||
return {
|
||||
completed,
|
||||
total: curriculum.length,
|
||||
percentage: Math.round((completed / curriculum.length) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 — those are for the knowledge graph only
|
||||
const learningTopics = topics.filter(t => t.type !== 'fact');
|
||||
|
||||
const weeks = [];
|
||||
const reviewWeeks = [13, 26, 39, 52];
|
||||
|
||||
// Calculate available weeks (52 total minus review weeks)
|
||||
const availableWeeks = 52 - reviewWeeks.length; // 48
|
||||
|
||||
// Distribute topics across available 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++;
|
||||
} else {
|
||||
// No topics at all
|
||||
weeks.push({
|
||||
week_number: w,
|
||||
topic_id: '',
|
||||
theme: 'Unassigned',
|
||||
quarter,
|
||||
is_review_week: false,
|
||||
sort_order: w,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.bulkSetCurriculum(currYear, weeks);
|
||||
return weeks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a curriculum exists for the given year.
|
||||
*/
|
||||
export async function hasCurriculum(year) {
|
||||
const currYear = year ?? getCurriculumYear();
|
||||
const entries = await db.getCurriculum(currYear);
|
||||
return entries.length > 0;
|
||||
}
|
||||
@@ -244,3 +244,67 @@ export async function setSetting(key, value) {
|
||||
return pb.collection('settings').create({ key, value: String(value) });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Curriculum ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getCurriculum(year) {
|
||||
try {
|
||||
return await pb.collection('curriculum').getFullList({
|
||||
filter: `year=${year}`,
|
||||
sort: 'week_number',
|
||||
});
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function getCurriculumWeek(year, weekNumber) {
|
||||
try {
|
||||
return await pb.collection('curriculum').getFirstListItem(
|
||||
`year=${year} && week_number=${weekNumber}`
|
||||
);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function setCurriculumWeek(year, weekNumber, data) {
|
||||
try {
|
||||
const r = await pb.collection('curriculum').getFirstListItem(
|
||||
`year=${year} && week_number=${weekNumber}`
|
||||
);
|
||||
return pb.collection('curriculum').update(r.id, data);
|
||||
} catch {
|
||||
return pb.collection('curriculum').create({
|
||||
year,
|
||||
week_number: weekNumber,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCurriculumWeek(year, weekNumber) {
|
||||
try {
|
||||
const r = await pb.collection('curriculum').getFirstListItem(
|
||||
`year=${year} && week_number=${weekNumber}`
|
||||
);
|
||||
return pb.collection('curriculum').delete(r.id);
|
||||
} catch { /* nothing to delete */ }
|
||||
}
|
||||
|
||||
export async function bulkSetCurriculum(year, weeks) {
|
||||
// Delete all existing entries for this year first
|
||||
const existing = await getCurriculum(year);
|
||||
await Promise.all(
|
||||
existing.map(r => pb.collection('curriculum').delete(r.id, { requestKey: null }))
|
||||
);
|
||||
// Create all new entries
|
||||
return Promise.all(
|
||||
weeks.map(w => pb.collection('curriculum').create({
|
||||
year,
|
||||
week_number: w.week_number,
|
||||
topic_id: w.topic_id || '',
|
||||
theme: w.theme || '',
|
||||
quarter: w.quarter || Math.ceil(w.week_number / 13),
|
||||
is_review_week: w.is_review_week || false,
|
||||
sort_order: w.sort_order ?? w.week_number,
|
||||
}, { requestKey: null }))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { anthropicApi } from './api';
|
||||
import * as db from './db';
|
||||
import { getCurriculumTopic, getCurriculumYear } 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.
|
||||
@@ -46,7 +47,21 @@ const CONTENT_SCHEMA_ALL = `{
|
||||
"infographic": ${CONTENT_SCHEMA_INFOGRAPHIC.replace(/^\{|\}$/g, '').trim()}
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Get the assigned topic for a given week.
|
||||
* Curriculum-first: checks the curriculum collection for the current year.
|
||||
* Falls back to hash-based assignment if no curriculum is configured.
|
||||
*/
|
||||
export async function getAssignedTopic(userId, weekNumber) {
|
||||
// Try curriculum first
|
||||
try {
|
||||
const { topic } = await getCurriculumTopic(weekNumber);
|
||||
if (topic) return topic;
|
||||
} catch (e) {
|
||||
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
|
||||
}
|
||||
|
||||
// Fallback: hash-based assignment (backwards compatible)
|
||||
const topics = await db.getTopics();
|
||||
if (!topics || topics.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { anthropicApi } from './api';
|
||||
import * as db from './db';
|
||||
import { getCurriculumTopic, getQuarterForWeek } from './curriculumService';
|
||||
|
||||
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
||||
You generate multiple-choice questions to test employee knowledge on specific topics.
|
||||
@@ -8,8 +9,39 @@ ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
|
||||
|
||||
async function selectTestTopics(userId, weekNumber) {
|
||||
const topics = await db.getTopics();
|
||||
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [] };
|
||||
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
||||
|
||||
// Try curriculum-based selection first
|
||||
try {
|
||||
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
|
||||
|
||||
if (curriculumEntry?.is_review_week) {
|
||||
// Review week: pull topics from the whole quarter
|
||||
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));
|
||||
// Use all quarter topics as review topics (no single primary)
|
||||
return {
|
||||
primaryTopic: quarterTopics[0] || topics[0],
|
||||
reviewTopics: quarterTopics.slice(1),
|
||||
isReviewWeek: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic) {
|
||||
const others = topics.filter(t => t.id !== topic.id);
|
||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
||||
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
||||
}
|
||||
|
||||
// Fallback: hash-based selection
|
||||
const str = `${userId}:${weekNumber}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
@@ -23,7 +55,7 @@ async function selectTestTopics(userId, weekNumber) {
|
||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
||||
|
||||
return { primaryTopic, reviewTopics };
|
||||
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
||||
}
|
||||
|
||||
export async function getCachedQuiz(userId, weekNumber) {
|
||||
|
||||
Reference in New Issue
Block a user