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:
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user