Feat: microlearning implementation
This commit is contained in:
@@ -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 10–30 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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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