Feat: microlearning implementation

This commit is contained in:
RaymondVerhoef
2026-05-25 18:32:45 +02:00
parent 18e98380d9
commit 042dfb2d92
4 changed files with 386 additions and 59 deletions

View File

@@ -341,3 +341,92 @@ export const ARTICLE_PATCH_TOOLS = [
REMOVE_SECTION_TOOL,
REPLACE_TAKEAWAYS_TOOL,
];
// ── Micro Learning generation tools ───────────────────────────────────────────
export const EMIT_CONCEPT_EXPLAINER_TOOL = {
name: 'emit_concept_explainer',
description: 'Return a structured concept explanation with multiple sections. Each section moves from definition → importance → practical application. The final section must include a concrete workplace example.',
input_schema: {
type: 'object',
properties: {
sections: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string', description: 'Section heading.' },
content: { type: 'string', description: 'Section body in HTML. Use <p>, <ul>, <li>, <strong> tags for formatting. At least 3 sentences.' },
},
required: ['title', 'content'],
},
minItems: 3,
description: 'At least 3 sections: What it is, Why it matters, Practical example.',
},
},
required: ['sections'],
},
};
export const EMIT_SCENARIO_QUIZ_TOOL = {
name: 'emit_scenario_quiz',
description: 'Return a realistic workplace scenario with 34 plausible answer options. Exactly one option is correct. Each option must have a detailed explanation teaching why it is right or wrong.',
input_schema: {
type: 'object',
properties: {
scenario: { type: 'string', description: 'A realistic workplace situation (35 sentences) where the employee must decide what to do.' },
options: {
type: 'array',
items: {
type: 'object',
properties: {
text: { type: 'string', description: 'The action the employee could take.' },
isCorrect: { type: 'boolean', description: 'True for exactly one option.' },
explanation: { type: 'string', description: 'Why this option is correct or incorrect (23 sentences). Teach, do not just state.' },
},
required: ['text', 'isCorrect', 'explanation'],
},
minItems: 3,
maxItems: 4,
},
},
required: ['scenario', 'options'],
},
};
export const EMIT_FLASHCARD_SET_TOOL = {
name: 'emit_flashcard_set',
description: 'Return a set of 510 flashcards covering key facts, terms, and relationships from the topic. Mix question types: definitions, applications, and relationships.',
input_schema: {
type: 'object',
properties: {
cards: {
type: 'array',
items: {
type: 'object',
properties: {
front: { type: 'string', description: 'The question or prompt shown on the front of the card.' },
back: { type: 'string', description: 'The answer revealed on the back of the card.' },
},
required: ['front', 'back'],
},
minItems: 5,
maxItems: 10,
},
},
required: ['cards'],
},
};
export const EMIT_REFLECTION_PROMPT_TOOL = {
name: 'emit_reflection_prompt',
description: 'Return an open-ended reflection question that asks the employee to connect the topic to their own professional experience, plus a model answer showing the expected depth and specificity.',
input_schema: {
type: 'object',
properties: {
prompt: { type: 'string', description: 'An open-ended question that cannot be answered with a fact. It must require the employee to think about their own context.' },
model_answer: { type: 'string', description: 'An example of a thoughtful, specific response (35 sentences). This is not a rubric — it illustrates depth.' },
},
required: ['prompt', 'model_answer'],
},
};

View File

@@ -0,0 +1,195 @@
/**
* Micro Learning generation service.
*
* Implements the generate-then-cache strategy:
* 1. Check PocketBase for an existing published record (topic × type)
* 2. If found → return it (cache hit)
* 3. If not → call LLM, store result as published, return it
*
* Content is generated once per (topic, type) pair and shared across all users.
*/
import { pb } from './pb';
import { callLLM, cachedSystem } from './llm';
import {
EMIT_CONCEPT_EXPLAINER_TOOL,
EMIT_SCENARIO_QUIZ_TOOL,
EMIT_FLASHCARD_SET_TOOL,
EMIT_REFLECTION_PROMPT_TOOL,
} from './llmTools';
import * as db from './db';
// ── Configuration per micro learning type ─────────────────────────────────────
const MICRO_LEARNING_TYPES = {
concept_explainer: {
tool: EMIT_CONCEPT_EXPLAINER_TOOL,
tier: 'standard',
maxTokens: 4096,
instructions: `Generate a concept explainer with at least 3 sections.
Section 1: What the concept is — define it clearly.
Section 2: Why it matters — explain its importance in the workplace.
Section 3: Practical example — give a concrete, realistic scenario showing how it works in practice.
Use HTML formatting in the content fields (<p>, <ul>, <li>, <strong>).`,
},
scenario_quiz: {
tool: EMIT_SCENARIO_QUIZ_TOOL,
tier: 'standard',
maxTokens: 4096,
instructions: `Generate a scenario quiz with a realistic workplace situation.
The scenario should be specific and domain-relevant — something the employee might actually encounter.
Provide 34 answer options. Exactly one must be correct.
Each option needs a detailed explanation (23 sentences) that teaches why it is right or wrong.
The incorrect options should represent common mistakes or reasonable misreadings, not obviously wrong answers.`,
},
flashcard_set: {
tool: EMIT_FLASHCARD_SET_TOOL,
tier: 'fast',
maxTokens: 2048,
instructions: `Generate a flashcard set with 510 cards.
Mix three question types:
- Definitions: "What is X?"
- Applications: "How would you apply X in situation Y?"
- Relationships: "How does X relate to Y?"
Keep answers concise — one or two sentences maximum.`,
},
reflection_prompt: {
tool: EMIT_REFLECTION_PROMPT_TOOL,
tier: 'fast',
maxTokens: 1024,
instructions: `Generate a reflection prompt.
The question must be open-ended and cannot be answered with a fact.
It must require the employee to think about their own professional context — their team, their role, their past experience.
The model answer should show depth and specificity (35 sentences). It is not a rubric — it is an example of thoughtful reflection.`,
},
};
const SYSTEM_PROMPT = `You are an expert learning content writer for Respellion, an internal IT company.
You create micro learning content for employees based on knowledge topics from the company knowledge base.
Always write in clear, professional English.
Make the content practical and anchored to the workplace — avoid abstract theory without application.
Emit the content through the provided tool — do not return prose or raw JSON.`;
// ── Core API ──────────────────────────────────────────────────────────────────
/**
* Get an existing micro learning or generate a new one.
* Returns the PocketBase record (with .content parsed).
*/
export async function getOrGenerateMicroLearning(topicId, type) {
const config = MICRO_LEARNING_TYPES[type];
if (!config) throw new Error(`Unknown micro learning type: ${type}`);
// 1. Check cache
const existing = await findExisting(topicId, type);
if (existing) {
console.log(`[MicroLearning] Cache hit: ${topicId} / ${type}`);
return existing;
}
// 2. Load topic metadata
const topic = await loadTopic(topicId);
if (!topic) throw new Error(`Topic not found: ${topicId}`);
// 3. Generate
console.log(`[MicroLearning] Generating: ${topicId} / ${type} (tier: ${config.tier})`);
const content = await generateContent(topic, type, config);
// 4. Store in PocketBase
const record = await pb.collection('micro_learnings').create({
topic_id: topicId,
type: type,
content: content,
status: 'published',
});
console.log(`[MicroLearning] Stored: ${record.id}`);
return record;
}
/**
* Delete an existing micro learning and regenerate it.
* Used when a topic's content has changed and the cached version is stale.
*/
export async function regenerateMicroLearning(topicId, type) {
const config = MICRO_LEARNING_TYPES[type];
if (!config) throw new Error(`Unknown micro learning type: ${type}`);
// Delete existing if present
const existing = await findExisting(topicId, type);
if (existing) {
console.log(`[MicroLearning] Deleting stale record: ${existing.id}`);
await pb.collection('micro_learnings').delete(existing.id);
}
// Generate fresh
return getOrGenerateMicroLearning(topicId, type);
}
/**
* Delete all cached micro learnings for a topic (all types).
*/
export async function deleteAllForTopic(topicId) {
try {
const records = await pb.collection('micro_learnings').getFullList({
filter: `topic_id = "${topicId}"`,
});
for (const record of records) {
await pb.collection('micro_learnings').delete(record.id);
}
console.log(`[MicroLearning] Deleted ${records.length} records for topic ${topicId}`);
return records.length;
} catch (err) {
console.error('[MicroLearning] Error deleting records:', err);
return 0;
}
}
// ── Internal helpers ──────────────────────────────────────────────────────────
async function findExisting(topicId, type) {
try {
const records = await pb.collection('micro_learnings').getFullList({
filter: `topic_id = "${topicId}" && type = "${type}" && status = "published"`,
});
return records.length > 0 ? records[0] : null;
} catch {
return null;
}
}
async function loadTopic(topicId) {
try {
const topics = await db.getTopics();
return topics.find(t => t.id === topicId) || null;
} catch {
return null;
}
}
async function generateContent(topic, type, config) {
const prompt = `Generate a ${type.replace('_', ' ')} micro learning for the following topic:
Label: ${topic.label}
Type: ${topic.type}
Description: ${topic.description}
${config.instructions}`;
const result = await callLLM({
task: `micro_learning.${type}`,
tier: config.tier,
system: cachedSystem(SYSTEM_PROMPT),
user: prompt,
tools: [config.tool],
toolChoice: { type: 'tool', name: config.tool.name },
maxTokens: config.maxTokens,
});
const content = result.toolUses[0]?.input;
if (!content) {
throw new Error(`AI did not return content for ${type}. Please try again.`);
}
return content;
}