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