diff --git a/src/hooks/useMicroLearnings.js b/src/hooks/useMicroLearnings.js
index f0eccc9..c55dfd6 100644
--- a/src/hooks/useMicroLearnings.js
+++ b/src/hooks/useMicroLearnings.js
@@ -1,17 +1,20 @@
-import { pb } from '../lib/pb';
+import { getOrGenerateMicroLearning, regenerateMicroLearning } from '../lib/microLearningService';
export function useMicroLearnings() {
- const getMicroLearningsByTopic = async (topicId) => {
- try {
- const records = await pb.collection('micro_learnings').getFullList({
- filter: `topic_id = "${topicId}" && status = 'published'`,
- });
- return records;
- } catch (err) {
- console.error("Error fetching micro learnings:", err);
- return [];
- }
+ /**
+ * Get or generate a micro learning for the given topic and type.
+ * Returns a PocketBase record with .content ready to render.
+ */
+ const getOrGenerate = async (topicId, type) => {
+ return getOrGenerateMicroLearning(topicId, type);
};
- return { getMicroLearningsByTopic };
+ /**
+ * Force regeneration of a micro learning (deletes cached version first).
+ */
+ const regenerate = async (topicId, type) => {
+ return regenerateMicroLearning(topicId, type);
+ };
+
+ return { getOrGenerate, regenerate };
}
diff --git a/src/lib/llmTools.js b/src/lib/llmTools.js
index 1a972c9..7278f27 100644
--- a/src/lib/llmTools.js
+++ b/src/lib/llmTools.js
@@ -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
,
,
, 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'],
+ },
+};
diff --git a/src/lib/microLearningService.js b/src/lib/microLearningService.js
new file mode 100644
index 0000000..d76e522
--- /dev/null
+++ b/src/lib/microLearningService.js
@@ -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 (
,
,
, ).`,
+ },
+ 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;
+}