Feat: microlearning implementation
This commit is contained in:
@@ -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 3–4 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 (3–5 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 (2–3 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 5–10 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 (3–5 sentences). This is not a rubric — it illustrates depth.' },
|
||||
},
|
||||
required: ['prompt', 'model_answer'],
|
||||
},
|
||||
};
|
||||
|
||||
195
src/lib/microLearningService.js
Normal file
195
src/lib/microLearningService.js
Normal 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 3–4 answer options. Exactly one must be correct.
|
||||
Each option needs a detailed explanation (2–3 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 5–10 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 (3–5 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;
|
||||
}
|
||||
Reference in New Issue
Block a user