Implements phase 1 of AI_PIPELINE_HARDENING_PLAN.md. Every Anthropic call now goes through one module that owns retry, timeout, abort, structured- output parsing, schema validation, and best-effort call telemetry. * src/lib/llm.js — single callLLM entry point. Resolves model per tier (fast / standard / reasoning) with admin:model legacy fallback for the standard tier; 60s default timeout via AbortController; balanced-brace JSON extraction; LLMHttpError, LLMTruncatedError, LLMOutputError, and LLMValidationError surface clearly distinct failure modes. * src/lib/llmRetry.js — exponential backoff with full jitter, retries only on transient HTTP statuses, honours Retry-After up to 60s, never retries on AbortError. * src/lib/llmSchemas.js — Zod schemas for every structured task plus normalizeHandbookResult (collapses legacy "executes" relations into the canonical "executed_by" vocabulary). * src/lib/api.js — thin shim over callLLM so existing callers (extraction pipeline, learning, quiz, R42, knowledge graph) keep working unchanged. * src/lib/__tests__/ — 32 Vitest cases covering parse paths, error surfaces, simulation mode, model resolution, and schema validation. * src/pages/Admin/index.jsx — three model inputs (fast / standard / reasoning) replacing the single legacy field; legacy value falls back for the standard tier so existing overrides survive. Adds Zod and Vitest, plus an "npm run test" script. Also cleans up the pre-existing repo-wide ESLint failures so phase 1's "npm run lint passes" acceptance criterion can be checked: drops unused React imports across the JSX tree (React 19 JSX runtime auto-imports), attaches cause to rethrown errors in the service modules, ignores pb_migrations in the ESLint config (PocketBase JSVM globals), and removes one dead handleCreateCustom function in Leren.jsx. A real behaviour bug surfaced in Testen.jsx — the quiz timer captured a stale finishQuiz via setInterval closure; now updated via finishQuizRef so the timer always invokes the latest callback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
251 lines
6.4 KiB
JavaScript
251 lines
6.4 KiB
JavaScript
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 (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++;
|
|
}
|
|
|
|
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 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++;
|
|
} 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;
|
|
}
|