import * as db from './db'; import { callLLM, cachedSystem } from './llm'; import { buildThemeTopicMap } from './curriculumService'; import { EMIT_ONBOARDING_OVERVIEW_TOOL } from './llmTools'; // The onboarding track introduces a new employee to every KB theme in breadth, // spread over a guideline of 5 "days". A theme is the unit we track completion // on; "days" are only a presentation grouping computed at read time, so // re-chunking (e.g. when the theme set changes) never loses a user's progress. export const ONBOARDING_DAY_COUNT = 5; // ── Pure helpers (unit-tested) ─────────────────────────────────────────────── /** * Order theme names by their first appearance in the active curriculum schedule * (a pedagogical order), then append any KB themes the schedule never names, * alphabetically. Schedule labels that aren't real KB themes (e.g. merged-week * labels) are ignored. De-duplicated. * * @param {string[]} themeNames — canonical theme names present in the KB * @param {Array<{theme?: string}>|null} schedule — active curriculum schedule * @returns {string[]} */ export function orderThemes(themeNames, schedule) { const known = new Set(themeNames); const ordered = []; const seen = new Set(); if (Array.isArray(schedule)) { for (const week of schedule) { const theme = week && week.theme; if (theme && known.has(theme) && !seen.has(theme)) { seen.add(theme); ordered.push(theme); } } } const remaining = themeNames .filter((t) => !seen.has(t)) .sort((a, b) => a.localeCompare(b)); return [...ordered, ...remaining]; } /** * Split an ordered theme list into balanced, order-preserving day buckets. * Uses at most `dayCount` days; with fewer themes than days it uses `N` days. * The first `remainder` days get one extra theme. * * @param {string[]} orderedThemes * @param {number} [dayCount=5] * @returns {Array<{ day: number, themes: string[] }>} non-empty days only */ export function distributeThemesIntoDays(orderedThemes, dayCount = ONBOARDING_DAY_COUNT) { const themes = Array.isArray(orderedThemes) ? orderedThemes : []; const n = themes.length; if (n === 0) return []; const effectiveDays = Math.min(dayCount, n); const base = Math.floor(n / effectiveDays); const remainder = n % effectiveDays; const days = []; let idx = 0; for (let d = 0; d < effectiveDays; d++) { const size = base + (d < remainder ? 1 : 0); days.push({ day: d + 1, themes: themes.slice(idx, idx + size) }); idx += size; } return days; } /** * Stable, order-independent fingerprint over a theme's topic ids. Changes when * a topic is added to or removed from the theme, so cached overviews can be * detected as stale and regenerated. * * @param {Array<{id?: string}>} topics * @returns {string} */ export function computeTopicsFingerprint(topics) { const ids = (Array.isArray(topics) ? topics : []) .map((t) => t && t.id) .filter(Boolean) .sort(); const joined = ids.join(','); let h = 5381; for (let i = 0; i < joined.length; i++) { h = ((h << 5) + h + joined.charCodeAt(i)) >>> 0; // djb2, unsigned } return `${ids.length}:${h.toString(16)}`; } /** * Derive progress from the plan's days and the set of completed themes. * * @param {Array<{ day: number, themes: string[] }>} days * @param {Set|string[]} completedThemeSet * @returns {{ themesTotal:number, themesDone:number, dayCount:number, daysCompleted:number, percentage:number, allDone:boolean }} */ export function computeOnboardingProgress(days, completedThemeSet) { const set = completedThemeSet instanceof Set ? completedThemeSet : new Set(completedThemeSet || []); const allThemes = (days || []).flatMap((d) => d.themes); const themesTotal = allThemes.length; const themesDone = allThemes.filter((t) => set.has(t)).length; const dayCount = (days || []).length; const daysCompleted = (days || []).filter( (d) => d.themes.length > 0 && d.themes.every((t) => set.has(t)), ).length; const percentage = themesTotal === 0 ? 0 : Math.round((themesDone / themesTotal) * 100); const allDone = themesTotal > 0 && themesDone === themesTotal; return { themesTotal, themesDone, dayCount, daysCompleted, percentage, allDone }; } /** * Build the onboarding plan from already-loaded topics + active curriculum version. * * @param {Array} topics — all topics (db.getTopics()) * @param {object|null} activeVersion — active curriculum_versions row (for schedule order) * @returns {{ themes: string[], days: Array<{day:number,themes:string[]}>, themeTopicMap: Map }} */ export function buildOnboardingPlan(topics, activeVersion) { const themeTopicMap = buildThemeTopicMap(topics || []); let schedule = activeVersion && activeVersion.schedule ? activeVersion.schedule : null; if (typeof schedule === 'string') { try { schedule = JSON.parse(schedule); } catch { schedule = null; } } const themes = orderThemes([...themeTopicMap.keys()], schedule); const days = distributeThemesIntoDays(themes); return { themes, days, themeTopicMap }; } // ── I/O + generation ───────────────────────────────────────────────────────── const ONBOARDING_OVERVIEW_SYSTEM = `You are an onboarding guide for Respellion, an internal IT company. You introduce a brand-new employee to ONE theme from the company knowledge base. This is a breadth-first introduction, not a deep lesson: keep it short, plain and skimmable, and always connect the theme to how work actually happens day-to-day and week-to-week at Respellion. Write in clear, professional English. Emit content only through the emit_onboarding_overview tool.`; function buildOverviewPrompt(theme, topics) { const topicLines = topics .map((t, idx) => `${idx + 1}. id=${t.id} | label="${t.label}" | type=${t.type || '—'} | description=${t.description || '—'}`) .join('\n'); return `Theme: ${theme} Topics that make up this theme (reuse the exact topic_id for each entry in topics_covered): ${topicLines || '(no topics listed)'} Write a short, breadth-first onboarding overview of this theme for a new employee's first week: what it is, why it matters for their daily and weekly work at Respellion, 3–5 key points, and the list of topics it covers.`; } /** * Load everything needed to render the track. * @returns {Promise<{themes:string[], days:Array, themeTopicMap:Map}>} */ export async function getOnboardingPlan() { const [topics, activeVersion] = await Promise.all([ db.getTopics(), db.getActiveCurriculumVersion(), ]); return buildOnboardingPlan(topics, activeVersion); } /** * Get the cached onboarding overview for a theme, or generate + cache it. * Regenerates when the theme's topic set has changed (fingerprint mismatch). * * @param {string} theme * @param {Array} topicsForTheme — topics belonging to the theme * @param {object} [opts] * @param {boolean} [opts.force=false] */ export async function getOrGenerateOnboardingOverview(theme, topicsForTheme, { force = false } = {}) { if (!theme) throw new Error('Onboarding overview requires a theme.'); const topics = Array.isArray(topicsForTheme) ? topicsForTheme : []; const fingerprint = computeTopicsFingerprint(topics); if (!force) { const cached = await db.getOnboardingOverview(theme); if (cached && cached.topics_fingerprint === fingerprint) return cached; } const result = await callLLM({ task: 'onboarding.overview', tier: 'fast', system: cachedSystem(ONBOARDING_OVERVIEW_SYSTEM), user: buildOverviewPrompt(theme, topics), tools: [EMIT_ONBOARDING_OVERVIEW_TOOL], toolChoice: { type: 'tool', name: EMIT_ONBOARDING_OVERVIEW_TOOL.name }, maxTokens: 1500, timeoutMs: 60_000, }); const emitted = result.toolUses[0]?.input; if (!emitted) throw new Error('AI did not return an onboarding overview. Please try again.'); return db.setOnboardingOverview(theme, emitted, fingerprint); } /** * Set of theme names the user has marked complete. * @param {string} userId * @returns {Promise>} */ export async function getCompletedThemes(userId) { const rows = await db.getCompletedOnboardingThemes(userId); return new Set(rows.map((r) => r.theme)); } /** * Mark a theme complete for the user. Idempotent. */ export async function markThemeCompleted(userId, theme) { return db.setOnboardingCompletion(userId, theme); } /** * Progress summary for the Dashboard chip. * @param {string} userId */ export async function getOnboardingSummary(userId) { const [plan, completed] = await Promise.all([ getOnboardingPlan(), getCompletedThemes(userId), ]); return computeOnboardingProgress(plan.days, completed); }