222 lines
7.2 KiB
JavaScript
222 lines
7.2 KiB
JavaScript
import * as db from './db';
|
||
import { callLLM, cachedSystem } 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 { getCurrentWeekContent } 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 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 primary topic for a given week.
|
||
* Curriculum v2: checks the active curriculum version for the given ISO week.
|
||
* Falls back to hash-based assignment if no curriculum is configured.
|
||
*/
|
||
export async function getAssignedTopic(userId, isoWeekNumber) {
|
||
try {
|
||
const weekContent = await getCurrentWeekContent(isoWeekNumber);
|
||
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
|
||
// For single-topic compatibility, return the first topic
|
||
return weekContent.topics[0];
|
||
}
|
||
} catch (e) {
|
||
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
|
||
}
|
||
|
||
// Fallback hash-based assignment
|
||
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}:${isoWeekNumber}`;
|
||
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];
|
||
}
|
||
|
||
/**
|
||
* Get all assigned topics for a given week.
|
||
*/
|
||
export async function getAssignedTopics(userId, isoWeekNumber) {
|
||
try {
|
||
const weekContent = await getCurrentWeekContent(isoWeekNumber);
|
||
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
|
||
return weekContent.topics;
|
||
}
|
||
} catch (e) {
|
||
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
|
||
}
|
||
|
||
// Fallback hash-based assignment
|
||
const topic = await getAssignedTopic(userId, isoWeekNumber);
|
||
return topic ? [topic] : [];
|
||
}
|
||
|
||
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;
|
||
}
|