feat: implement AI-driven learning content generation service and interactive student dashboard
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user