feat: implement core knowledge graph UI components, extraction pipeline, and initial platform navigation pages

This commit is contained in:
RaymondVerhoef
2026-05-10 21:33:02 +02:00
parent a626042092
commit 31aacd68d5
14 changed files with 1634 additions and 480 deletions

View File

@@ -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

View File

@@ -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
View 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);
}