Files
learning-platform/src/lib/learningService.js
RaymondVerhoef 66e0c275da
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 31s
On Pull Request to Main / publish (pull_request) Successful in 1m1s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m31s
feat: phase 4 of AI pipeline hardening — quiz & content quality
- src/lib/random.js: Fisher–Yates shuffle/sample/pickInt; replace every
  biased .sort(() => 0.5 - Math.random()) site in testService.
- testService: debias correctIndex via prompt + runtime re-roll (up to 2x
  when one position holds >50%); quality gate rejecting <4 distinct
  options, banned filler ("all of the above" etc) and explanations
  shorter than 20 chars; dedup new questions against the existing bank
  via normalised question text.
- Quiz schema/tool/prompt require difficulty ('easy'|'medium'|'hard');
  db.getQuizBank defaults legacy records to 'medium' on read.
- learningService.generateCustomTopic: kebab-case slug ID from the
  polished label with collision suffixes; default learning_relevance
  'standard' when the model omits it.
- Tests for random helpers, dedup/quality-gate behaviour and the
  extended quiz schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:22:10 +02:00

202 lines
6.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as db from './db';
import { callLLM } from './llm';
import {
EMIT_LEARNING_ARTICLE_TOOL,
EMIT_LEARNING_SLIDES_TOOL,
EMIT_LEARNING_INFOGRAPHIC_TOOL,
EMIT_LEARNING_ALL_TOOL,
EMIT_CUSTOM_TOPIC_TOOL,
ARTICLE_PATCH_TOOLS,
} from './llmTools';
import { applyAndValidate } from './articlePatches';
import { getCurriculumTopic } from './curriculumService';
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.
Emit the requested content through the matching tool — do not return prose JSON.`;
const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
const TOOL_BY_TYPE = {
article: EMIT_LEARNING_ARTICLE_TOOL,
slides: EMIT_LEARNING_SLIDES_TOOL,
infographic: EMIT_LEARNING_INFOGRAPHIC_TOOL,
all: EMIT_LEARNING_ALL_TOOL,
};
const INSTRUCTIONS_BY_TYPE = {
article: 'Provide at least 3 article sections and at least 2 key takeaways.',
slides: 'Provide at least 4 slides.',
infographic: 'Provide at least 3 stats and 3 steps.',
all: 'Provide at least 3 article sections, 4 slides, 3 stats, and 3 steps in the infographic.',
};
/**
* Get the assigned topic for a given week.
* Curriculum-first: checks the curriculum collection for the current year.
* Falls back to hash-based assignment if no curriculum is configured.
*/
export async function getAssignedTopic(userId, weekNumber) {
try {
const { topic } = await getCurriculumTopic(weekNumber);
if (topic && topic.learning_relevance !== 'exclude') return topic;
} catch (e) {
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
}
const allTopics = await db.getTopics();
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
if (!topics || topics.length === 0) return null;
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 index = Math.abs(hash) % topics.length;
return topics[index];
}
export async function getCachedContent(topicId) {
return db.getContent(topicId);
}
export async function getAllGeneratedContent() {
const topics = await db.getTopics();
const results = await Promise.all(
topics.map(async topic => {
const content = await db.getContent(topic.id);
return { topic, content, hasContent: !!content };
})
);
return results.filter(item => item.hasContent);
}
export async function generateLearningContent(topic, force = false, selectedType = 'article') {
let cached = null;
if (!force) {
cached = await db.getContent(topic.id);
if (cached && cached[selectedType]) {
console.log(`[Learn] Cache hit for topic: ${topic.id} (${selectedType})`);
return cached;
}
}
const tool = TOOL_BY_TYPE[selectedType];
if (!tool) throw new Error(`Unknown learning content type: ${selectedType}`);
const instructions = INSTRUCTIONS_BY_TYPE[selectedType];
const prompt = `Generate a learning module piece for the following topic:
Label: ${topic.label}
Type: ${topic.type}
Description: ${topic.description}
${instructions}`;
const result = await callLLM({
task: `learning.${selectedType}`,
tier: 'standard',
system: cachedSystem(CONTENT_GENERATION_SYSTEM),
user: prompt,
tools: [tool],
toolChoice: { type: 'tool', name: tool.name },
maxTokens: 8192,
});
const newContent = result.toolUses[0]?.input;
if (!newContent) throw new Error('AI did not return learning content. Please try again.');
const mergedContent = { ...(cached || {}), ...newContent };
await db.setContent(topic.id, mergedContent);
return mergedContent;
}
export async function refineLearningContent(topic, refinementInstruction) {
const existing = await db.getContent(topic.id);
if (!existing?.article) {
throw new Error('Refinement is currently only supported for the article. Generate an article for this topic first.');
}
const prompt = `You have previously generated the following article for the topic "${topic.label}":
${JSON.stringify(existing.article, null, 2)}
The admin has requested the following refinement:
"${refinementInstruction}"
Apply the refinement by calling one or more of the available patch tools. Make the smallest set of changes that satisfies the instruction — do not rewrite untouched sections.`;
const result = await callLLM({
task: 'learning.refine',
tier: 'standard',
system: cachedSystem(CONTENT_GENERATION_SYSTEM),
user: prompt,
tools: ARTICLE_PATCH_TOOLS,
toolChoice: { type: 'any' },
maxTokens: 4096,
});
if (!result.toolUses.length) {
throw new Error('AI did not propose any changes for that instruction. Try a more specific request.');
}
const patchedArticle = applyAndValidate(existing.article, result.toolUses);
const merged = { ...existing, article: patchedArticle };
await db.setContent(topic.id, merged);
return merged;
}
export async function deleteCachedContent(topicId) {
return db.deleteContent(topicId);
}
function slugify(label) {
const base = String(label || '')
.toLowerCase()
.normalize('NFKD')
.replace(/\p{Diacritic}/gu, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return base || 'topic';
}
async function pickUniqueTopicId(label) {
const existing = await db.getTopics();
const used = new Set(existing.map((t) => t.id));
const base = slugify(label);
if (!used.has(base)) return base;
for (let i = 2; i < 1000; i++) {
const candidate = `${base}-${i}`;
if (!used.has(candidate)) return candidate;
}
return `${base}-${Date.now().toString(36)}`;
}
export async function generateCustomTopic(label) {
const result = await callLLM({
task: 'topic.custom',
tier: 'standard',
system: cachedSystem('You are a knowledge graph AI categorising user-requested topics for the Respellion learning platform.'),
user: `A user wants to learn about "${label}". Provide a polished label, type, and 23 sentence description via the emit_custom_topic tool.`,
tools: [EMIT_CUSTOM_TOPIC_TOOL],
toolChoice: { type: 'tool', name: EMIT_CUSTOM_TOPIC_TOOL.name },
maxTokens: 1024,
});
const emitted = result.toolUses[0]?.input;
if (!emitted) throw new Error('Could not process custom topic. Please try again.');
const id = await pickUniqueTopicId(emitted.label);
const newTopic = {
...emitted,
id,
learning_relevance: emitted.learning_relevance || 'standard',
};
await db.upsertTopic(newTopic);
return newTopic;
}