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

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