From 042dfb2d92cd09ed2dcd31cb606b1db0d758e4ed Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Mon, 25 May 2026 18:32:45 +0200 Subject: [PATCH] Feat: microlearning implementation --- .../micro_learning/MicroLearningSelector.jsx | 134 +++++++----- src/hooks/useMicroLearnings.js | 27 +-- src/lib/llmTools.js | 89 ++++++++ src/lib/microLearningService.js | 195 ++++++++++++++++++ 4 files changed, 386 insertions(+), 59 deletions(-) create mode 100644 src/lib/microLearningService.js diff --git a/src/components/micro_learning/MicroLearningSelector.jsx b/src/components/micro_learning/MicroLearningSelector.jsx index 4faf36c..55821d7 100644 --- a/src/components/micro_learning/MicroLearningSelector.jsx +++ b/src/components/micro_learning/MicroLearningSelector.jsx @@ -1,83 +1,123 @@ -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
Loading learning formats...
; - - if (availableMLs.length === 0) { - return
No micro learnings available for this topic yet.
; + // Loading state while AI generates + if (loading) { + return ( + + +

AI is generating your learning module…

+

This may take 10–30 seconds.

+
+ ); } + // Error state + if (error) { + return ( + +

Generation failed

+

{error}

+ +
+ ); + } + + // Render selected micro learning if (selectedML) { return (
- -
); } + // Type selection menu — always shows all 4 types return (

Choose a Learning Format

+

Select how you want to engage with this topic.

- {availableMLs.map((ml) => ( -
handleSelection(ml)} + {TYPES.map(({ key, label, description, icon: Icon }) => ( +
handleSelection(key)} > -

{TYPE_LABELS[ml.type] || ml.type}

-

{TYPE_DESCRIPTIONS[ml.type]}

+
+
+ +
+

{label}

+
+

{description}

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