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:
RaymondVerhoef
2026-05-23 18:13:08 +02:00
parent dda20612e9
commit 472685f0d7
62 changed files with 11552 additions and 21 deletions

View 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' });
});
}

View 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,
});
});
}