feat: 5-day theme-level onboarding track from the "New here?" card (#30)
A self-paced onboarding track that introduces a new employee to every KB theme in breadth (not depth), so they grasp how Respellion works day to day and week to week. Offered as a CTA inside the Dashboard "New here?" explainer card; always available regardless of enrollment. Design: - Theme is the trackable unit; the 5 "days" are a read-time presentation grouping, so re-chunking never loses progress. Completion is stored per theme in onboarding_completions. - Per-theme overview generated lazily on first open (fast-tier emit_onboarding_overview tool), cached in onboarding_overviews keyed by theme + a topics_fingerprint that triggers regeneration when the theme's topic set changes. - Reachable via /onboarding-track using the existing skipEnrollmentGate prop, decoupled from the 26-week curriculum (distinct from /onboarding, the enrollment page). Backend: - pb_migrations/1781200000_created_onboarding.js: two collections with authenticated-only rules and unique indexes; TEXT team_member_id (no relation) per the post-#18/#27 convention. Mirrored in scripts/setup-pb-collections.mjs. - src/lib/onboardingService.js: pure helpers (orderThemes, distributeThemesIntoDays, computeTopicsFingerprint, computeOnboardingProgress, buildOnboardingPlan) + generation + I/O. - db.js onboarding helpers use pb.filter() bindings (theme is free text). - LLM tool + Zod schema + registry + simulation stub. Frontend: - src/pages/OnboardingTrack.jsx (day list, per-theme overview, completion banner, progress ring/day bar). - Dashboard "New here?" card CTA + X/5-days progress chip (hidden when the KB has no themes). Docs: data-model, generation-spec (§D), frontend-spec updated. Verified: 22 new unit tests (npm test 134/134), eslint clean on changed files, npm run build OK, PocketBase v0.30.4 boot applies the migration (collections + unique indexes + authed rules confirmed), and a backend contract check (upsert idempotency, unique-index guard, special-char theme filtering). Closes #30 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
152
src/lib/__tests__/onboardingService.test.js
Normal file
152
src/lib/__tests__/onboardingService.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ONBOARDING_DAY_COUNT,
|
||||
orderThemes,
|
||||
distributeThemesIntoDays,
|
||||
computeTopicsFingerprint,
|
||||
computeOnboardingProgress,
|
||||
buildOnboardingPlan,
|
||||
} from '../onboardingService';
|
||||
|
||||
const sizes = (days) => days.map((d) => d.themes.length);
|
||||
|
||||
describe('onboardingService pure helpers', () => {
|
||||
it('exposes a 5-day guideline', () => {
|
||||
expect(ONBOARDING_DAY_COUNT).toBe(5);
|
||||
});
|
||||
|
||||
describe('orderThemes', () => {
|
||||
const kb = ['Governance', 'Culture', 'Privacy', 'Process'];
|
||||
|
||||
it('orders by first appearance in the schedule, then appends the rest alphabetically', () => {
|
||||
const schedule = [{ theme: 'Privacy' }, { theme: 'Governance' }];
|
||||
expect(orderThemes(kb, schedule)).toEqual(['Privacy', 'Governance', 'Culture', 'Process']);
|
||||
});
|
||||
|
||||
it('de-duplicates repeated schedule labels', () => {
|
||||
const schedule = [{ theme: 'Privacy' }, { theme: 'Privacy' }, { theme: 'Culture' }];
|
||||
expect(orderThemes(kb, schedule)).toEqual(['Privacy', 'Culture', 'Governance', 'Process']);
|
||||
});
|
||||
|
||||
it('ignores schedule labels that are not real KB themes (e.g. merged-week labels)', () => {
|
||||
const schedule = [{ theme: 'Privacy & Governance (merged)' }, { theme: 'Culture' }];
|
||||
expect(orderThemes(kb, schedule)).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||
});
|
||||
|
||||
it('falls back to pure alphabetical when the schedule is null/empty', () => {
|
||||
expect(orderThemes(kb, null)).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||
expect(orderThemes(kb, [])).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('distributeThemesIntoDays', () => {
|
||||
const themes = (n) => Array.from({ length: n }, (_, i) => `T${i + 1}`);
|
||||
|
||||
it('splits 10 themes evenly across 5 days', () => {
|
||||
expect(sizes(distributeThemesIntoDays(themes(10)))).toEqual([2, 2, 2, 2, 2]);
|
||||
});
|
||||
|
||||
it('front-loads the remainder (12 -> 3,3,2,2,2)', () => {
|
||||
expect(sizes(distributeThemesIntoDays(themes(12)))).toEqual([3, 3, 2, 2, 2]);
|
||||
});
|
||||
|
||||
it('handles 7 -> 2,2,1,1,1', () => {
|
||||
expect(sizes(distributeThemesIntoDays(themes(7)))).toEqual([2, 2, 1, 1, 1]);
|
||||
});
|
||||
|
||||
it('uses fewer days than the cap when there are fewer themes', () => {
|
||||
const days = distributeThemesIntoDays(themes(3));
|
||||
expect(sizes(days)).toEqual([1, 1, 1]);
|
||||
expect(days.map((d) => d.day)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns [] for no themes', () => {
|
||||
expect(distributeThemesIntoDays([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves order and loses nothing', () => {
|
||||
const input = themes(12);
|
||||
const flat = distributeThemesIntoDays(input).flatMap((d) => d.themes);
|
||||
expect(flat).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTopicsFingerprint', () => {
|
||||
it('is order-independent', () => {
|
||||
const a = computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }, { id: 'z' }]);
|
||||
const b = computeTopicsFingerprint([{ id: 'z' }, { id: 'x' }, { id: 'y' }]);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('changes when a topic is added or removed', () => {
|
||||
const base = computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }]);
|
||||
expect(computeTopicsFingerprint([{ id: 'x' }])).not.toBe(base);
|
||||
expect(computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }, { id: 'z' }])).not.toBe(base);
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(typeof computeTopicsFingerprint([])).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeOnboardingProgress', () => {
|
||||
const days = [
|
||||
{ day: 1, themes: ['A', 'B'] },
|
||||
{ day: 2, themes: ['C'] },
|
||||
];
|
||||
|
||||
it('does not count a partially-done day as complete', () => {
|
||||
const p = computeOnboardingProgress(days, new Set(['A']));
|
||||
expect(p).toMatchObject({ themesTotal: 3, themesDone: 1, dayCount: 2, daysCompleted: 0, allDone: false });
|
||||
expect(p.percentage).toBe(33);
|
||||
});
|
||||
|
||||
it('counts a fully-done day', () => {
|
||||
const p = computeOnboardingProgress(days, new Set(['A', 'B']));
|
||||
expect(p).toMatchObject({ themesDone: 2, daysCompleted: 1, allDone: false });
|
||||
});
|
||||
|
||||
it('reports allDone only when every theme is complete', () => {
|
||||
const p = computeOnboardingProgress(days, new Set(['A', 'B', 'C']));
|
||||
expect(p).toMatchObject({ themesDone: 3, daysCompleted: 2, percentage: 100, allDone: true });
|
||||
});
|
||||
|
||||
it('accepts an array as well as a Set', () => {
|
||||
expect(computeOnboardingProgress(days, ['A', 'B', 'C']).allDone).toBe(true);
|
||||
});
|
||||
|
||||
it('returns zeros for an empty plan', () => {
|
||||
expect(computeOnboardingProgress([], new Set())).toEqual({
|
||||
themesTotal: 0, themesDone: 0, dayCount: 0, daysCompleted: 0, percentage: 0, allDone: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOnboardingPlan', () => {
|
||||
const topics = [
|
||||
{ id: 't1', label: 'T1', theme: 'Privacy', complexity_weight: 2 },
|
||||
{ id: 't2', label: 'T2', theme: 'Culture', complexity_weight: 1 },
|
||||
{ id: 'f1', label: 'F1', theme: 'Privacy', type: 'fact' }, // excluded by buildThemeTopicMap
|
||||
];
|
||||
|
||||
it('builds themes (schedule-ordered), days, and a theme→topics map', () => {
|
||||
const activeVersion = { schedule: [{ theme: 'Privacy' }, { theme: 'Culture' }] };
|
||||
const plan = buildOnboardingPlan(topics, activeVersion);
|
||||
expect(plan.themes).toEqual(['Privacy', 'Culture']);
|
||||
expect(sizes(plan.days)).toEqual([1, 1]);
|
||||
expect(plan.themeTopicMap.get('Privacy').map((t) => t.id)).toEqual(['t1']); // fact excluded
|
||||
});
|
||||
|
||||
it('tolerates a stringified schedule and a missing version', () => {
|
||||
const stringified = { schedule: JSON.stringify([{ theme: 'Culture' }]) };
|
||||
expect(buildOnboardingPlan(topics, stringified).themes).toEqual(['Culture', 'Privacy']);
|
||||
expect(buildOnboardingPlan(topics, null).themes).toEqual(['Culture', 'Privacy']);
|
||||
});
|
||||
|
||||
it('returns an empty plan for an empty KB', () => {
|
||||
const plan = buildOnboardingPlan([], null);
|
||||
expect(plan.themes).toEqual([]);
|
||||
expect(plan.days).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -406,6 +406,49 @@ export async function setThemeSessionCompletion(userId, themeSessionId, sessionW
|
||||
);
|
||||
}
|
||||
|
||||
// ── Onboarding track ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cached onboarding overview for a theme, or null. `theme` is free text →
|
||||
* use pb.filter() bindings so quotes/specials can't break the filter.
|
||||
*/
|
||||
export async function getOnboardingOverview(theme) {
|
||||
try {
|
||||
return await pb.collection('onboarding_overviews').getFirstListItem(
|
||||
pb.filter('theme = {:theme}', { theme }),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Upsert the cached overview for a theme (keyed by theme). */
|
||||
export async function setOnboardingOverview(theme, content, fingerprint) {
|
||||
return pbUpsert(
|
||||
'onboarding_overviews',
|
||||
pb.filter('theme = {:theme}', { theme }),
|
||||
{ content, topics_fingerprint: fingerprint },
|
||||
{ theme, content, topics_fingerprint: fingerprint },
|
||||
);
|
||||
}
|
||||
|
||||
/** All onboarding-completion rows for a user (each has a `theme`). */
|
||||
export async function getCompletedOnboardingThemes(userId) {
|
||||
return pb.collection('onboarding_completions').getFullList({
|
||||
filter: pb.filter('team_member_id = {:userId}', { userId }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a theme complete for the user. Idempotent (unique on user+theme). */
|
||||
export async function setOnboardingCompletion(userId, theme) {
|
||||
return pbUpsert(
|
||||
'onboarding_completions',
|
||||
pb.filter('team_member_id = {:userId} && theme = {:theme}', { userId, theme }),
|
||||
{ team_member_id: userId, theme },
|
||||
{ team_member_id: userId, theme },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getLeaderboard() {
|
||||
|
||||
@@ -213,6 +213,13 @@ const SIMULATION_TOOL_STUBS = {
|
||||
},
|
||||
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
|
||||
set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
|
||||
emit_onboarding_overview: {
|
||||
title: 'Voorbeeldthema',
|
||||
what_it_is: 'Een korte simulatie-omschrijving van dit thema.',
|
||||
why_it_matters: 'In simulatiemodus laat dit zien hoe de onboarding-overview eruitziet zonder de API te raken.',
|
||||
key_points: ['Kernpunt één', 'Kernpunt twee', 'Kernpunt drie'],
|
||||
topics_covered: [{ topic_id: 'sim-topic', label: 'Simulatie onderwerp' }],
|
||||
},
|
||||
};
|
||||
|
||||
function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
|
||||
|
||||
@@ -229,6 +229,16 @@ export const themeSessionSchema = z.object({
|
||||
keyTakeaways: z.array(z.string().min(1)).min(3),
|
||||
});
|
||||
|
||||
export const onboardingOverviewSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
what_it_is: z.string().min(1),
|
||||
why_it_matters: z.string().min(1),
|
||||
key_points: z.array(z.string().min(1)).min(3).max(5),
|
||||
topics_covered: z
|
||||
.array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) }))
|
||||
.min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Registry mapping known tool names to their input schemas. `callLLM`
|
||||
* consults this when the caller does not pass an explicit `toolSchemas`
|
||||
@@ -252,4 +262,5 @@ export const toolSchemaRegistry = {
|
||||
remove_section: removeSectionPatchSchema,
|
||||
replace_takeaways: replaceTakeawaysPatchSchema,
|
||||
emit_theme_session: themeSessionSchema,
|
||||
emit_onboarding_overview: onboardingOverviewSchema,
|
||||
};
|
||||
|
||||
@@ -454,3 +454,39 @@ export const EMIT_THEME_SESSION_TOOL = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── Onboarding overview (breadth-first, one theme) ───────────────────────────
|
||||
|
||||
export const EMIT_ONBOARDING_OVERVIEW_TOOL = {
|
||||
name: 'emit_onboarding_overview',
|
||||
description: 'Return a SHORT, breadth-first onboarding overview of ONE theme for a brand-new Respellion employee. This is a light introduction, NOT a deep lesson: what the theme is in plain language, why it matters for day-to-day and week-to-week work at Respellion, a few key points, and which topics belong to it. Keep it skimmable.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Learner-facing theme title, 2–6 words.' },
|
||||
what_it_is: { type: 'string', description: '1–2 plain-language sentences defining what this theme is about.' },
|
||||
why_it_matters: { type: 'string', description: '1–3 sentences on why this theme matters for a new employee\'s daily and weekly work at Respellion.' },
|
||||
key_points: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
minItems: 3,
|
||||
maxItems: 5,
|
||||
description: '3–5 short, concrete takeaways a newcomer should remember about this theme.',
|
||||
},
|
||||
topics_covered: {
|
||||
type: 'array',
|
||||
description: 'The topics that make up this theme. Reuse the exact topic_id you were given so the UI can link back.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topic_id: { type: 'string', description: 'The id of the topic (must match one of the ids provided).' },
|
||||
label: { type: 'string', description: 'The topic label, as provided.' },
|
||||
},
|
||||
required: ['topic_id', 'label'],
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
},
|
||||
required: ['title', 'what_it_is', 'why_it_matters', 'key_points', 'topics_covered'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
233
src/lib/onboardingService.js
Normal file
233
src/lib/onboardingService.js
Normal file
@@ -0,0 +1,233 @@
|
||||
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>|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<string, Array> }}
|
||||
*/
|
||||
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<Set<string>>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user