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; }