feat: implement core knowledge graph UI components, extraction pipeline, and initial platform navigation pages
This commit is contained in:
@@ -1,28 +1,28 @@
|
||||
import { anthropicApi } from './api';
|
||||
import { storage } from './storage';
|
||||
|
||||
const SYSTEM_PROMPT = `Je bent een AI-kennisextractor voor Respellion, een IT-bedrijf gericht op "radicale transparantie".
|
||||
Je krijgt een brontekst. Jouw taak is om kernconcepten, rollen, processen en feiten te extraheren en deze als een gestructureerde JSON Kennisgraaf terug te geven.
|
||||
const SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency.
|
||||
You receive a source text. Your task is to extract core concepts, roles, processes, and facts, and return them as a structured JSON Knowledge Graph.
|
||||
|
||||
Geef ALTIJD een valide JSON object terug in het volgende formaat:
|
||||
ALWAYS return a valid JSON object in the following format:
|
||||
{
|
||||
"topics": [
|
||||
{
|
||||
"id": "unieke-slug",
|
||||
"label": "Titel van onderwerp",
|
||||
"id": "unique-slug",
|
||||
"label": "Topic title",
|
||||
"type": "concept | role | process | fact",
|
||||
"description": "Een beknopte, heldere uitleg van max 3 zinnen."
|
||||
"description": "A concise, clear explanation of max 3 sentences."
|
||||
}
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"source": "id-van-topic-1",
|
||||
"target": "id-van-topic-2",
|
||||
"source": "topic-id-1",
|
||||
"target": "topic-id-2",
|
||||
"type": "related_to | depends_on | part_of | executed_by"
|
||||
}
|
||||
]
|
||||
}
|
||||
Zorg dat je alleen JSON teruggeeft, geen markdown blokken of andere tekst.`;
|
||||
Return JSON only. No markdown blocks or other text.`;
|
||||
|
||||
/**
|
||||
* Voert tekst door de Anthropic API en slaat de resulterende topics op.
|
||||
@@ -44,7 +44,7 @@ export async function processSourceText(textContent, sourceName) {
|
||||
|
||||
try {
|
||||
// 2. Roep Anthropic API aan
|
||||
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyseer de volgende tekst:\n\n${textContent}`);
|
||||
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyze the following text:\n\n${textContent}`);
|
||||
|
||||
// 3. Parse JSON (veilig)
|
||||
let extractedData;
|
||||
@@ -54,7 +54,7 @@ export async function processSourceText(textContent, sourceName) {
|
||||
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
|
||||
extractedData = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
throw new Error('AI response was geen geldige JSON.');
|
||||
throw new Error('AI response was not valid JSON.');
|
||||
}
|
||||
|
||||
// 4. Deduplicatie en opslag van Topics en Relaties
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import { anthropicApi } from './api';
|
||||
import { storage } from './storage';
|
||||
|
||||
const CONTENT_GENERATION_SYSTEM = `Je bent een expert leerinhoud-schrijver voor Respellion, een intern IT-bedrijf.
|
||||
Je schrijft leermateriaal voor medewerkers op basis van kennisonderwerpen.
|
||||
Schrijf altijd in het Nederlands, helder en professioneel.
|
||||
Geef ALTIJD geldige JSON terug, zonder markdown code-blokken.`;
|
||||
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
|
||||
You write training material for employees based on knowledge topics.
|
||||
Always write in clear, professional English.
|
||||
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
|
||||
|
||||
const CONTENT_SCHEMA = `{
|
||||
"article": {
|
||||
"title": "Article title",
|
||||
"intro": "Short intro of 1-2 sentences",
|
||||
"sections": [
|
||||
{ "heading": "Section title", "body": "Section text of at least 3 sentences." }
|
||||
],
|
||||
"keyTakeaways": ["Takeaway 1", "Takeaway 2", "Takeaway 3"]
|
||||
},
|
||||
"slides": [
|
||||
{ "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.",
|
||||
"infographic": {
|
||||
"headline": "A short, punchy headline summarizing the topic (max 8 words)",
|
||||
"tagline": "A subtitle of max 15 words",
|
||||
"stats": [
|
||||
{ "value": "Number or %", "label": "Short description", "icon": "📊" }
|
||||
],
|
||||
"steps": [
|
||||
{ "number": 1, "title": "Step title", "description": "One-sentence description.", "icon": "🔑" }
|
||||
],
|
||||
"quote": "An inspiring or insightful quote about the topic.",
|
||||
"colorTheme": "teal"
|
||||
}
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Get the assigned topic for a user for a given week using round-robin.
|
||||
@@ -14,66 +41,69 @@ export function getAssignedTopic(userId, weekNumber) {
|
||||
const topics = storage.get('kb:topics', []);
|
||||
if (!topics || topics.length === 0) return null;
|
||||
|
||||
// Simple deterministic hash
|
||||
const str = `${userId}:${weekNumber}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0; // Convert to 32-bit integer
|
||||
hash |= 0;
|
||||
}
|
||||
const index = Math.abs(hash) % topics.length;
|
||||
return topics[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete learning module for a topic.
|
||||
* Returns an object with { article, slides, podcastScript, infographic }.
|
||||
* Caches results in storage.
|
||||
* Returns the cache key for a topic's content.
|
||||
*/
|
||||
export async function generateLearningContent(topic) {
|
||||
const cacheKey = `kb:content:${topic.id}`;
|
||||
const cached = storage.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`[Learn] Cache hit voor topic: ${topic.id}`);
|
||||
return cached;
|
||||
export function getContentCacheKey(topicId) {
|
||||
return `kb:content:${topicId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns cached content for a topic, or null if none exists.
|
||||
*/
|
||||
export function getCachedContent(topicId) {
|
||||
return storage.get(getContentCacheKey(topicId), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all topics that have generated content.
|
||||
*/
|
||||
export function getAllGeneratedContent() {
|
||||
const topics = storage.get('kb:topics', []);
|
||||
return topics
|
||||
.map(topic => ({
|
||||
topic,
|
||||
content: getCachedContent(topic.id),
|
||||
hasContent: !!getCachedContent(topic.id),
|
||||
}))
|
||||
.filter(item => item.hasContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete learning module for a topic.
|
||||
* Uses cached version if available (unless force = true).
|
||||
*/
|
||||
export async function generateLearningContent(topic, force = false) {
|
||||
const cacheKey = getContentCacheKey(topic.id);
|
||||
|
||||
if (!force) {
|
||||
const cached = storage.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`[Learn] Cache hit for topic: ${topic.id}`);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = `Genereer een compleet leermodule voor het volgende onderwerp:
|
||||
const prompt = `Generate a complete learning module for the following topic:
|
||||
|
||||
Label: ${topic.label}
|
||||
Type: ${topic.type}
|
||||
Beschrijving: ${topic.description}
|
||||
Description: ${topic.description}
|
||||
|
||||
Geef ALLEEN een JSON object terug met de volgende structuur:
|
||||
{
|
||||
"article": {
|
||||
"title": "Artikel titel",
|
||||
"intro": "Korte intro van 1-2 zinnen",
|
||||
"sections": [
|
||||
{ "heading": "Sectietitel", "body": "Sectietekst van minimaal 3 zinnen." }
|
||||
],
|
||||
"keyTakeaways": ["Lespunt 1", "Lespunt 2", "Lespunt 3"]
|
||||
},
|
||||
"slides": [
|
||||
{ "title": "Diatitel", "bullets": ["Punt 1", "Punt 2", "Punt 3"], "speakerNote": "Toelichting voor de spreker." }
|
||||
],
|
||||
"podcastScript": "Een vloeiend gesproken script van ca. 300 woorden dat de inhoud samenvat als een podcast.",
|
||||
"infographic": {
|
||||
"headline": "Een korte, krachtige zin die het onderwerp samenvat (max 8 woorden)",
|
||||
"tagline": "Een subkop van max 15 woorden",
|
||||
"stats": [
|
||||
{ "value": "Getal of %", "label": "Korte omschrijving", "icon": "📊" }
|
||||
],
|
||||
"steps": [
|
||||
{ "number": 1, "title": "Staptitel", "description": "Korte beschrijving van 1 zin.", "icon": "🔑" }
|
||||
],
|
||||
"quote": "Een inspirerende of kernachtige quote over het onderwerp.",
|
||||
"colorTheme": "teal"
|
||||
}
|
||||
}
|
||||
|
||||
Zorg voor minimaal 3 secties in het artikel, 4 slides, 3 statistieken en 3-5 stappen in de infographic.`;
|
||||
Return ONLY a JSON object with the following structure:
|
||||
${CONTENT_SCHEMA}
|
||||
|
||||
Provide at least 3 article sections, 4 slides, 3 stats, and 3-5 steps in the infographic.`;
|
||||
|
||||
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
|
||||
|
||||
@@ -82,10 +112,47 @@ Zorg voor minimaal 3 secties in het artikel, 4 slides, 3 statistieken en 3-5 sta
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||
} catch (e) {
|
||||
throw new Error('AI kon geen geldige leerinhoud genereren.');
|
||||
throw new Error('AI could not generate valid learning content. Please try again.');
|
||||
}
|
||||
|
||||
// Cache the content
|
||||
storage.set(cacheKey, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refine existing content for a topic using a natural language instruction.
|
||||
* Sends current content + refinement prompt to AI, saves new version.
|
||||
*/
|
||||
export async function refineLearningContent(topic, refinementInstruction) {
|
||||
const cacheKey = getContentCacheKey(topic.id);
|
||||
const existing = storage.get(cacheKey);
|
||||
|
||||
const prompt = `You have previously generated the following learning module for the topic "${topic.label}":
|
||||
|
||||
${JSON.stringify(existing, null, 2)}
|
||||
|
||||
The admin has requested the following refinement:
|
||||
"${refinementInstruction}"
|
||||
|
||||
Apply the refinement and return the complete updated JSON object using the same structure. Return ONLY valid JSON.`;
|
||||
|
||||
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
|
||||
|
||||
let content;
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||
} catch (e) {
|
||||
throw new Error('AI could not process the refinement. Please try a different instruction.');
|
||||
}
|
||||
|
||||
storage.set(cacheKey, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached content for a topic, forcing a fresh generation next time.
|
||||
*/
|
||||
export function deleteCachedContent(topicId) {
|
||||
storage.remove(getContentCacheKey(topicId));
|
||||
}
|
||||
|
||||
224
src/lib/testService.js
Normal file
224
src/lib/testService.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import { anthropicApi } from './api';
|
||||
import { storage } from './storage';
|
||||
|
||||
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
||||
You generate multiple-choice questions to test employee knowledge on specific topics.
|
||||
Always write in clear, professional English.
|
||||
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
|
||||
|
||||
/**
|
||||
* Select topics for the weekly test:
|
||||
* - 50% from the user's assigned learning topic this week
|
||||
* - 50% from random other topics (review)
|
||||
*/
|
||||
function selectTestTopics(userId, weekNumber) {
|
||||
const topics = storage.get('kb:topics', []);
|
||||
if (!topics || topics.length === 0) return [];
|
||||
|
||||
// Deterministic hash for the user's current topic
|
||||
const str = `${userId}:${weekNumber}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
const primaryIndex = Math.abs(hash) % topics.length;
|
||||
const primaryTopic = topics[primaryIndex];
|
||||
|
||||
// Pick up to 5 "review" topics (random, different from primary)
|
||||
const others = topics.filter((_, i) => i !== primaryIndex);
|
||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
||||
|
||||
return { primaryTopic, reviewTopics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached quiz, or null.
|
||||
*/
|
||||
export function getCachedQuiz(userId, weekNumber) {
|
||||
return storage.get(`quiz:${userId}:week:${weekNumber}`, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported helper for admin: manually trigger generation for a topic.
|
||||
*/
|
||||
export async function forceGenerateTopicQuestions(topic, count = 10) {
|
||||
const bankKey = `quiz:bank:${topic.id}`;
|
||||
let bank = storage.get(bankKey, []);
|
||||
|
||||
const prompt = `Generate exactly ${count} multiple-choice quiz questions based on this knowledge topic:
|
||||
|
||||
Topic: ${topic.label}
|
||||
Type: ${topic.type}
|
||||
Description: ${topic.description}
|
||||
|
||||
Return ONLY a JSON object with this structure:
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"id": "unique-id-string",
|
||||
"question": "The question text",
|
||||
"topicLabel": "${topic.label}",
|
||||
"options": ["A) First option", "B) Second option", "C) Third option", "D) Fourth option"],
|
||||
"correctIndex": 0,
|
||||
"explanation": "A clear 1-2 sentence explanation of why the correct answer is correct."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Each question must have exactly 4 options.
|
||||
- correctIndex is 0-based (0=A, 1=B, 2=C, 3=D).
|
||||
- Mix difficulty: 4 easy, 4 medium, 2 hard.
|
||||
- Make questions specific and practical, not trivial.`;
|
||||
|
||||
const responseText = await anthropicApi.generateContent(QUIZ_SYSTEM, prompt);
|
||||
let newQuestions = [];
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||
newQuestions = parsed.questions || [];
|
||||
newQuestions.forEach(q => {
|
||||
q.id = `${topic.id}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to generate questions for topic', topic.label, e);
|
||||
throw new Error(`Could not generate questions for ${topic.label}`);
|
||||
}
|
||||
|
||||
bank = [...bank, ...newQuestions];
|
||||
storage.set(bankKey, bank);
|
||||
return newQuestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a topic has enough questions in its bank, generating more if needed.
|
||||
* Returns exactly `count` questions.
|
||||
*/
|
||||
async function getOrGenerateTopicQuestions(topic, count) {
|
||||
const bankKey = `quiz:bank:${topic.id}`;
|
||||
let bank = storage.get(bankKey, []);
|
||||
|
||||
// If we don't have enough questions, ask AI to generate a batch of 10
|
||||
if (bank.length < count) {
|
||||
await forceGenerateTopicQuestions(topic, 10);
|
||||
bank = storage.get(bankKey, []); // reload
|
||||
}
|
||||
|
||||
// Shuffle and pick `count` questions
|
||||
const shuffled = [...bank].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, Math.min(count, shuffled.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin Helper: get question bank for a topic
|
||||
*/
|
||||
export function getTopicQuestionBank(topicId) {
|
||||
return storage.get(`quiz:bank:${topicId}`, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin Helper: delete a single question
|
||||
*/
|
||||
export function deleteQuestion(topicId, questionId) {
|
||||
const bankKey = `quiz:bank:${topicId}`;
|
||||
const bank = storage.get(bankKey, []);
|
||||
storage.set(bankKey, bank.filter(q => q.id !== questionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 10 MCQ questions for a user's weekly test.
|
||||
* Caches the result so the same quiz is served on retry.
|
||||
* Pulls from topic-specific question banks, generating more if banks are low.
|
||||
*/
|
||||
export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
||||
const cacheKey = `quiz:${userId}:week:${weekNumber}`;
|
||||
if (!force) {
|
||||
const cached = storage.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const { primaryTopic, reviewTopics } = selectTestTopics(userId, weekNumber);
|
||||
if (!primaryTopic) throw new Error('No topics available to generate a quiz.');
|
||||
|
||||
const questions = [];
|
||||
|
||||
// Get 5 questions for the primary topic
|
||||
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 5);
|
||||
questions.push(...primaryQs);
|
||||
|
||||
// Get 1 question for each of the up to 5 review topics
|
||||
for (const rt of reviewTopics) {
|
||||
const rtQs = await getOrGenerateTopicQuestions(rt, 1);
|
||||
questions.push(...rtQs);
|
||||
}
|
||||
|
||||
// If there are fewer than 10 questions (e.g. no review topics yet), pad with more from primary
|
||||
if (questions.length < 10) {
|
||||
const needed = 10 - questions.length;
|
||||
// We already took 5, so let's try to get enough extra unique questions
|
||||
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, needed + 5);
|
||||
const existingIds = new Set(questions.map(q => q.id));
|
||||
for (const eq of extraQs) {
|
||||
if (!existingIds.has(eq.id) && questions.length < 10) {
|
||||
questions.push(eq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle the final 10 questions
|
||||
questions.sort(() => 0.5 - Math.random());
|
||||
|
||||
const quiz = { questions };
|
||||
quiz.meta = {
|
||||
userId,
|
||||
weekNumber,
|
||||
generatedAt: new Date().toISOString(),
|
||||
primaryTopic: primaryTopic.label,
|
||||
};
|
||||
|
||||
storage.set(cacheKey, quiz);
|
||||
return quiz;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a completed test result.
|
||||
*/
|
||||
export function saveTestResult(userId, weekNumber, result) {
|
||||
const key = `quiz:result:${userId}:week:${weekNumber}`;
|
||||
storage.set(key, result);
|
||||
|
||||
// Update leaderboard points
|
||||
const leaderboard = storage.get('leaderboard:current', []);
|
||||
const entry = leaderboard.find(e => e.userId === userId);
|
||||
const pointsEarned = result.score * 2; // 2 pts per correct answer
|
||||
|
||||
if (entry) {
|
||||
entry.points += pointsEarned;
|
||||
entry.testsCompleted = (entry.testsCompleted || 0) + 1;
|
||||
} else {
|
||||
const users = storage.get('users:registry', []);
|
||||
const user = users.find(u => u.id === userId);
|
||||
leaderboard.push({
|
||||
userId,
|
||||
name: user?.name || 'Unknown',
|
||||
points: pointsEarned,
|
||||
testsCompleted: 1,
|
||||
learningsCompleted: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort desc
|
||||
leaderboard.sort((a, b) => b.points - a.points);
|
||||
storage.set('leaderboard:current', leaderboard);
|
||||
|
||||
return { pointsEarned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a previously completed test result, or null.
|
||||
*/
|
||||
export function getTestResult(userId, weekNumber) {
|
||||
return storage.get(`quiz:result:${userId}:week:${weekNumber}`, null);
|
||||
}
|
||||
Reference in New Issue
Block a user