Add specifications for gamification, generation, and R42 chat services
- Introduced gamification service spec detailing responsibilities, API surface, XP calculation, levels, streaks, badges, milestone cards, and heatmap data. - Added generation service spec outlining the process for generating micro learning content, including API endpoints, AI call configuration, prompt strategies, and error handling. - Created R42 chat service spec covering chatbot interactions, retrieval pipeline, prompt construction, response generation, and stateless design principles.
This commit is contained in:
4
app/services/curriculum/.gitignore
vendored
Normal file
4
app/services/curriculum/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.err
|
||||
1550
app/services/curriculum/package-lock.json
generated
Normal file
1550
app/services/curriculum/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
app/services/curriculum/package.json
Normal file
25
app/services/curriculum/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "curriculum",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.24",
|
||||
"fastify": "^4",
|
||||
"pocketbase": "^0.21",
|
||||
"uuid": "^9",
|
||||
"zod": "^3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/uuid": "^9",
|
||||
"dotenv": "^16",
|
||||
"tsx": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
200
app/services/curriculum/src/generator/build.ts
Normal file
200
app/services/curriculum/src/generator/build.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { getPocketBase } from '../lib/pocketbase.js';
|
||||
import { anthropic, MODELS } from '../lib/anthropic.js';
|
||||
import { sequenceTopics } from './sequence.js';
|
||||
import { buildCycleSystemRules, buildCycleUserPrompt } from './cycle.js';
|
||||
import {
|
||||
KBSnapshotSchema,
|
||||
CurriculumDraftSchema,
|
||||
type KBSnapshot,
|
||||
type CurriculumDraft,
|
||||
type CycleContext,
|
||||
} from '../types.js';
|
||||
|
||||
const BASE_SYSTEM_PROMPT = `You are a curriculum designer. Your task is to distribute a set of learning
|
||||
Themes across 26 weekly sessions to create an effective learning journey.
|
||||
|
||||
Output ONLY valid JSON matching the schema provided. No preamble, no
|
||||
explanation, no markdown fences.
|
||||
|
||||
Rules:
|
||||
- Every Theme must appear at least once across 26 weeks
|
||||
- Themes with more Topics (higher topic count) may span multiple weeks or
|
||||
appear in multiple cycles within the 26 weeks
|
||||
- Sequence Themes so foundational concepts precede dependent ones
|
||||
- Distribute complexity progressively: introductory Themes early, advanced
|
||||
Themes in the second half
|
||||
- If total Topics across all Themes exceeds what 26 weeks can cover in depth,
|
||||
prioritise breadth in cycle 1 — every Theme covered, key Topics per Theme
|
||||
- Assign an estimated duration in minutes per week (15–45 minutes per session)
|
||||
- Return exactly 26 week slots
|
||||
|
||||
Output schema:
|
||||
{
|
||||
"weeks": [
|
||||
{
|
||||
"weekNumber": 1,
|
||||
"themeId": "string",
|
||||
"topicIds": ["string"],
|
||||
"estimatedDurationMinutes": 25,
|
||||
"rationale": "one sentence"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
export async function fetchKBSnapshot(): Promise<KBSnapshot> {
|
||||
const pb = await getPocketBase();
|
||||
|
||||
const themeRecords = await pb.collection('themes').getFullList({
|
||||
filter: 'status = "published"',
|
||||
expand: 'topics',
|
||||
});
|
||||
|
||||
const themes = themeRecords.map(theme => {
|
||||
const expandedTopics = (theme['expand'] as Record<string, unknown>)?.['topics'];
|
||||
const rawTopics = Array.isArray(expandedTopics) ? expandedTopics : [];
|
||||
|
||||
const topics = rawTopics.map((t: unknown) => {
|
||||
const r = t as Record<string, unknown>;
|
||||
return {
|
||||
id: r['id'] as string,
|
||||
title: r['title'] as string,
|
||||
complexityWeight: (r['complexity_weight'] as number) ?? 1,
|
||||
difficulty: (r['difficulty'] as string) ?? 'introductory',
|
||||
prerequisiteTopics: (r['prerequisite_topics'] as string[]) ?? [],
|
||||
relatedTopics: (r['related_topics'] as string[]) ?? [],
|
||||
contrastTopics: (r['contrast_topics'] as string[]) ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: theme['id'] as string,
|
||||
title: theme['title'] as string,
|
||||
description: (theme['description'] as string) ?? '',
|
||||
topics,
|
||||
};
|
||||
});
|
||||
|
||||
return KBSnapshotSchema.parse({ themes });
|
||||
}
|
||||
|
||||
export function preprocessSnapshot(snapshot: KBSnapshot): KBSnapshot {
|
||||
return {
|
||||
themes: snapshot.themes.map(theme => ({
|
||||
...theme,
|
||||
topics: sequenceTopics(theme.topics),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function callAI(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
): Promise<CurriculumDraft> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const message = await anthropic.messages.create({
|
||||
model: MODELS.SONNET,
|
||||
max_tokens: 4000,
|
||||
temperature: 0,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
});
|
||||
|
||||
const textBlock = message.content.find(b => b.type === 'text');
|
||||
if (!textBlock || textBlock.type !== 'text') {
|
||||
if (attempt === 0) continue;
|
||||
throw new Error('No text block in AI response');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(textBlock.text);
|
||||
} catch {
|
||||
if (attempt === 0) continue;
|
||||
throw new Error('AI response is not valid JSON');
|
||||
}
|
||||
|
||||
const result = CurriculumDraftSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
if (attempt === 0) continue;
|
||||
throw new Error(`AI response failed Zod validation: ${result.error.message}`);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
throw new Error('AI generation failed after 2 attempts');
|
||||
}
|
||||
|
||||
function validateDraftAgainstSnapshot(draft: CurriculumDraft, snapshot: KBSnapshot): void {
|
||||
const themeIds = new Set(snapshot.themes.map(t => t.id));
|
||||
const topicIds = new Set(snapshot.themes.flatMap(t => t.topics.map(p => p.id)));
|
||||
|
||||
for (const week of draft.weeks) {
|
||||
if (!themeIds.has(week.themeId)) {
|
||||
throw new Error(`Unknown themeId in week ${week.weekNumber}: ${week.themeId}`);
|
||||
}
|
||||
for (const topicId of week.topicIds) {
|
||||
if (!topicIds.has(topicId)) {
|
||||
throw new Error(`Unknown topicId in week ${week.weekNumber}: ${topicId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeDraftToPocketBase(
|
||||
draft: CurriculumDraft,
|
||||
reason: string,
|
||||
): Promise<string> {
|
||||
const pb = await getPocketBase();
|
||||
|
||||
// Determine next version number
|
||||
const existing = await pb.collection('curriculum_versions').getFullList({
|
||||
sort: '-version',
|
||||
perPage: 1,
|
||||
});
|
||||
const latestVersion = existing[0] ? (existing[0]['version'] as number) : 0;
|
||||
|
||||
const versionRecord = await pb.collection('curriculum_versions').create({
|
||||
version: latestVersion + 1,
|
||||
status: 'draft',
|
||||
generated_at: new Date().toISOString(),
|
||||
generation_notes: reason,
|
||||
});
|
||||
|
||||
for (const week of draft.weeks) {
|
||||
await pb.collection('curriculum_weeks').create({
|
||||
curriculum_version: versionRecord.id,
|
||||
week_number: week.weekNumber,
|
||||
theme: week.themeId,
|
||||
topics: week.topicIds,
|
||||
topic_order: week.topicIds.map((_, i) => i),
|
||||
estimated_duration_minutes: week.estimatedDurationMinutes,
|
||||
admin_notes: week.rationale,
|
||||
});
|
||||
}
|
||||
|
||||
return versionRecord.id;
|
||||
}
|
||||
|
||||
export async function buildCurriculum(
|
||||
reason: string,
|
||||
cycleCtx?: CycleContext,
|
||||
): Promise<string> {
|
||||
const rawSnapshot = await fetchKBSnapshot();
|
||||
const snapshot = preprocessSnapshot(rawSnapshot);
|
||||
|
||||
let systemPrompt = BASE_SYSTEM_PROMPT;
|
||||
let userPrompt: string;
|
||||
|
||||
if (cycleCtx && cycleCtx.cycleNumber > 1) {
|
||||
systemPrompt += buildCycleSystemRules();
|
||||
userPrompt = buildCycleUserPrompt(snapshot, cycleCtx);
|
||||
} else {
|
||||
userPrompt = `Knowledge base snapshot:\n${JSON.stringify(snapshot)}\n\nGenerate a 26-week curriculum schedule.`;
|
||||
}
|
||||
|
||||
const draft = await callAI(systemPrompt, userPrompt);
|
||||
validateDraftAgainstSnapshot(draft, snapshot);
|
||||
|
||||
return writeDraftToPocketBase(draft, reason);
|
||||
}
|
||||
20
app/services/curriculum/src/generator/cycle.ts
Normal file
20
app/services/curriculum/src/generator/cycle.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CycleContext, KBSnapshot } from '../types.js';
|
||||
|
||||
const ADDITIONAL_SYSTEM_RULES = `
|
||||
- Vary the Theme sequence from the previous cycle
|
||||
- Topics identified as low engagement should appear earlier in this cycle
|
||||
- The rationale field should note what is different from cycle 1`;
|
||||
|
||||
export function buildCycleUserPrompt(snapshot: KBSnapshot, ctx: CycleContext): string {
|
||||
return `Knowledge base snapshot:
|
||||
${JSON.stringify(snapshot)}
|
||||
|
||||
Cycle context:
|
||||
${JSON.stringify(ctx)}
|
||||
|
||||
Generate a 26-week curriculum schedule.`;
|
||||
}
|
||||
|
||||
export function buildCycleSystemRules(): string {
|
||||
return ADDITIONAL_SYSTEM_RULES;
|
||||
}
|
||||
49
app/services/curriculum/src/generator/sequence.ts
Normal file
49
app/services/curriculum/src/generator/sequence.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { KBTopic } from '../types.js';
|
||||
|
||||
export function sequenceTopics(topics: KBTopic[]): KBTopic[] {
|
||||
if (topics.length === 0) return [];
|
||||
|
||||
const idToTopic = new Map<string, KBTopic>();
|
||||
for (const t of topics) {
|
||||
idToTopic.set(t.id, t);
|
||||
}
|
||||
|
||||
// Build adjacency list: id → prerequisite ids that exist in this set
|
||||
const prereqs = new Map<string, Set<string>>();
|
||||
for (const t of topics) {
|
||||
const localPrereqs = new Set(t.prerequisiteTopics.filter(id => idToTopic.has(id)));
|
||||
prereqs.set(t.id, localPrereqs);
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const result: KBTopic[] = [];
|
||||
const onStack = new Set<string>();
|
||||
let hasCycle = false;
|
||||
|
||||
function visit(id: string): void {
|
||||
if (onStack.has(id)) {
|
||||
hasCycle = true;
|
||||
return;
|
||||
}
|
||||
if (visited.has(id)) return;
|
||||
onStack.add(id);
|
||||
for (const prereqId of prereqs.get(id) ?? []) {
|
||||
visit(prereqId);
|
||||
}
|
||||
onStack.delete(id);
|
||||
visited.add(id);
|
||||
const topic = idToTopic.get(id);
|
||||
if (topic) result.push(topic);
|
||||
}
|
||||
|
||||
for (const t of topics) {
|
||||
visit(t.id);
|
||||
}
|
||||
|
||||
if (hasCycle) {
|
||||
// Fall back to complexity_weight ascending
|
||||
return [...topics].sort((a, b) => a.complexityWeight - b.complexityWeight);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
18
app/services/curriculum/src/index.ts
Normal file
18
app/services/curriculum/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'dotenv/config';
|
||||
import Fastify from 'fastify';
|
||||
import { curriculumRoutes } from './routes/curriculum.js';
|
||||
import { employeeRoutes } from './routes/employee.js';
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(curriculumRoutes);
|
||||
await app.register(employeeRoutes);
|
||||
|
||||
const port = parseInt(process.env['CURRICULUM_PORT'] ?? '3003', 10);
|
||||
|
||||
try {
|
||||
await app.listen({ port, host: '0.0.0.0' });
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
46
app/services/curriculum/src/jobs/queue.ts
Normal file
46
app/services/curriculum/src/jobs/queue.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { buildCurriculum } from '../generator/build.js';
|
||||
import type { GenerationJob } from '../types.js';
|
||||
|
||||
const jobs = new Map<string, GenerationJob>();
|
||||
|
||||
export function createJob(triggeredBy: string, reason: 'new_topics' | 'manual'): GenerationJob {
|
||||
const job: GenerationJob = {
|
||||
id: uuid(),
|
||||
triggeredBy,
|
||||
reason,
|
||||
status: 'queued',
|
||||
versionId: null,
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
void runPipeline(job.id);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getJob(id: string): GenerationJob | undefined {
|
||||
return jobs.get(id);
|
||||
}
|
||||
|
||||
function updateJob(id: string, updates: Partial<Omit<GenerationJob, 'id' | 'createdAt'>>): void {
|
||||
const job = jobs.get(id);
|
||||
if (!job) return;
|
||||
jobs.set(id, { ...job, ...updates, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
async function runPipeline(jobId: string): Promise<void> {
|
||||
const job = jobs.get(jobId);
|
||||
if (!job) return;
|
||||
|
||||
updateJob(jobId, { status: 'running' });
|
||||
|
||||
try {
|
||||
const versionId = await buildCurriculum(job.reason);
|
||||
updateJob(jobId, { status: 'done', versionId });
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
updateJob(jobId, { status: 'failed', error });
|
||||
}
|
||||
}
|
||||
9
app/services/curriculum/src/lib/anthropic.ts
Normal file
9
app/services/curriculum/src/lib/anthropic.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
export const anthropic = new Anthropic({
|
||||
apiKey: process.env['ANTHROPIC_API_KEY'],
|
||||
});
|
||||
|
||||
export const MODELS = {
|
||||
SONNET: 'claude-sonnet-4-20250514',
|
||||
} as const;
|
||||
14
app/services/curriculum/src/lib/pocketbase.ts
Normal file
14
app/services/curriculum/src/lib/pocketbase.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const POCKETBASE_URL = process.env['POCKETBASE_URL'] ?? '';
|
||||
const POCKETBASE_ADMIN_EMAIL = process.env['POCKETBASE_ADMIN_EMAIL'] ?? '';
|
||||
const POCKETBASE_ADMIN_PASSWORD = process.env['POCKETBASE_ADMIN_PASSWORD'] ?? '';
|
||||
|
||||
const pb = new PocketBase(POCKETBASE_URL);
|
||||
|
||||
export async function getPocketBase(): Promise<PocketBase> {
|
||||
if (!pb.authStore.isValid) {
|
||||
await pb.admins.authWithPassword(POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD);
|
||||
}
|
||||
return pb;
|
||||
}
|
||||
174
app/services/curriculum/src/routes/curriculum.ts
Normal file
174
app/services/curriculum/src/routes/curriculum.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { createJob, getJob } from '../jobs/queue.js';
|
||||
import { applyVersion } from '../versioning/apply.js';
|
||||
import { getPocketBase } from '../lib/pocketbase.js';
|
||||
|
||||
const GenerateBodySchema = z.object({
|
||||
triggeredBy: z.string(),
|
||||
reason: z.enum(['new_topics', 'manual']),
|
||||
});
|
||||
|
||||
const PatchWeekBodySchema = z.object({
|
||||
theme: z.string().optional(),
|
||||
topics: z.array(z.string()).optional(),
|
||||
topic_order: z.array(z.number()).optional(),
|
||||
admin_notes: z.string().optional(),
|
||||
estimated_duration_minutes: z.number().min(15).max(45).optional(),
|
||||
});
|
||||
|
||||
export async function curriculumRoutes(app: FastifyInstance): Promise<void> {
|
||||
// POST /generate
|
||||
app.post('/generate', async (request, reply) => {
|
||||
const body = GenerateBodySchema.safeParse(request.body);
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({ error: 'Invalid request body', details: body.error.issues });
|
||||
}
|
||||
|
||||
const job = createJob(body.data.triggeredBy, body.data.reason);
|
||||
return reply.status(202).send({ jobId: job.id, status: job.status });
|
||||
});
|
||||
|
||||
// GET /status/:jobId
|
||||
app.get<{ Params: { jobId: string } }>('/status/:jobId', async (request, reply) => {
|
||||
const job = getJob(request.params.jobId);
|
||||
if (!job) {
|
||||
return reply.status(404).send({ error: 'Job not found' });
|
||||
}
|
||||
return reply.send(job);
|
||||
});
|
||||
|
||||
// GET /current
|
||||
app.get('/current', async (_request, reply) => {
|
||||
const pb = await getPocketBase();
|
||||
|
||||
const versions = await pb.collection('curriculum_versions').getFullList({
|
||||
filter: 'status = "active"',
|
||||
});
|
||||
|
||||
if (versions.length === 0) {
|
||||
return reply.status(404).send({ error: 'No active curriculum version' });
|
||||
}
|
||||
|
||||
const version = versions[0]!;
|
||||
const weeks = await pb.collection('curriculum_weeks').getFullList({
|
||||
filter: `curriculum_version = "${version.id}"`,
|
||||
sort: 'week_number',
|
||||
expand: 'theme,topics',
|
||||
});
|
||||
|
||||
return reply.send({ version, weeks });
|
||||
});
|
||||
|
||||
// GET /preview
|
||||
app.get('/preview', async (_request, reply) => {
|
||||
const pb = await getPocketBase();
|
||||
|
||||
const versions = await pb.collection('curriculum_versions').getFullList({
|
||||
filter: 'status = "draft"',
|
||||
sort: '-version',
|
||||
perPage: 1,
|
||||
});
|
||||
|
||||
if (versions.length === 0) {
|
||||
return reply.status(404).send({ error: 'No draft curriculum version' });
|
||||
}
|
||||
|
||||
const version = versions[0]!;
|
||||
const weeks = await pb.collection('curriculum_weeks').getFullList({
|
||||
filter: `curriculum_version = "${version.id}"`,
|
||||
sort: 'week_number',
|
||||
expand: 'theme,topics',
|
||||
});
|
||||
|
||||
const themeIds = new Set(weeks.map(w => w['theme'] as string));
|
||||
const topicIds = new Set(weeks.flatMap(w => (w['topics'] as string[]) ?? []));
|
||||
|
||||
const coverageStats = {
|
||||
themesTotal: themeIds.size,
|
||||
themesCovered: themeIds.size,
|
||||
topicsTotal: topicIds.size,
|
||||
topicsCovered: topicIds.size,
|
||||
};
|
||||
|
||||
return reply.send({
|
||||
version: version['version'],
|
||||
weeks: weeks.map(w => {
|
||||
const expand = (w['expand'] as Record<string, unknown>) ?? {};
|
||||
const theme = expand['theme'] as Record<string, unknown> | undefined;
|
||||
const rawTopics = expand['topics'];
|
||||
const topicList = Array.isArray(rawTopics) ? rawTopics : [];
|
||||
|
||||
return {
|
||||
weekNumber: w['week_number'],
|
||||
theme: theme ? { id: theme['id'], title: theme['title'] } : null,
|
||||
topics: topicList.map((t: unknown) => {
|
||||
const tr = t as Record<string, unknown>;
|
||||
return {
|
||||
id: tr['id'],
|
||||
title: tr['title'],
|
||||
complexityWeight: tr['complexity_weight'],
|
||||
};
|
||||
}),
|
||||
estimatedDurationMinutes: w['estimated_duration_minutes'],
|
||||
};
|
||||
}),
|
||||
coverageStats,
|
||||
});
|
||||
});
|
||||
|
||||
// PATCH /weeks/:weekId
|
||||
app.patch<{ Params: { weekId: string } }>('/weeks/:weekId', async (request, reply) => {
|
||||
const body = PatchWeekBodySchema.safeParse(request.body);
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({ error: 'Invalid request body', details: body.error.issues });
|
||||
}
|
||||
|
||||
const pb = await getPocketBase();
|
||||
|
||||
let week;
|
||||
try {
|
||||
week = await pb.collection('curriculum_weeks').getOne(request.params.weekId);
|
||||
} catch {
|
||||
return reply.status(404).send({ error: 'Week not found' });
|
||||
}
|
||||
|
||||
// Only allow patching draft versions
|
||||
const version = await pb.collection('curriculum_versions').getOne(week['curriculum_version'] as string);
|
||||
if (version['status'] !== 'draft') {
|
||||
return reply.status(409).send({ error: 'Can only edit weeks belonging to a draft version' });
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (body.data.theme !== undefined) updates['theme'] = body.data.theme;
|
||||
if (body.data.topics !== undefined) updates['topics'] = body.data.topics;
|
||||
if (body.data.topic_order !== undefined) updates['topic_order'] = body.data.topic_order;
|
||||
if (body.data.admin_notes !== undefined) updates['admin_notes'] = body.data.admin_notes;
|
||||
if (body.data.estimated_duration_minutes !== undefined) {
|
||||
updates['estimated_duration_minutes'] = body.data.estimated_duration_minutes;
|
||||
}
|
||||
|
||||
const updated = await pb.collection('curriculum_weeks').update(request.params.weekId, updates);
|
||||
return reply.send(updated);
|
||||
});
|
||||
|
||||
// POST /confirm
|
||||
app.post('/confirm', async (_request, reply) => {
|
||||
const pb = await getPocketBase();
|
||||
|
||||
const drafts = await pb.collection('curriculum_versions').getFullList({
|
||||
filter: 'status = "draft"',
|
||||
sort: '-version',
|
||||
perPage: 1,
|
||||
});
|
||||
|
||||
if (drafts.length === 0) {
|
||||
return reply.status(404).send({ error: 'No draft curriculum to confirm' });
|
||||
}
|
||||
|
||||
const draftId = drafts[0]!.id;
|
||||
await applyVersion(draftId);
|
||||
|
||||
return reply.send({ applied: draftId, status: 'active' });
|
||||
});
|
||||
}
|
||||
140
app/services/curriculum/src/routes/employee.ts
Normal file
140
app/services/curriculum/src/routes/employee.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { getPocketBase } from '../lib/pocketbase.js';
|
||||
import type { EmployeeState } from '../types.js';
|
||||
|
||||
const AdvanceBodySchema = z.object({
|
||||
completedWeek: z.number().min(1).max(26),
|
||||
});
|
||||
|
||||
export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /state/:userId
|
||||
app.get<{ Params: { userId: string } }>('/state/:userId', async (request, reply) => {
|
||||
const pb = await getPocketBase();
|
||||
|
||||
const records = await pb.collection('employee_curriculum_state').getFullList({
|
||||
filter: `user = "${request.params.userId}"`,
|
||||
expand: 'active_version',
|
||||
});
|
||||
|
||||
if (records.length === 0) {
|
||||
return reply.status(404).send({ error: 'Employee curriculum state not found' });
|
||||
}
|
||||
|
||||
const state = records[0]!;
|
||||
const currentWeek = state['current_week'] as number;
|
||||
const activeVersionId = state['active_version'] as string;
|
||||
|
||||
// Fetch the week record for next session
|
||||
const nextWeekRecords = await pb.collection('curriculum_weeks').getFullList({
|
||||
filter: `curriculum_version = "${activeVersionId}" && week_number = ${currentWeek}`,
|
||||
expand: 'theme,topics',
|
||||
});
|
||||
|
||||
let nextSessionTheme: EmployeeState['nextSessionTheme'] = null;
|
||||
let nextSessionTopics: EmployeeState['nextSessionTopics'] = [];
|
||||
|
||||
const nextWeek = nextWeekRecords[0];
|
||||
if (nextWeek) {
|
||||
const expand = (nextWeek['expand'] as Record<string, unknown>) ?? {};
|
||||
const theme = expand['theme'] as Record<string, unknown> | undefined;
|
||||
if (theme) {
|
||||
nextSessionTheme = { id: theme['id'] as string, title: theme['title'] as string };
|
||||
}
|
||||
const rawTopics = expand['topics'];
|
||||
if (Array.isArray(rawTopics)) {
|
||||
nextSessionTopics = rawTopics.map((t: unknown) => {
|
||||
const tr = t as Record<string, unknown>;
|
||||
return {
|
||||
id: tr['id'] as string,
|
||||
title: tr['title'] as string,
|
||||
complexityWeight: (tr['complexity_weight'] as number) ?? 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const employeeState: EmployeeState = {
|
||||
userId: request.params.userId,
|
||||
currentCycle: state['current_cycle'] as number,
|
||||
currentWeek,
|
||||
startDate: state['start_date'] as string,
|
||||
activeVersionId,
|
||||
nextSessionTheme,
|
||||
nextSessionTopics,
|
||||
};
|
||||
|
||||
return reply.send(employeeState);
|
||||
});
|
||||
|
||||
// POST /advance/:userId
|
||||
app.post<{ Params: { userId: string } }>('/advance/:userId', async (request, reply) => {
|
||||
const body = AdvanceBodySchema.safeParse(request.body);
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({ error: 'Invalid request body', details: body.error.issues });
|
||||
}
|
||||
|
||||
const pb = await getPocketBase();
|
||||
|
||||
const records = await pb.collection('employee_curriculum_state').getFullList({
|
||||
filter: `user = "${request.params.userId}"`,
|
||||
});
|
||||
|
||||
if (records.length === 0) {
|
||||
return reply.status(404).send({ error: 'Employee curriculum state not found' });
|
||||
}
|
||||
|
||||
const state = records[0]!;
|
||||
const currentWeek = state['current_week'] as number;
|
||||
const currentCycle = state['current_cycle'] as number;
|
||||
|
||||
if (body.data.completedWeek !== currentWeek) {
|
||||
return reply.status(409).send({
|
||||
error: 'completedWeek does not match employee current_week',
|
||||
expected: currentWeek,
|
||||
received: body.data.completedWeek,
|
||||
});
|
||||
}
|
||||
|
||||
let newWeek: number;
|
||||
let newCycle: number;
|
||||
let newActiveVersion: string;
|
||||
|
||||
if (currentWeek === 26) {
|
||||
// Cycle transition
|
||||
newWeek = 1;
|
||||
newCycle = currentCycle + 1;
|
||||
|
||||
// Use current active curriculum version for new cycle
|
||||
const activeVersions = await pb.collection('curriculum_versions').getFullList({
|
||||
filter: 'status = "active"',
|
||||
perPage: 1,
|
||||
});
|
||||
newActiveVersion = activeVersions[0]?.id ?? (state['active_version'] as string);
|
||||
} else {
|
||||
newWeek = currentWeek + 1;
|
||||
newCycle = currentCycle;
|
||||
newActiveVersion = state['active_version'] as string;
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
current_week: newWeek,
|
||||
current_cycle: newCycle,
|
||||
active_version: newActiveVersion,
|
||||
};
|
||||
|
||||
if (currentWeek === 26) {
|
||||
updates['start_date'] = new Date().toISOString();
|
||||
}
|
||||
|
||||
await pb.collection('employee_curriculum_state').update(state.id, updates);
|
||||
|
||||
return reply.send({
|
||||
userId: request.params.userId,
|
||||
previousWeek: currentWeek,
|
||||
currentWeek: newWeek,
|
||||
currentCycle: newCycle,
|
||||
cycleTransition: currentWeek === 26,
|
||||
});
|
||||
});
|
||||
}
|
||||
125
app/services/curriculum/src/types.ts
Normal file
125
app/services/curriculum/src/types.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KB snapshot — input to generator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KBTopicSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
complexityWeight: z.number().min(1).max(5),
|
||||
difficulty: z.string(),
|
||||
prerequisiteTopics: z.array(z.string()),
|
||||
relatedTopics: z.array(z.string()),
|
||||
contrastTopics: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const KBThemeSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
topics: z.array(KBTopicSchema),
|
||||
});
|
||||
|
||||
export const KBSnapshotSchema = z.object({
|
||||
themes: z.array(KBThemeSchema),
|
||||
});
|
||||
|
||||
export type KBTopic = z.infer<typeof KBTopicSchema>;
|
||||
export type KBTheme = z.infer<typeof KBThemeSchema>;
|
||||
export type KBSnapshot = z.infer<typeof KBSnapshotSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Curriculum draft — AI output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CurriculumWeekDraftSchema = z.object({
|
||||
weekNumber: z.number().min(1).max(26),
|
||||
themeId: z.string(),
|
||||
topicIds: z.array(z.string()),
|
||||
estimatedDurationMinutes: z.number().min(15).max(45),
|
||||
rationale: z.string(),
|
||||
});
|
||||
|
||||
export const CurriculumDraftSchema = z.object({
|
||||
weeks: z.array(CurriculumWeekDraftSchema).length(26),
|
||||
});
|
||||
|
||||
export type CurriculumWeekDraft = z.infer<typeof CurriculumWeekDraftSchema>;
|
||||
export type CurriculumDraft = z.infer<typeof CurriculumDraftSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Employee state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EmployeeState {
|
||||
userId: string;
|
||||
currentCycle: number;
|
||||
currentWeek: number;
|
||||
startDate: string;
|
||||
activeVersionId: string;
|
||||
nextSessionTheme: { id: string; title: string } | null;
|
||||
nextSessionTopics: { id: string; title: string; complexityWeight: number }[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'done' | 'failed';
|
||||
|
||||
export interface GenerationJob {
|
||||
id: string;
|
||||
triggeredBy: string;
|
||||
reason: 'new_topics' | 'manual';
|
||||
status: JobStatus;
|
||||
versionId: string | null;
|
||||
error: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cycle variant context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CycleContext {
|
||||
cycleNumber: number;
|
||||
employeeHistory: {
|
||||
typesUsed: string[];
|
||||
typesNotUsed: string[];
|
||||
lowEngagementTopics: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PocketBase record shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CurriculumVersionRecord {
|
||||
id: string;
|
||||
version: number;
|
||||
status: 'draft' | 'active' | 'superseded';
|
||||
generated_at: string;
|
||||
generation_notes: string;
|
||||
}
|
||||
|
||||
export interface CurriculumWeekRecord {
|
||||
id: string;
|
||||
curriculum_version: string;
|
||||
week_number: number;
|
||||
theme: string;
|
||||
topics: string[];
|
||||
topic_order: number[];
|
||||
estimated_duration_minutes: number;
|
||||
admin_notes: string;
|
||||
}
|
||||
|
||||
export interface EmployeeStateRecord {
|
||||
id: string;
|
||||
user: string;
|
||||
current_cycle: number;
|
||||
current_week: number;
|
||||
start_date: string;
|
||||
active_version: string;
|
||||
}
|
||||
25
app/services/curriculum/src/versioning/apply.ts
Normal file
25
app/services/curriculum/src/versioning/apply.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getPocketBase } from '../lib/pocketbase.js';
|
||||
|
||||
export async function applyVersion(newVersionId: string): Promise<void> {
|
||||
const pb = await getPocketBase();
|
||||
|
||||
// Get all employees
|
||||
const employees = await pb.collection('employee_curriculum_state').getFullList();
|
||||
|
||||
for (const emp of employees) {
|
||||
await pb.collection('employee_curriculum_state').update(emp.id, {
|
||||
active_version: newVersionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Supersede old active version
|
||||
const activeVersions = await pb.collection('curriculum_versions').getFullList({
|
||||
filter: 'status = "active"',
|
||||
});
|
||||
for (const v of activeVersions) {
|
||||
await pb.collection('curriculum_versions').update(v.id, { status: 'superseded' });
|
||||
}
|
||||
|
||||
// Activate new version
|
||||
await pb.collection('curriculum_versions').update(newVersionId, { status: 'active' });
|
||||
}
|
||||
25
app/services/curriculum/src/versioning/freeze.ts
Normal file
25
app/services/curriculum/src/versioning/freeze.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getPocketBase } from '../lib/pocketbase.js';
|
||||
|
||||
/**
|
||||
* Returns the set of week numbers that are "frozen" for an employee —
|
||||
* weeks they have already started or completed, derived from their current_week.
|
||||
* Weeks < current_week are rendered from session_completions and must not be
|
||||
* replaced by a new curriculum version.
|
||||
*/
|
||||
export async function getFrozenWeeks(userId: string): Promise<Set<number>> {
|
||||
const pb = await getPocketBase();
|
||||
|
||||
const records = await pb.collection('employee_curriculum_state').getFullList({
|
||||
filter: `user = "${userId}"`,
|
||||
});
|
||||
|
||||
const state = records[0];
|
||||
if (!state) return new Set();
|
||||
|
||||
const currentWeek = state['current_week'] as number;
|
||||
const frozen = new Set<number>();
|
||||
for (let w = 1; w < currentWeek; w++) {
|
||||
frozen.add(w);
|
||||
}
|
||||
return frozen;
|
||||
}
|
||||
16
app/services/curriculum/tsconfig.json
Normal file
16
app/services/curriculum/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user