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

@@ -1,51 +1,84 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { Loader, BookOpen, Target, Layers, MessageCircle } from 'lucide-react';
import { useMicroLearnings } from '../../hooks/useMicroLearnings';
import MicroLearningContainer from './MicroLearningContainer';
import Button from '../ui/Button';
import Card from '../ui/Card';
const TYPE_LABELS = {
'concept_explainer': 'Concept Explainer',
'scenario_quiz': 'Scenario Quiz',
'flashcard_set': 'Flashcard Set',
'reflection_prompt': 'Reflection Prompt'
};
const TYPE_DESCRIPTIONS = {
'concept_explainer': 'Read a structured explanation to understand the concept.',
'scenario_quiz': 'Apply your knowledge in a realistic workplace scenario.',
'flashcard_set': 'Test your recall with a set of quick flashcards.',
'reflection_prompt': 'Connect the topic to your own professional experience.'
};
const TYPES = [
{
key: 'concept_explainer',
label: 'Concept Explainer',
description: 'Read a structured explanation to understand the concept.',
icon: BookOpen,
},
{
key: 'scenario_quiz',
label: 'Scenario Quiz',
description: 'Apply your knowledge in a realistic workplace scenario.',
icon: Target,
},
{
key: 'flashcard_set',
label: 'Flashcard Set',
description: 'Test your recall with a set of quick flashcards.',
icon: Layers,
},
{
key: 'reflection_prompt',
label: 'Reflection Prompt',
description: 'Connect the topic to your own professional experience.',
icon: MessageCircle,
},
];
export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCompleted }) {
const { getMicroLearningsByTopic } = useMicroLearnings();
const [availableMLs, setAvailableMLs] = useState([]);
const { getOrGenerate } = useMicroLearnings();
const [selectedML, setSelectedML] = useState(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchMLs = async () => {
setLoading(true);
const data = await getMicroLearningsByTopic(topicId);
setAvailableMLs(data);
const handleSelection = async (type) => {
setLoading(true);
setError(null);
try {
const record = await getOrGenerate(topicId, type);
setSelectedML(record);
} catch (err) {
console.error('[MicroLearningSelector] Generation failed:', err);
setError(err.message || 'Failed to generate content. Please try again.');
} finally {
setLoading(false);
};
if (topicId) {
fetchMLs();
}
}, [topicId]);
const handleSelection = (ml) => {
setSelectedML(ml);
};
if (loading) return <div className="text-slate-500 text-center py-8">Loading learning formats...</div>;
if (availableMLs.length === 0) {
return <div className="text-slate-500 text-center py-8">No micro learnings available for this topic yet.</div>;
// Loading state while AI generates
if (loading) {
return (
<Card className="w-full text-center py-16">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<p className="font-medium text-lg">AI is generating your learning module</p>
<p className="text-fg-muted text-sm mt-2">This may take 1030 seconds.</p>
</Card>
);
}
// Error state
if (error) {
return (
<Card className="w-full border border-red-200 bg-red-50 text-red-900 p-6">
<p className="font-bold mb-1">Generation failed</p>
<p className="text-sm mb-4">{error}</p>
<button
onClick={() => setError(null)}
className="text-sm font-medium text-red-700 hover:text-red-900 underline"
>
Back to selection
</button>
</Card>
);
}
// Render selected micro learning
if (selectedML) {
return (
<div className="space-y-4">
@@ -64,20 +97,27 @@ export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCom
);
}
// Type selection menu — always shows all 4 types
return (
<Card className="w-full">
<div className="mb-6 pb-4 border-b border-bg-warm">
<h2 className="text-xl font-bold">Choose a Learning Format</h2>
<p className="text-sm text-fg-muted mt-1">Select how you want to engage with this topic.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{availableMLs.map((ml) => (
{TYPES.map(({ key, label, description, icon: Icon }) => (
<div
key={ml.id}
className="cursor-pointer border-2 border-bg-warm rounded-[var(--r-md)] p-6 hover:border-teal hover:bg-teal/5 transition-all"
onClick={() => handleSelection(ml)}
key={key}
className="cursor-pointer border-2 border-bg-warm rounded-[var(--r-md)] p-6 hover:border-teal hover:bg-teal/5 transition-all group"
onClick={() => handleSelection(key)}
>
<h3 className="font-bold text-lg mb-2 text-teal">{TYPE_LABELS[ml.type] || ml.type}</h3>
<p className="text-sm text-fg-muted">{TYPE_DESCRIPTIONS[ml.type]}</p>
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-teal/10 flex items-center justify-center flex-shrink-0 group-hover:bg-teal/20 transition-colors">
<Icon size={20} className="text-teal" />
</div>
<h3 className="font-bold text-lg text-teal">{label}</h3>
</div>
<p className="text-sm text-fg-muted">{description}</p>
</div>
))}
</div>

View File

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

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