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,18 @@
import 'dotenv/config';
import Fastify from 'fastify';
import { generateRoutes } from './routes/generate.js';
import { publishRoutes } from './routes/publish.js';
const app = Fastify({ logger: true });
await app.register(generateRoutes);
await app.register(publishRoutes);
const port = parseInt(process.env['GENERATION_PORT'] ?? '3002', 10);
try {
await app.listen({ port, host: '0.0.0.0' });
} catch (err) {
app.log.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,168 @@
import { v4 as uuid } from 'uuid';
import { getPocketBase } from '../lib/pocketbase.js';
import { generateMicroLearning } from '../pipeline/generate.js';
import {
MICRO_LEARNING_TYPES,
type GenerationJob,
type JobProgress,
type TopicRecord,
} from '../types.js';
// ---------------------------------------------------------------------------
// In-memory store
// ---------------------------------------------------------------------------
const jobs = new Map<string, GenerationJob>();
const DEFAULT_PROGRESS: JobProgress = {
topicsTotal: 0,
topicsProcessed: 0,
itemsTotal: 0,
itemsGenerated: 0,
itemsFailed: 0,
};
export function createJob(themeId: string): GenerationJob {
const job: GenerationJob = {
id: uuid(),
themeId,
status: 'queued',
progress: { ...DEFAULT_PROGRESS },
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() });
}
function mergeProgress(id: string, partial: Partial<JobProgress>): void {
const job = jobs.get(id);
if (!job) return;
updateJob(id, { progress: { ...job.progress, ...partial } });
}
// ---------------------------------------------------------------------------
// Pipeline orchestration
// ---------------------------------------------------------------------------
async function runPipeline(jobId: string): Promise<void> {
const job = jobs.get(jobId);
if (!job) return;
updateJob(jobId, { status: 'running' });
let topics: TopicRecord[];
try {
topics = await fetchPublishedTopics(job.themeId);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
updateJob(jobId, { status: 'failed', error: `topic_fetch_failed: ${reason}` });
return;
}
const totalItems = topics.length * MICRO_LEARNING_TYPES.length;
mergeProgress(jobId, {
topicsTotal: topics.length,
itemsTotal: totalItems,
});
// Pre-create all micro_learning records with status: queued
const recordIds = await preCreateRecords(topics);
let generated = 0;
let failed = 0;
for (let ti = 0; ti < topics.length; ti++) {
const topic = topics[ti];
if (!topic) continue;
for (const type of MICRO_LEARNING_TYPES) {
const recordId = recordIds.get(`${topic.id}:${type}`);
if (!recordId) continue;
try {
const content = await generateMicroLearning(topic, type);
await updateMicroLearning(recordId, 'generated', content);
generated++;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await updateMicroLearning(recordId, 'failed', null, reason);
failed++;
}
mergeProgress(jobId, { itemsGenerated: generated, itemsFailed: failed });
}
mergeProgress(jobId, { topicsProcessed: ti + 1 });
}
updateJob(jobId, { status: 'done' });
}
// ---------------------------------------------------------------------------
// PocketBase helpers
// ---------------------------------------------------------------------------
async function fetchPublishedTopics(themeId: string): Promise<TopicRecord[]> {
const pb = await getPocketBase();
const records = await pb.collection('topics').getFullList({
filter: `theme = "${themeId}" && status = "published"`,
});
return records.map(r => ({
id: r['id'] as string,
title: r['title'] as string,
body: r['body'] as string,
difficulty: r['difficulty'] as TopicRecord['difficulty'],
key_terms: (r['key_terms'] as string[] | null) ?? [],
status: r['status'] as string,
}));
}
async function preCreateRecords(
topics: TopicRecord[],
): Promise<Map<string, string>> {
const pb = await getPocketBase();
const map = new Map<string, string>();
for (const topic of topics) {
for (const type of MICRO_LEARNING_TYPES) {
const record = await pb.collection('micro_learnings').create({
topic: topic.id,
type,
content: null,
status: 'queued',
generation_model: 'claude-sonnet-4-20250514',
});
map.set(`${topic.id}:${type}`, record.id);
}
}
return map;
}
async function updateMicroLearning(
recordId: string,
status: 'generated' | 'failed',
content: unknown,
_errorNote?: string,
): Promise<void> {
const pb = await getPocketBase();
await pb.collection('micro_learnings').update(recordId, {
status,
content: content ?? null,
generated_at: status === 'generated' ? new Date().toISOString() : null,
});
}

View 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;

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

View File

@@ -0,0 +1,163 @@
import { anthropic, MODELS } from '../lib/anthropic.js';
import {
CONTENT_SCHEMAS,
TYPE_LABELS,
type MicroLearningType,
type TopicRecord,
} from '../types.js';
const SYSTEM_PROMPT = `You are a learning content designer. Your task is to generate structured learning content for a specific topic in an employee learning platform.
Output ONLY valid JSON matching the schema provided. No preamble, no explanation, no markdown fences.
The content should be accurate, practical, and appropriate for the stated difficulty level. Tone: professional but accessible.`;
function buildUserPrompt(
topic: TopicRecord,
type: MicroLearningType,
schemaDescription: string,
strict: boolean,
): string {
const base = `Topic: ${topic.title}
Difficulty: ${topic.difficulty}
Body:
${topic.body}
Key terms: ${topic.key_terms.join(', ')}
Generate a ${TYPE_LABELS[type]} for this topic.
Output schema:
${schemaDescription}`;
return strict ? base + '\n\nRespond with valid JSON only, no other text.' : base;
}
const SCHEMA_DESCRIPTIONS: Record<MicroLearningType, string> = {
concept_explainer: `{
"paragraphs": ["2 to 3 paragraphs explaining the concept in plain language"],
"example": "one concrete real-world example"
}`,
scenario_quiz: `{
"scenario": "a realistic workplace scenario",
"options": [
{ "label": "A", "text": "answer text", "correct": false, "explanation": "why" },
{ "label": "B", "text": "answer text", "correct": true, "explanation": "why" },
{ "label": "C", "text": "answer text", "correct": false, "explanation": "why" },
{ "label": "D", "text": "answer text", "correct": false, "explanation": "why" }
]
}
Rules: exactly 4 options, exactly 1 marked correct: true.`,
misconceptions: `{
"items": [
{ "misconception": "common wrong belief", "correction": "accurate explanation" }
]
}
Rules: 3 to 5 items.`,
how_to: `{
"steps": [
{ "number": 1, "instruction": "what to do" }
]
}
Rules: 3 to 8 steps.`,
comparison_card: `{
"subject_a": "first concept or approach",
"subject_b": "second concept or approach",
"dimensions": [
{ "label": "dimension name", "a": "how A differs", "b": "how B differs" }
]
}
Rules: 3 to 6 dimensions.`,
reflection_prompt: `{
"prompt": "open-ended question for the employee to reflect on",
"model_answer": "a thoughtful example answer the employee can compare against"
}`,
flashcard_set: `{
"cards": [
{ "question": "question text", "answer": "answer text" }
]
}
Rules: 5 to 10 cards.`,
case_study: `{
"scenario": "a detailed real-world scenario (150+ words)",
"questions": ["discussion question 1", "discussion question 2"]
}
Rules: 2 to 4 questions.`,
glossary_anchor: `{
"term": "the key term",
"definition": "precise definition",
"correct_use": "example sentence showing correct use",
"misuse": "common incorrect usage to avoid"
}`,
myth_vs_evidence: `{
"myth": "a commonly held misconception about this topic",
"evidence": "the evidence-based counterpoint",
"sources": ["source or reference if applicable — use empty array if none"]
}`,
};
async function callClaude(prompt: string): Promise<string> {
const response = await anthropic.messages.create({
model: MODELS.SONNET,
max_tokens: 2000,
temperature: 0,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: prompt }],
});
const block = response.content[0];
if (!block || block.type !== 'text') {
throw new Error('unexpected response format from Claude');
}
return block.text;
}
async function parseAndValidate(raw: string, type: MicroLearningType): Promise<unknown> {
const schema = CONTENT_SCHEMAS[type];
const parsed: unknown = JSON.parse(raw);
return schema.parse(parsed);
}
export async function generateMicroLearning(
topic: TopicRecord,
type: MicroLearningType,
): Promise<unknown> {
const schemaDesc = SCHEMA_DESCRIPTIONS[type];
// For glossary_anchor, hint Claude to use the first key term
const topicWithHint: TopicRecord =
type === 'glossary_anchor' && topic.key_terms.length > 0
? { ...topic, body: topic.body + `\n\nAnchor term: ${topic.key_terms[0]}` }
: topic;
const prompt = buildUserPrompt(topicWithHint, type, schemaDesc, false);
let raw: string;
try {
raw = await callClaude(prompt);
} catch (err) {
throw new Error(`Claude API error: ${err instanceof Error ? err.message : String(err)}`);
}
// First parse attempt
try {
return await parseAndValidate(raw, type);
} catch {
// Retry with strict prompt
const strictPrompt = buildUserPrompt(topicWithHint, type, schemaDesc, true);
let raw2: string;
try {
raw2 = await callClaude(strictPrompt);
} catch (err) {
throw new Error(`Claude API error on retry: ${err instanceof Error ? err.message : String(err)}`);
}
try {
return await parseAndValidate(raw2, type);
} catch (err) {
throw new Error(
`validation failed after retry: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}

View File

@@ -0,0 +1,36 @@
import type { FastifyInstance } from 'fastify';
import { createJob, getJob } from '../jobs/queue.js';
import { GenerateBodySchema } from '../types.js';
export async function generateRoutes(app: FastifyInstance): Promise<void> {
app.post('/generate', async (request, reply) => {
const parsed = GenerateBodySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: 'invalid request', details: parsed.error.issues });
}
const { themeId } = parsed.data;
const job = createJob(themeId);
return reply.status(202).send({
jobId: job.id,
status: job.status,
topicsFound: job.progress.topicsTotal,
totalItems: job.progress.itemsTotal,
});
});
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({
jobId: job.id,
status: job.status,
progress: job.progress,
error: job.error,
});
});
}

View File

@@ -0,0 +1,44 @@
import type { FastifyInstance } from 'fastify';
import { getPocketBase } from '../lib/pocketbase.js';
import { PublishBodySchema } from '../types.js';
export async function publishRoutes(app: FastifyInstance): Promise<void> {
app.patch<{ Params: { id: string } }>('/micro-learnings/:id', async (request, reply) => {
const parsed = PublishBodySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: 'invalid request', details: parsed.error.issues });
}
const { id } = request.params;
const { status: newStatus } = parsed.data;
const pb = await getPocketBase();
let existing: Record<string, unknown>;
try {
existing = await pb.collection('micro_learnings').getOne(id) as Record<string, unknown>;
} catch {
return reply.status(404).send({ error: 'micro learning not found' });
}
if (existing['status'] !== 'generated') {
return reply.status(400).send({
error: 'only generated records can be published or rejected',
currentStatus: existing['status'],
});
}
const updates: Record<string, unknown> = { status: newStatus };
if (newStatus === 'published') {
updates['published_at'] = new Date().toISOString();
}
const updated = await pb.collection('micro_learnings').update(id, updates);
return reply.send({
id: updated.id,
status: updated['status'],
published_at: updated['published_at'] ?? null,
});
});
}

View File

@@ -0,0 +1,201 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Micro learning types
// ---------------------------------------------------------------------------
export const MICRO_LEARNING_TYPES = [
'concept_explainer',
'scenario_quiz',
'misconceptions',
'how_to',
'comparison_card',
'reflection_prompt',
'flashcard_set',
'case_study',
'glossary_anchor',
'myth_vs_evidence',
] as const;
export type MicroLearningType = (typeof MICRO_LEARNING_TYPES)[number];
// ---------------------------------------------------------------------------
// Content schemas — validated against AI output before PocketBase write
// ---------------------------------------------------------------------------
export const ConceptExplainerSchema = z.object({
paragraphs: z.array(z.string().min(10)).min(2).max(3),
example: z.string().min(20),
});
export const ScenarioQuizSchema = z.object({
scenario: z.string().min(30),
options: z
.array(
z.object({
label: z.enum(['A', 'B', 'C', 'D']),
text: z.string().min(5),
correct: z.boolean(),
explanation: z.string().min(10),
}),
)
.length(4)
.refine(opts => opts.filter(o => o.correct).length === 1, {
message: 'exactly one correct option required',
}),
});
export const MisconceptionsSchema = z.object({
items: z
.array(
z.object({
misconception: z.string().min(10),
correction: z.string().min(10),
}),
)
.min(3)
.max(5),
});
export const HowToSchema = z.object({
steps: z
.array(
z.object({
number: z.number().int().positive(),
instruction: z.string().min(10),
}),
)
.min(3)
.max(8),
});
export const ComparisonCardSchema = z.object({
subject_a: z.string().min(2),
subject_b: z.string().min(2),
dimensions: z
.array(
z.object({
label: z.string().min(2),
a: z.string().min(5),
b: z.string().min(5),
}),
)
.min(3)
.max(6),
});
export const ReflectionPromptSchema = z.object({
prompt: z.string().min(20),
model_answer: z.string().min(50),
});
export const FlashcardSetSchema = z.object({
cards: z
.array(
z.object({
question: z.string().min(5),
answer: z.string().min(5),
}),
)
.min(5)
.max(10),
});
export const CaseStudySchema = z.object({
scenario: z.string().min(150),
questions: z.array(z.string().min(10)).min(2).max(4),
});
export const GlossaryAnchorSchema = z.object({
term: z.string().min(2),
definition: z.string().min(20),
correct_use: z.string().min(20),
misuse: z.string().min(20),
});
export const MythVsEvidenceSchema = z.object({
myth: z.string().min(20),
evidence: z.string().min(30),
sources: z.array(z.string()),
});
// Map type → schema for lookup
export const CONTENT_SCHEMAS: Record<MicroLearningType, z.ZodTypeAny> = {
concept_explainer: ConceptExplainerSchema,
scenario_quiz: ScenarioQuizSchema,
misconceptions: MisconceptionsSchema,
how_to: HowToSchema,
comparison_card: ComparisonCardSchema,
reflection_prompt: ReflectionPromptSchema,
flashcard_set: FlashcardSetSchema,
case_study: CaseStudySchema,
glossary_anchor: GlossaryAnchorSchema,
myth_vs_evidence: MythVsEvidenceSchema,
};
// Map type → human-readable label for prompts
export const TYPE_LABELS: Record<MicroLearningType, string> = {
concept_explainer: 'Concept Explainer',
scenario_quiz: 'Scenario Quiz',
misconceptions: 'Misconceptions',
how_to: 'How-To Guide',
comparison_card: 'Comparison Card',
reflection_prompt: 'Reflection Prompt',
flashcard_set: 'Flashcard Set',
case_study: 'Case Study',
glossary_anchor: 'Glossary Anchor',
myth_vs_evidence: 'Myth vs Evidence',
};
// ---------------------------------------------------------------------------
// PocketBase: Topic (fetched from PB before generation)
// ---------------------------------------------------------------------------
export interface TopicRecord {
id: string;
title: string;
body: string;
difficulty: 'introductory' | 'intermediate' | 'advanced';
key_terms: string[];
status: string;
}
// ---------------------------------------------------------------------------
// Job system
// ---------------------------------------------------------------------------
export type JobStatus = 'queued' | 'running' | 'done' | 'failed';
export interface JobProgress {
topicsTotal: number;
topicsProcessed: number;
itemsTotal: number;
itemsGenerated: number;
itemsFailed: number;
}
export interface GenerationJob {
id: string;
themeId: string;
status: JobStatus;
progress: JobProgress;
error: string | null;
createdAt: Date;
updatedAt: Date;
}
// ---------------------------------------------------------------------------
// API request schemas (Zod — validates external input)
// ---------------------------------------------------------------------------
export const GenerateBodySchema = z.object({
themeId: z.string().min(1),
});
export type GenerateBody = z.infer<typeof GenerateBodySchema>;
export const PublishBodySchema = z.object({
status: z.enum(['published', 'rejected']),
});
export type PublishBody = z.infer<typeof PublishBodySchema>;