feat: implement AI-driven learning content generation service and interactive student dashboard
All checks were successful
On Push to Main / test (push) Successful in 30s
On Push to Main / publish (push) Successful in 57s
On Push to Main / deploy-dev (push) Successful in 1m32s

This commit is contained in:
RaymondVerhoef
2026-05-17 17:10:40 +02:00
parent 98e32d8ac0
commit 5b37c04588
5 changed files with 109 additions and 30 deletions

View File

@@ -34,7 +34,7 @@ const ContentManager = () => {
const handleRegenerate = async (topic) => { const handleRegenerate = async (topic) => {
setTopicState(topic.id, { loading: 'regenerating', error: null, success: null }); setTopicState(topic.id, { loading: 'regenerating', error: null, success: null });
try { try {
await generateLearningContent(topic, true); await generateLearningContent(topic, true, 'all');
setTopicState(topic.id, { loading: null, success: 'Content regenerated.' }); setTopicState(topic.id, { loading: null, success: 'Content regenerated.' });
refresh(); refresh();
} catch (e) { } catch (e) {

View File

@@ -20,8 +20,17 @@ const MODES = [
{ key: 'infographic', icon: BarChart2, label: 'Infographic' }, { key: 'infographic', icon: BarChart2, label: 'Infographic' },
]; ];
const LearningContentViewer = ({ content, topic, initialMode = 'article' }) => { const LearningContentViewer = ({ content, topic, initialMode = 'article', onGenerate, isLoading }) => {
const [activeMode, setActiveMode] = useState(initialMode); const populatedModes = MODES.filter(m => m.key === 'podcast' ? !!content?.podcastScript : !!content?.[m.key]);
const defaultMode = populatedModes.length > 0 ? populatedModes[0].key : initialMode;
const [activeMode, setActiveMode] = useState(defaultMode);
// If content populates while we are on an empty tab, keep the tab active
useEffect(() => {
if (content && populatedModes.length === 1 && !populatedModes.find(m => m.key === activeMode)) {
setActiveMode(populatedModes[0].key);
}
}, [content]);
if (!content || !topic) return null; if (!content || !topic) return null;
@@ -54,10 +63,26 @@ const LearningContentViewer = ({ content, topic, initialMode = 'article' }) => {
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
{activeMode === 'article' && <ArticleView content={content.article} />} {(() => {
{activeMode === 'slides' && <SlidesView slides={content.slides} />} const isPopulated = activeMode === 'podcast' ? !!content?.podcastScript : !!content?.[activeMode];
{activeMode === 'podcast' && <PodcastView script={content.podcastScript} topicLabel={topic.label} />} if (!isPopulated) {
{activeMode === 'infographic' && <InfographicView data={content.infographic} topicLabel={topic.label} />} return (
<Card className="border border-bg-warm text-center py-16">
<p className="text-fg-muted mb-6">This content has not been generated yet.</p>
{onGenerate && (
<Button onClick={() => onGenerate(activeMode)} disabled={isLoading}>
Generate {MODES.find(m => m.key === activeMode)?.label}
</Button>
)}
</Card>
);
}
if (activeMode === 'article') return <ArticleView content={content.article} />;
if (activeMode === 'slides') return <SlidesView slides={content.slides} />;
if (activeMode === 'podcast') return <PodcastView script={content.podcastScript} topicLabel={topic.label} />;
if (activeMode === 'infographic') return <InfographicView data={content.infographic} topicLabel={topic.label} />;
return null;
})()}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>

View File

@@ -6,7 +6,7 @@ You write training material for employees based on knowledge topics.
Always write in clear, professional English. Always write in clear, professional English.
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`; ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
const CONTENT_SCHEMA = `{ const CONTENT_SCHEMA_ARTICLE = `{
"article": { "article": {
"title": "Article title", "title": "Article title",
"intro": "Short intro of 1-2 sentences", "intro": "Short intro of 1-2 sentences",
@@ -14,11 +14,20 @@ const CONTENT_SCHEMA = `{
{ "heading": "Section title", "body": "Section text of at least 3 sentences." } { "heading": "Section title", "body": "Section text of at least 3 sentences." }
], ],
"keyTakeaways": ["Takeaway 1", "Takeaway 2", "Takeaway 3"] "keyTakeaways": ["Takeaway 1", "Takeaway 2", "Takeaway 3"]
}, }
}`;
const CONTENT_SCHEMA_SLIDES = `{
"slides": [ "slides": [
{ "title": "Slide title", "bullets": ["Point 1", "Point 2", "Point 3"], "speakerNote": "Speaker note for this slide." } { "title": "Slide title", "bullets": ["Point 1", "Point 2", "Point 3"], "speakerNote": "Speaker note for this slide." }
], ]
"podcastScript": "A natural spoken script of approx. 300 words summarizing the topic as a podcast episode.", }`;
const CONTENT_SCHEMA_PODCAST = `{
"podcastScript": "A natural spoken script of approx. 300 words summarizing the topic as a podcast episode."
}`;
const CONTENT_SCHEMA_INFOGRAPHIC = `{
"infographic": { "infographic": {
"headline": "A short, punchy headline summarizing the topic (max 8 words)", "headline": "A short, punchy headline summarizing the topic (max 8 words)",
"tagline": "A subtitle of max 15 words", "tagline": "A subtitle of max 15 words",
@@ -33,6 +42,13 @@ const CONTENT_SCHEMA = `{
} }
}`; }`;
const CONTENT_SCHEMA_ALL = `{
"article": ${CONTENT_SCHEMA_ARTICLE.replace(/^\{|\}$/g, '').trim()},
"slides": ${CONTENT_SCHEMA_SLIDES.replace(/^\{|\}$/g, '').trim()},
"podcastScript": "A natural spoken script of approx. 300 words summarizing the topic as a podcast episode.",
"infographic": ${CONTENT_SCHEMA_INFOGRAPHIC.replace(/^\{|\}$/g, '').trim()}
}`;
export async function getAssignedTopic(userId, weekNumber) { export async function getAssignedTopic(userId, weekNumber) {
const topics = await db.getTopics(); const topics = await db.getTopics();
if (!topics || topics.length === 0) return null; if (!topics || topics.length === 0) return null;
@@ -62,38 +78,64 @@ export async function getAllGeneratedContent() {
return results.filter(item => item.hasContent); return results.filter(item => item.hasContent);
} }
export async function generateLearningContent(topic, force = false) { export async function generateLearningContent(topic, force = false, selectedType = 'article') {
let cached = null;
if (!force) { if (!force) {
const cached = await db.getContent(topic.id); cached = await db.getContent(topic.id);
if (cached) { if (cached) {
console.log(`[Learn] Cache hit for topic: ${topic.id}`); if (selectedType === 'podcast' && cached.podcastScript) {
return cached; console.log(`[Learn] Cache hit for topic: ${topic.id} (podcast)`);
return cached;
} else if (selectedType !== 'podcast' && cached[selectedType]) {
console.log(`[Learn] Cache hit for topic: ${topic.id} (${selectedType})`);
return cached;
}
} }
} }
const prompt = `Generate a complete learning module for the following topic: let schema = '';
let instructions = '';
if (selectedType === 'all') {
schema = CONTENT_SCHEMA_ALL;
instructions = 'Provide at least 3 article sections, 4 slides, 3 stats, and 3-5 steps in the infographic.';
} else if (selectedType === 'article') {
schema = CONTENT_SCHEMA_ARTICLE;
instructions = 'Provide at least 3 article sections.';
} else if (selectedType === 'slides') {
schema = CONTENT_SCHEMA_SLIDES;
instructions = 'Provide at least 4 slides.';
} else if (selectedType === 'podcast') {
schema = CONTENT_SCHEMA_PODCAST;
instructions = 'Provide a natural spoken script.';
} else if (selectedType === 'infographic') {
schema = CONTENT_SCHEMA_INFOGRAPHIC;
instructions = 'Provide at least 3 stats, and 3-5 steps in the infographic.';
}
const prompt = `Generate a learning module piece for the following topic:
Label: ${topic.label} Label: ${topic.label}
Type: ${topic.type} Type: ${topic.type}
Description: ${topic.description} Description: ${topic.description}
Return ONLY a JSON object with the following structure: Return ONLY a JSON object with the following structure:
${CONTENT_SCHEMA} ${schema}
Provide at least 3 article sections, 4 slides, 3 stats, and 3-5 steps in the infographic.`; ${instructions}`;
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt); const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
let content; let newContent;
try { try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/); const jsonMatch = responseText.match(/\{[\s\S]*\}/);
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText); newContent = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
} catch (e) { } catch (e) {
throw new Error('AI could not generate valid learning content. Please try again.'); throw new Error('AI could not generate valid learning content. Please try again.');
} }
await db.setContent(topic.id, content); const mergedContent = { ...(cached || {}), ...newContent };
return content; await db.setContent(topic.id, mergedContent);
return mergedContent;
} }
export async function refineLearningContent(topic, refinementInstruction) { export async function refineLearningContent(topic, refinementInstruction) {

View File

@@ -68,12 +68,12 @@ const Leren = () => {
} }
}; };
const loadContent = async () => { const loadContent = async (selectedType = 'article') => {
if (!activeTopic) return; if (!activeTopic) return;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const generated = await generateLearningContent(activeTopic); const generated = await generateLearningContent(activeTopic, false, selectedType);
setContent(generated); setContent(generated);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
@@ -220,8 +220,13 @@ const Leren = () => {
{!content && !isLoading && ( {!content && !isLoading && (
<Card className="border border-bg-warm text-center py-16"> <Card className="border border-bg-warm text-center py-16">
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" /> <BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
<p className="text-fg-muted mb-6">Click the button to generate personalized AI learning content for this topic.</p> <p className="text-fg-muted mb-6">Choose how you want to learn this topic.</p>
<Button onClick={loadContent}>Generate Learning Content</Button> <div className="flex flex-wrap justify-center gap-4">
<Button onClick={() => loadContent('article')}>Article</Button>
<Button onClick={() => loadContent('slides')}>Slides</Button>
<Button onClick={() => loadContent('podcast')}>Podcast</Button>
<Button onClick={() => loadContent('infographic')}>Infographic</Button>
</div>
</Card> </Card>
)} )}
@@ -241,7 +246,7 @@ const Leren = () => {
</Card> </Card>
)} )}
{content && <LearningContentViewer content={content} topic={activeTopic} />} {content && <LearningContentViewer content={content} topic={activeTopic} onGenerate={loadContent} isLoading={isLoading} />}
{content && ( {content && (
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end"> <div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">

View File

@@ -6,7 +6,7 @@ const AppContext = createContext();
const initialState = { const initialState = {
currentUser: null, currentUser: null,
users: [], users: [],
weekNumber: 1, weekNumber: getWeekNumber(new Date()),
isLoading: true isLoading: true
}; };
@@ -16,7 +16,7 @@ function appReducer(state, action) {
return { return {
...state, ...state,
users: action.payload.users, users: action.payload.users,
weekNumber: action.payload.weekNumber, weekNumber: action.payload.weekNumber || state.weekNumber,
isLoading: false isLoading: false
}; };
case 'LOGIN': case 'LOGIN':
@@ -30,6 +30,13 @@ function appReducer(state, action) {
} }
} }
function getWeekNumber(d) {
d = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay()||7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1));
return Math.ceil(( ( (d - yearStart) / 86400000) + 1)/7);
}
export function AppProvider({ children }) { export function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState); const [state, dispatch] = useReducer(appReducer, initialState);
@@ -47,7 +54,7 @@ export function AppProvider({ children }) {
} }
} }
const storedWeek = Number(await db.getSetting('admin:current_week', 1)); const storedWeek = getWeekNumber(new Date());
const sessionUserId = sessionStorage.getItem('respellion_session'); const sessionUserId = sessionStorage.getItem('respellion_session');
if (sessionUserId) { if (sessionUserId) {