- 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>
202 lines
6.6 KiB
JavaScript
202 lines
6.6 KiB
JavaScript
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 2–3 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;
|
||
}
|