feat: theme-level weekly sessions and theme-grouped knowledge library
All checks were successful
On Push to Main / test (push) Successful in 46s
On Push to Main / publish (push) Successful in 1m28s
On Push to Main / deploy-dev (push) Successful in 2m9s

- New theme_sessions + theme_session_completions PocketBase collections
- Generate a detailed per-week theme summary covering every topic in the
  week's curriculum slot via EMIT_THEME_SESSION_TOOL; cache per
  (curriculum_version, week_number)
- Replace Leren.jsx "This Week's Topic" card with "This Week's Theme
  Session" that opens the new ThemeSessionView; per-topic micro-learnings
  remain reachable as optional practice from inside the session
- Group the Knowledge Library by topic.theme, pin the current week's
  theme on top, and badge topics already covered by the active session
- Mark week complete when the user finishes reading the theme session

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RaymondVerhoef
2026-05-28 10:44:10 +02:00
parent 6a1f5556a9
commit d38c75beb1
7 changed files with 688 additions and 99 deletions

View File

@@ -242,6 +242,61 @@ export async function getLearnDone() { return false; }
/** @deprecated learn_progress collection has been dropped. */
export async function setLearnDone() { return null; }
// ── Theme Sessions ──────────────────────────────────────────────────────────
/**
* Look up the cached theme session for a given (curriculum_version, week_number).
* Returns null when no cache entry exists yet.
*/
export async function getThemeSession(curriculumVersionId, weekNumber) {
try {
const record = await pb.collection('theme_sessions').getFirstListItem(
`curriculum_version = "${curriculumVersionId}" && week_number = ${weekNumber}`
);
return record;
} catch {
return null;
}
}
/**
* Upsert the theme session for a given (curriculum_version, week_number).
*/
export async function setThemeSession(curriculumVersionId, weekNumber, theme, content) {
return pbUpsert(
'theme_sessions',
`curriculum_version = "${curriculumVersionId}" && week_number = ${weekNumber}`,
{ theme, content },
{ curriculum_version: curriculumVersionId, week_number: weekNumber, theme, content },
);
}
/**
* Has the user completed the theme session for a given personal week?
* Returns the completion record, or null.
*/
export async function getThemeSessionCompletion(userId, sessionWeek) {
try {
return await pb.collection('theme_session_completions').getFirstListItem(
`team_member_id = "${userId}" && session_week = ${sessionWeek}`
);
} catch {
return null;
}
}
/**
* Mark a theme session as completed for the user/week. Idempotent.
*/
export async function setThemeSessionCompletion(userId, themeSessionId, sessionWeek) {
return pbUpsert(
'theme_session_completions',
`team_member_id = "${userId}" && session_week = ${sessionWeek}`,
{ theme_session_id: themeSessionId },
{ team_member_id: userId, theme_session_id: themeSessionId, session_week: sessionWeek },
);
}
// ── Leaderboard ───────────────────────────────────────────────────────────────
export async function getLeaderboard() {
@@ -484,6 +539,8 @@ export async function resetForSmokeTest({
'topics',
'micro_learnings',
'micro_learning_completions',
'theme_session_completions',
'theme_sessions',
'test_results',
];
if (includeProgress) collections.push('leaderboard');

View File

@@ -213,6 +213,22 @@ export const replaceTakeawaysPatchSchema = z.object({
items: z.array(z.string().min(1)).min(1),
});
// ── Theme session schema ─────────────────────────────────────────────────────
const themeSessionTopicSection = z.object({
topic_id: z.string().min(1),
label: z.string().min(1),
summary: z.string().min(1),
});
export const themeSessionSchema = z.object({
title: z.string().min(1),
intro: z.string().min(1),
topicSections: z.array(themeSessionTopicSection).min(1),
connections: z.string().min(1),
keyTakeaways: z.array(z.string().min(1)).min(3),
});
/**
* Registry mapping known tool names to their input schemas. `callLLM`
* consults this when the caller does not pass an explicit `toolSchemas`
@@ -235,4 +251,5 @@ export const toolSchemaRegistry = {
add_section: addSectionPatchSchema,
remove_section: removeSectionPatchSchema,
replace_takeaways: replaceTakeawaysPatchSchema,
emit_theme_session: themeSessionSchema,
};

View File

@@ -418,3 +418,39 @@ export const EMIT_FLASHCARD_SET_TOOL = {
},
};
// ── Theme session (theme-level weekly summary) ───────────────────────────────
export const EMIT_THEME_SESSION_TOOL = {
name: 'emit_theme_session',
description: 'Return a detailed weekly theme session that synthesises every topic in the week into one coherent learning experience. Cover each topic in its own section and finish with how the topics connect plus key takeaways for the theme.',
input_schema: {
type: 'object',
properties: {
title: { type: 'string', description: 'A short, learner-facing title for this weekly theme session. 38 words.' },
intro: { type: 'string', description: 'One-paragraph introduction (35 sentences) framing the theme and why it matters to a Respellion employee this week.' },
topicSections: {
type: 'array',
description: 'One section per topic in the week. Use the topic_id you were given so the UI can link back to per-topic practice.',
items: {
type: 'object',
properties: {
topic_id: { type: 'string', description: 'The id of the topic this section covers (must match one of the topic ids provided).' },
label: { type: 'string', description: 'The topic label, as provided.' },
summary: { type: 'string', description: 'Detailed summary of the topic in HTML. Use <p>, <ul>, <li>, <strong>. At least 4 sentences with a concrete example or application.' },
},
required: ['topic_id', 'label', 'summary'],
},
minItems: 1,
},
connections: { type: 'string', description: 'A short HTML paragraph (24 sentences) explaining how the topics in this week connect to each other within the theme.' },
keyTakeaways: {
type: 'array',
items: { type: 'string' },
minItems: 3,
description: 'At least 3 punchy bullet takeaways covering the theme as a whole.',
},
},
required: ['title', 'intro', 'topicSections', 'connections', 'keyTakeaways'],
},
};

View File

@@ -0,0 +1,61 @@
import * as db from './db';
import { callLLM, cachedSystem } from './llm';
import { EMIT_THEME_SESSION_TOOL } from './llmTools';
const THEME_SESSION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
You produce theme-level weekly learning sessions for the perpetual 26-week curriculum.
A weekly session is a single, coherent piece of learning that covers every topic the curriculum has slotted for that week's theme — not a list of separate articles.
Write in clear, professional English. Each topic section must be detailed (at least four sentences) and include a concrete workplace example or application.
Emit content only through the emit_theme_session tool.`;
function buildUserPrompt(weekContent) {
const topicLines = weekContent.topics
.map((t, idx) => `${idx + 1}. id=${t.id} | label="${t.label}" | type=${t.type} | description=${t.description}`)
.join('\n');
return `Theme: ${weekContent.theme}
Week ${weekContent.weekNumber} of the 26-week curriculum.
Estimated duration: ${weekContent.estimatedDuration || 30} minutes.
Rationale for this week's position: ${weekContent.rationale || '—'}
Topics in this theme this week (you MUST cover every one of them, one section each, reusing the exact topic_id):
${topicLines}
Produce a detailed theme session that synthesises these topics into a single coherent learning experience. Cover every topic in its own section, finish with a "how these connect" paragraph, and at least 3 takeaways for the theme.`;
}
/**
* Get the cached theme session for the active curriculum version + week,
* or generate one with the LLM and cache it.
*
* @param {string} curriculumVersionId — id of the active curriculum_versions row
* @param {object} weekContent — output of getCurrentWeekContent: { weekNumber, theme, topics[], estimatedDuration, rationale }
* @param {object} [opts]
* @param {boolean} [opts.force=false] — regenerate even if cached
*/
export async function getOrGenerateThemeSession(curriculumVersionId, weekContent, { force = false } = {}) {
if (!curriculumVersionId) throw new Error('Theme session requires an active curriculum version.');
if (!weekContent || !Array.isArray(weekContent.topics) || weekContent.topics.length === 0) {
throw new Error('Theme session requires at least one topic for the week.');
}
if (!force) {
const cached = await db.getThemeSession(curriculumVersionId, weekContent.weekNumber);
if (cached) return cached;
}
const result = await callLLM({
task: 'theme_session.generate',
tier: 'standard',
system: cachedSystem(THEME_SESSION_SYSTEM),
user: buildUserPrompt(weekContent),
tools: [EMIT_THEME_SESSION_TOOL],
toolChoice: { type: 'tool', name: EMIT_THEME_SESSION_TOOL.name },
maxTokens: 8192,
});
const emitted = result.toolUses[0]?.input;
if (!emitted) throw new Error('AI did not return a theme session. Please try again.');
return db.setThemeSession(curriculumVersionId, weekContent.weekNumber, weekContent.theme, emitted);
}