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

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

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