From c5e23c77cdb902de793aef9277cd0b2699803217 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 19:50:20 +0200 Subject: [PATCH] feat: implement curriculum management system including automated generation, enrichment, and versioning workflows --- AI_AGENT.md | 22 +- curriculum-spec-agnostic.md | 334 +++++++++++++ pb_migrations/1780600000_curriculum_v2.js | 202 ++++++++ src/components/admin/CurriculumManager.jsx | 507 ++++++++++--------- src/lib/curriculumService.js | 536 ++++++++++++--------- src/lib/db.js | 53 +- src/lib/learningService.js | 36 +- src/lib/llm.js | 16 + src/lib/llmSchemas.js | 25 + src/lib/llmTools.js | 52 ++ src/lib/testService.js | 30 +- src/pages/Admin/index.jsx | 4 +- src/pages/Dashboard.jsx | 43 +- src/pages/Leren.jsx | 115 +---- src/store/AppContext.jsx | 2 - 15 files changed, 1354 insertions(+), 623 deletions(-) create mode 100644 curriculum-spec-agnostic.md create mode 100644 pb_migrations/1780600000_curriculum_v2.js diff --git a/AI_AGENT.md b/AI_AGENT.md index 90567d7..412cead 100644 --- a/AI_AGENT.md +++ b/AI_AGENT.md @@ -129,17 +129,17 @@ The platform ships a global chatbot avatar called **R42**, rendered as the Respe * **AI token budget.** If you see `[Pipeline] AI returned non-JSON response` in the logs, the response was truncated. Increase the topic cap prompt constraint before raising `max_tokens`. * **PocketBase auto-cancellation is OFF.** `pb.autoCancellation(false)` is set globally in `src/lib/pb.js`. Never re-enable it — it causes abort errors during concurrent fetches in React StrictMode. -## 12. Annual Curriculum System -The platform uses a **52-week annual curriculum** so every employee covers all knowledge-base topics in one calendar year. +## 12. 26-Week Versioned Curriculum System +The platform uses a **26-week perpetual curriculum cycle** so every employee covers the knowledge base in focused, thematic weekly blocks. Cycle 1 covers weeks 1-26, Cycle 2 replays the same schedule, and so on. -* **Service:** `src/lib/curriculumService.js` — curriculum engine with topic lookup, progress tracking, and auto-generation. -* **DB functions:** `db.getCurriculum(year)`, `db.getCurriculumWeek(year, week)`, `db.setCurriculumWeek(...)`, `db.bulkSetCurriculum(year, weeks[])`. -* **Admin UI:** `src/components/admin/CurriculumManager.jsx` — accessed via the "Curriculum" tab in the admin panel. Admins can auto-generate a schedule from KB topics or manually assign topics per week. -* **Same topic for everyone:** All employees study the same topic each week — this is by design to enable team discussion and shared quizzes. -* **Quarterly structure:** Weeks 1–13 (Q1), 14–26 (Q2), 27–39 (Q3), 40–52 (Q4). Review/recap weeks at 13, 26, 39, 52. -* **Fallback:** If no curriculum exists for the current year, `getAssignedTopic()` falls back to the legacy hash-based assignment for backward compatibility. -* **Progress tracking:** `getYearProgress(userId)` and `getQuarterProgress(userId, quarter)` compute completion from the `learn_progress` collection against the curriculum. -* **Auto-generate:** `autoGenerateCurriculum(year)` distributes all non-fact topics across 48 content weeks + 4 review weeks. If there are fewer topics than weeks, they cycle. If more, excess topics remain in the self-service library. -* **Do not remove the hash fallback** — it ensures the platform works even without a configured curriculum. +* **Service:** `src/lib/curriculumService.js` — curriculum engine, week resolution, AI generation, version lifecycle. +* **DB Collections:** `curriculum_versions` holds the generated JSON schedules. `topics` includes `theme`, `complexity_weight`, and `difficulty` for generation input. +* **Version Lifecycle:** `draft` -> `active` -> `superseded`. Only one active version exists at a time. +* **Admin UI:** `src/components/admin/CurriculumManager.jsx` — accessed via the "Curriculum" tab. Admins trigger an AI-generated draft, preview the 26-week schedule, and confirm it to activate it globally. +* **Calendar-Driven Weeks:** The current week (1-26) and cycle (1, 2, 3...) are derived via modulo arithmetic from the absolute ISO calendar week in `curriculumService.js`. All employees share the same schedule. +* **Topic Enrichment:** The Curriculum Manager includes a one-off AI enrichment step to assign `theme` and `complexity_weight` to topics missing them before curriculum generation. +* **Progress tracking:** `getYearProgress(userId, isoWeekNumber)` computes completion for the *current 26-week cycle*. +* **Fallback:** If no active curriculum version exists, `getAssignedTopic()` falls back to the legacy hash-based assignment. Do not remove the hash fallback. +* **Deferred Features (V2):** Per-employee staggered start dates, cycle 2+ personalization, and prerequisite DAG sorting are deferred to future iterations to keep the MVP lean. Good luck! You are building a platform that empowers continuous learning. Keep it fast, keep it beautiful, and keep the user engaged. diff --git a/curriculum-spec-agnostic.md b/curriculum-spec-agnostic.md new file mode 100644 index 0000000..4d238d6 --- /dev/null +++ b/curriculum-spec-agnostic.md @@ -0,0 +1,334 @@ +# Curriculum specification + +## Purpose + +This document defines the functional behaviour of a perpetual AI-generated +learning curriculum. It is implementation-agnostic — no assumptions are made +about programming language, framework, database, or AI provider. Any system +that satisfies these functional requirements is a valid implementation. + +--- + +## Concepts + +**Knowledge base (KB)** +The source of truth for all learning content. Organised as a two-level +hierarchy: Themes (broad subject areas) containing Topics (specific concepts). +Topics carry metadata that informs curriculum sequencing. + +**Theme** +A broad subject area. One Theme = one weekly session. A Theme contains one +or more Topics. + +**Topic** +An atomic unit of knowledge within a Theme. Carries: +- a complexity weight (1–5 scale, 1 = introductory, 5 = advanced) +- a difficulty label (introductory / intermediate / advanced) +- relationships to other topics: prerequisite, related, contrast + +**Curriculum** +A versioned 26-week schedule mapping one Theme per week. The curriculum is +generated by AI from the KB, not manually authored. Admins review and +finetune it before it is applied. + +**Curriculum version** +Each generation produces a new version. Only one version is active at a time. +Previous versions are retained for audit purposes. + +**Cycle** +One full pass through the 26-week curriculum. After completing week 26, +an employee begins cycle 2. Cycles continue indefinitely. + +**Employee curriculum state** +Tracks each employee's position: current cycle, current week, start date, +and which curriculum version they are on. Employees have independent start +dates — there are no cohorts. + +**Completed week** +A week where the employee has recorded at least one session completion. +Completed weeks are immutable — no regeneration or version change may alter +a completed week's record. + +--- + +## Functional requirements + +### 1. Curriculum generation + +The system must be able to generate a 26-week curriculum from the current +published KB. + +**Trigger conditions:** +- Manually by an admin +- Automatically when new Topics are published to the KB + +**Inputs to the generator:** +- All published Themes, each with their ordered Topic list +- Per Topic: title, complexity weight, difficulty, prerequisite relationships +- If regenerating: reason for regeneration + +**Generation process:** + +Step 1 — Pre-process topic order within each Theme +Before any AI involvement, resolve the order of Topics within each Theme +using their prerequisite relationships. Build a directed graph of +prerequisite edges and apply a topological sort. The result is an ordered +topic list per Theme where prerequisites always precede dependent Topics. +If a cycle is detected in the graph (which should not occur in a well-formed +KB), fall back to ordering by complexity weight ascending. + +This pre-processing offloads prerequisite reasoning from the AI — the AI +receives already-ordered topic lists and focuses only on Theme sequencing +across weeks. + +Step 2 — AI sequences Themes across 26 weeks +The AI receives the pre-processed KB snapshot and produces a 26-week +schedule. The AI must follow these sequencing rules: + +- Every Theme must appear at least once across the 26 weeks +- Themes with more Topics may span multiple weeks or recur within the 26 weeks +- Foundational Themes precede dependent ones +- Complexity increases progressively: introductory Themes in the first half, + advanced Themes in the second half +- If the KB contains more Topics than 26 weeks can cover in depth, prioritise + breadth in cycle 1 — every Theme covered, key Topics per Theme +- Each week slot has an estimated session duration (15–45 minutes) +- Each week slot includes a one-sentence rationale explaining its position + +**Output:** +A draft 26-week schedule with one Theme and an ordered Topic subset per week, +estimated duration per week, and a rationale per week. + +**Validation:** +All Theme and Topic references in the generated schedule must resolve to +existing published KB entries. Any reference that does not resolve must +cause the generation to be retried or rejected — never persisted. + +--- + +### 2. Admin review and confirmation + +A generated curriculum is always a draft first. It is not applied to any +employee until an admin explicitly confirms it. + +**Admin capabilities before confirmation:** +- Preview the full proposed 26-week schedule +- View the rationale for each week's Theme assignment +- Reorder weeks (swap Theme assignments between weeks) +- Add or remove Topics from a week's Topic subset +- Annotate any week with free-text notes +- Compare the proposed schedule against the currently active version + +**Confirmation:** +When admin confirms, the new version becomes active and is applied to all +employees whose current week falls within the regenerated range (see +section 4 — versioning). + +**Rejection:** +If admin rejects the draft, it is discarded and no changes are applied. + +--- + +### 3. Perpetual cycling + +The curriculum runs indefinitely. After an employee completes week 26, cycle 2 +begins automatically on the latest active curriculum version. + +**Cycle 2 and beyond are not identical to cycle 1.** +The generator must vary subsequent cycles to reinforce rather than repeat. +When generating a cycle 2+ schedule for an employee, the generator receives +additional input: + +- Which micro learning types the employee has used across their history +- Which micro learning types the employee has not yet used +- Which Topics the employee engaged with minimally (completed only the + minimum required interaction) + +The generator uses this input to: +- Vary the Theme sequence from the previous cycle +- Surface underused micro learning types as recommended formats +- Schedule low-engagement Topics earlier in the new cycle to give them + more visibility + +--- + +### 4. Versioning and version transitions + +**One active version at all times.** +Previous versions are archived, not deleted. + +**Completed weeks are frozen.** +When a new curriculum version is applied, only future unstarted weeks are +updated. Any week the employee has already started or completed retains its +original content. The boundary is the employee's current week at the moment +the new version is applied. + +**Version transition logic:** +When a new version is confirmed: +- For each active employee: identify their current week N +- Weeks 1 through N-1 are already completed — untouched +- Week N onward is replaced with the new version's schedule +- The employee continues from week N on the new version seamlessly + +**Regeneration triggers:** +Two events should prompt a regeneration: +1. New Topics are published to the KB (new content available for scheduling) +2. Admin manually requests regeneration + +In both cases, the new version must be previewed and confirmed by an admin +before being applied. Regeneration is never automatic. + +--- + +### 5. Employee curriculum state + +Each employee has an independent curriculum state: + +| Attribute | Description | +|---|---| +| Current cycle | Which pass through the 26-week schedule (starts at 1) | +| Current week | Which week within the cycle (1–26) | +| Start date | When this employee began their curriculum (rolling) | +| Active version | Which curriculum version the employee's future weeks draw from | + +**State transitions:** +- Week completion: current week increments by 1 +- Week 26 completion: cycle increments by 1, current week resets to 1, + active version updates to latest, cycle variant generated +- Version applied: active version updates for weeks not yet started + +--- + +### 6. Coverage guarantee + +The curriculum must cover the entire published KB within one cycle. +Every published Theme must appear at least once in the 26-week schedule. +Coverage is verified after generation and before the draft is made available +for admin review. A schedule that fails coverage verification is rejected +and regenerated. + +Coverage statistics must be surfaced to the admin during preview: +- Total Themes in KB vs Themes scheduled +- Total Topics in KB vs Topics scheduled across all weeks + +--- + +### 7. Estimated session duration + +Each week slot carries an estimated duration in minutes (15–45 minute range). +This is an AI estimate based on Topic count and complexity weights for that +week. It is informational — it guides employees on time commitment but does +not enforce a time limit. + +--- + +## Data model (logical) + +These are logical entities. Implementation may map them to any storage system. + +**CurriculumVersion** +``` +id +version_number integer, increments on each generation +status draft | active | superseded +generated_at datetime +generation_reason string +confirmed_by user reference +confirmed_at datetime +``` + +**CurriculumWeek** +``` +id +curriculum_version → CurriculumVersion +week_number 1–26 +theme → Theme +topics ordered list of → Topic +estimated_duration minutes (15–45) +week_rationale string (AI-generated, one sentence) +admin_notes string (free text, admin-authored) +``` + +**EmployeeCurriculumState** +``` +id +employee → User +current_cycle integer (starts at 1) +current_week integer (1–26) +start_date datetime +active_version → CurriculumVersion +``` + +--- + +## AI prompt requirements + +The following requirements apply to any AI model used for generation, +regardless of provider. + +**Input the AI must receive:** +- Full KB snapshot with Themes and pre-ordered Topic lists +- Per Topic: complexity weight, difficulty, relationship types +- Cycle number (1 for first cycle, 2+ for subsequent) +- For cycle 2+: employee history (types used, types not used, low-engagement topics) + +**Output the AI must produce:** +- Exactly 26 week slots +- Each slot: theme reference, ordered topic references, estimated duration, rationale +- Structured format suitable for machine parsing (JSON or equivalent) +- No explanatory prose outside the structured output + +**Constraints on AI behaviour:** +- Temperature should be set to deterministic or near-deterministic (0 or equivalent) +- Output must be validated before persistence — never persist unvalidated AI output +- On validation failure: retry once with a stricter prompt, then surface an error + to the operator rather than persisting a partial or malformed schedule +- The AI must not invent Theme or Topic references — all references must exist + in the provided KB snapshot + +--- + +## Admin capabilities summary + +| Capability | When available | +|---|---| +| Trigger manual regeneration | Any time | +| Preview proposed schedule | After generation, before confirmation | +| View week rationale | During preview | +| Reorder weeks | During preview | +| Edit topic subset per week | During preview | +| Add admin notes per week | During preview and after confirmation | +| Confirm version | During preview | +| Reject draft | During preview | +| View coverage statistics | During preview | +| View active schedule | Any time | +| View version history | Any time | + +--- + +## Behaviours that must never occur + +- A completed week's content changes after the employee has completed it +- A curriculum version is applied without admin confirmation +- AI output is persisted without validation against the KB +- An employee's cycle increments without week 26 being completed +- Two versions are active simultaneously +- A Theme is absent from a 26-week schedule when it exists in the published KB +- The admin is not shown a preview before a new version is applied + +--- + +## Acceptance criteria + +1. Generate a curriculum from a KB with 5 or more Themes → exactly 26 weeks produced +2. All published Themes appear at least once in the schedule +3. Topic order within each week respects prerequisite relationships +4. Coverage statistics correctly reflect KB vs schedule coverage +5. A draft version is available for preview before any employee is affected +6. Admin can reorder two weeks and confirm → reordered schedule applied +7. New Topics published to KB → regeneration triggered → admin sees preview → + confirms → employees at week N see weeks 1 to N unchanged, weeks N+1 onward updated +8. Employee completes week 26 → cycle 2 begins → sequence differs from cycle 1 +9. Cycle 2 schedule surfaces Topics the employee engaged with minimally +10. Two employees with different start dates and current weeks both receive + correct version transitions independently when a new version is applied diff --git a/pb_migrations/1780600000_curriculum_v2.js b/pb_migrations/1780600000_curriculum_v2.js new file mode 100644 index 0000000..f5a4d9e --- /dev/null +++ b/pb_migrations/1780600000_curriculum_v2.js @@ -0,0 +1,202 @@ +/// +migrate((app) => { + // ── 1. Create curriculum_versions collection ────────────────────────────── + const curriculumVersions = new Collection({ + "createRule": "", + "deleteRule": "", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "number_version_number", + "max": null, + "min": 1, + "name": "version_number", + "onlyInt": true, + "presentable": true, + "required": true, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_status", + "max": 20, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_generation_reason", + "max": 0, + "min": 0, + "name": "generation_reason", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_confirmed_by", + "max": 0, + "min": 0, + "name": "confirmed_by", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_confirmed_at", + "max": 0, + "min": 0, + "name": "confirmed_at", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "json_schedule", + "maxSize": 0, + "name": "schedule", + "presentable": false, + "required": true, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json_coverage_stats", + "maxSize": 0, + "name": "coverage_stats", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_curriculum_v2", + "indexes": [], + "listRule": "", + "name": "curriculum_versions", + "system": false, + "type": "base", + "updateRule": "", + "viewRule": "" + }); + + app.save(curriculumVersions); + + // ── 2. Add theme, complexity_weight, difficulty to topics ───────────────── + const topics = app.findCollectionByNameOrId("topics"); + + topics.fields.add(new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text_theme", + "max": 0, + "min": 0, + "name": "theme", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })); + + topics.fields.add(new Field({ + "hidden": false, + "id": "number_complexity_weight", + "max": 5, + "min": 1, + "name": "complexity_weight", + "onlyInt": true, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })); + + topics.fields.add(new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text_difficulty", + "max": 20, + "min": 0, + "name": "difficulty", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })); + + app.save(topics); + +}, (app) => { + // ── Rollback: remove curriculum_versions collection ──────────────────────── + const curriculumVersions = app.findCollectionByNameOrId("curriculum_versions"); + app.delete(curriculumVersions); + + // ── Rollback: remove new fields from topics ─────────────────────────────── + const topics = app.findCollectionByNameOrId("topics"); + topics.fields.removeById("text_theme"); + topics.fields.removeById("number_complexity_weight"); + topics.fields.removeById("text_difficulty"); + app.save(topics); +}); diff --git a/src/components/admin/CurriculumManager.jsx b/src/components/admin/CurriculumManager.jsx index 93d3a10..912b94c 100644 --- a/src/components/admin/CurriculumManager.jsx +++ b/src/components/admin/CurriculumManager.jsx @@ -1,114 +1,184 @@ -import { useState, useEffect, useMemo } from 'react'; -import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, Loader, AlertTriangle } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { Calendar, Wand2, CheckCircle2, Loader, AlertTriangle, Play, Sparkles, X, Clock, Target } from 'lucide-react'; import Card from '../ui/Card'; import Button from '../ui/Button'; import Tag from '../ui/Tag'; import * as db from '../../lib/db'; import { - autoGenerateCurriculum, - getCurriculumYear, - getQuarterForWeek, - getQuarterName, - getFullCurriculum, + getCurriculumWeek, + generateCurriculumDraft, + confirmVersion, + rejectVersion, + enrichTopicsForCurriculum, + getActiveVersion, + getDraftVersion } from '../../lib/curriculumService'; -const QUARTER_COLORS = { - 1: { bg: 'bg-teal-50', border: 'border-teal-200', text: 'text-teal-700', accent: 'var(--color-teal)' }, - 2: { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700', accent: '#7c3aed' }, - 3: { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', accent: '#2563eb' }, - 4: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-700', accent: '#d97706' }, -}; - const CurriculumManager = () => { - const [year, setYear] = useState(getCurriculumYear()); - const [curriculum, setCurriculum] = useState([]); + const [activeVersion, setActiveVersion] = useState(null); + const [draftVersion, setDraftVersion] = useState(null); const [topics, setTopics] = useState([]); + const [isLoading, setIsLoading] = useState(true); const [isGenerating, setIsGenerating] = useState(false); - const [expandedQuarters, setExpandedQuarters] = useState({ 1: true, 2: true, 3: true, 4: true }); - const [editingWeek, setEditingWeek] = useState(null); - const [saveStatus, setSaveStatus] = useState(null); + const [isEnriching, setIsEnriching] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); + + const [generationReason, setGenerationReason] = useState(''); - const currentWeek = useMemo(() => { + const currentIsoWeek = (() => { const d = new Date(); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil(((d - yearStart) / 86400000 + 1) / 7); - }, []); + })(); + + const currentWeek = getCurriculumWeek(currentIsoWeek); const load = async () => { setIsLoading(true); - const [currData, topicData] = await Promise.all([ - getFullCurriculum(year), - db.getTopics(), - ]); - setCurriculum(currData); - setTopics(topicData.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude')); - setIsLoading(false); + try { + const [active, draft, allTopics] = await Promise.all([ + getActiveVersion(), + getDraftVersion(), + db.getTopics(), + ]); + setActiveVersion(active); + setDraftVersion(draft); + setTopics(allTopics); + } catch (e) { + console.error('Failed to load curriculum state:', e); + } finally { + setIsLoading(false); + } }; - useEffect(() => { load(); }, [year]); + useEffect(() => { load(); }, []); - const handleAutoGenerate = async () => { - if (curriculum.length > 0 && !confirm('This will replace the existing curriculum for ' + year + '. Continue?')) return; + const showStatus = (msg) => { + setStatusMessage(msg); + setTimeout(() => setStatusMessage(''), 4000); + }; + + const handleEnrichTopics = async () => { + setIsEnriching(true); + try { + const res = await enrichTopicsForCurriculum(); + showStatus(`Enrichment complete! Enriched: ${res.enriched}, Skipped: ${res.skipped}`); + await load(); + } catch (e) { + showStatus(`Enrichment failed: ${e.message}`); + } finally { + setIsEnriching(false); + } + }; + + const handleGenerate = async () => { setIsGenerating(true); try { - await autoGenerateCurriculum(year); + await generateCurriculumDraft(generationReason || 'Admin request'); + setGenerationReason(''); + showStatus('Draft generated successfully!'); await load(); - setSaveStatus('Curriculum generated!'); - setTimeout(() => setSaveStatus(null), 3000); } catch (e) { - console.error('Failed to generate curriculum:', e); - setSaveStatus('Generation failed: ' + e.message); + showStatus(`Generation failed: ${e.message}`); } finally { setIsGenerating(false); } }; - const handleWeekTopicChange = async (weekNumber, topicId) => { - const topic = topics.find(t => t.id === topicId); - await db.setCurriculumWeek(year, weekNumber, { - topic_id: topicId, - theme: topic?.type || 'General', - quarter: getQuarterForWeek(weekNumber), - is_review_week: false, - sort_order: weekNumber, - }); - setEditingWeek(null); - await load(); - setSaveStatus('Week ' + weekNumber + ' updated'); - setTimeout(() => setSaveStatus(null), 2000); + const handleConfirmDraft = async () => { + if (!draftVersion) return; + if (!confirm('This will replace the currently active curriculum for all employees. Proceed?')) return; + + try { + // Typically we'd pass the actual admin user ID here, hardcoded to 'admin' for MVP + await confirmVersion(draftVersion.id, 'admin'); + showStatus('Curriculum activated!'); + await load(); + } catch (e) { + showStatus(`Confirmation failed: ${e.message}`); + } }; - const handleToggleReview = async (weekNumber, currentEntry) => { - await db.setCurriculumWeek(year, weekNumber, { - topic_id: currentEntry?.topic_id || '', - theme: !currentEntry?.is_review_week ? `Q${getQuarterForWeek(weekNumber)} Review` : currentEntry?.theme || '', - quarter: getQuarterForWeek(weekNumber), - is_review_week: !currentEntry?.is_review_week, - sort_order: weekNumber, - }); - await load(); + const handleRejectDraft = async () => { + if (!draftVersion) return; + if (!confirm('Are you sure you want to discard this draft?')) return; + + try { + await rejectVersion(draftVersion.id); + showStatus('Draft discarded.'); + await load(); + } catch (e) { + showStatus(`Rejection failed: ${e.message}`); + } }; - const toggleQuarter = (q) => { - setExpandedQuarters(prev => ({ ...prev, [q]: !prev[q] })); + // --- Rendering Helpers --- + + const renderCoverageStats = (stats) => { + if (!stats) return null; + return ( +
+
+
{stats.themes_scheduled} / {stats.themes_kb}
+
Themes Scheduled
+
+
+
{stats.topics_scheduled} / {stats.topics_kb}
+
Topics Covered
+
+
+
26
+
Total Weeks
+
+
+
100%
+
Perpetual Cycle
+
+
+ ); }; - // Group by quarter - const quarters = [1, 2, 3, 4].map(q => ({ - quarter: q, - name: getQuarterName(q * 13 - 12), - weeks: curriculum.filter(w => w.quarter === q), - colors: QUARTER_COLORS[q], - startWeek: (q - 1) * 13 + 1, - endWeek: q * 13, - })); + const renderScheduleList = (schedule, isActiveVersion = false) => { + return ( +
+ {schedule.map((week) => { + const isCurrent = isActiveVersion && week.week_number === currentWeek; + + return ( +
+
+
+ {week.week_number} +
+ +
+
+ {week.theme} + {isCurrent && Current Week} + + {week.estimated_duration}m + +
+ +
+ Topics: {week.topic_ids.join(', ')} +
+ +

+ Rationale: {week.week_rationale} +

+
+
+
+ ); + })} +
+ ); + }; - // Stats - const assignedCount = curriculum.filter(w => w.topic_id).length; - const reviewCount = curriculum.filter(w => w.is_review_week).length; - const unassignedCount = curriculum.length > 0 ? 52 - assignedCount - reviewCount : 52; + // --- Main Render --- if (isLoading) { return ( @@ -118,201 +188,128 @@ const CurriculumManager = () => { ); } + const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'); + const unenrichedCount = learningTopics.filter(t => !t.theme).length; + return ( -
- {/* Header with year selector and stats */} +
+ + {/* Header & Global Status */}
-
- - +
+

+ 26-Week Curriculum +

+

Manage the AI-generated perpetual learning cycle.

- -
- {saveStatus && {saveStatus}} - -
-
- - {/* Stats bar */} - -
-
-
{curriculum.length}
-
Total Weeks
-
-
-
{assignedCount}
-
Topics Assigned
-
-
-
{reviewCount}
-
Review Weeks
-
-
-
0 ? '#ef4444' : '#22c55e' }}>{unassignedCount}
-
Unassigned
-
-
- {curriculum.length > 0 && ( -
- {[1, 2, 3, 4].map(q => { - const qWeeks = curriculum.filter(w => w.quarter === q).length; - return ( -
- ); - })} + + {statusMessage && ( +
+ {statusMessage}
)} - +
- {/* Empty state */} - {curriculum.length === 0 && ( - - -

No curriculum for {year}

-

- Click "Auto-Generate Curriculum" to distribute all knowledge base topics across 52 weeks - with quarterly review periods. -

- {topics.length === 0 && ( -
- - No topics in the knowledge base yet. Import sources first. + {/* Draft Preview View */} + {draftVersion && ( + +
+
+

+ Curriculum Draft Preview +

+

Generated for: "{draftVersion.generation_reason}"

+
+
+ + +
+
+ + {renderCoverageStats(draftVersion.coverage_stats)} + {renderScheduleList(draftVersion.schedule, false)} +
+ )} + + {/* Empty State / Generation Tools */} + {!draftVersion && ( + +
+

Curriculum Generator

+

Generate a new 26-week draft based on the current knowledge base.

+
+ + {learningTopics.length === 0 ? ( +
+ + No learning topics in the knowledge base. Import sources first before generating a curriculum. +
+ ) : unenrichedCount > 0 ? ( +
+
+ + There are {unenrichedCount} topics missing theme and complexity data. Enrich them before generating. +
+ +
+ ) : ( +
+
+ + setGenerationReason(e.target.value)} + placeholder="e.g., 'Include new privacy topics' or 'Q3 refresh'" + className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-2 bg-bg focus:outline-none focus:border-teal" + /> +
+
)}
)} - {/* Quarter sections */} - {curriculum.length > 0 && quarters.map(({ quarter, name, weeks, colors, startWeek, endWeek }) => ( -
- - - {expandedQuarters[quarter] && ( - -
- {/* Fill in all weeks for the quarter, even if not in curriculum */} - {Array.from({ length: 13 }, (_, i) => startWeek + i).map(weekNum => { - const entry = weeks.find(w => w.week_number === weekNum) || curriculum.find(w => w.week_number === weekNum); - const isCurrent = weekNum === currentWeek && year === getCurriculumYear(); - const isPast = year < getCurriculumYear() || (year === getCurriculumYear() && weekNum < currentWeek); - - return ( -
- {/* Week number */} -
- {weekNum} -
- - {/* Content */} -
- {entry?.is_review_week ? ( -
- - {entry.theme || `Q${quarter} Review`} -
- ) : entry?.topic ? ( -
- {entry.topic.label} - {entry.theme} -
- ) : entry?.topic_id ? ( - Topic: {entry.topic_id} (not found) - ) : ( - Unassigned - )} -
- - {/* Actions */} -
- {isCurrent && Current} - {isPast && entry?.topic_id && } - - {editingWeek === weekNum ? ( -
- -
- ) : ( - - )} -
-
- ); - })} -
-
- )} + Active +
+ + {renderCoverageStats(activeVersion.coverage_stats)} + {renderScheduleList(activeVersion.schedule, true)} + + )} + + {!draftVersion && !activeVersion && learningTopics.length > 0 && unenrichedCount === 0 && ( +
+ +

No active curriculum

+

+ Generate your first curriculum draft above to get started. +

- ))} + )}
); }; diff --git a/src/lib/curriculumService.js b/src/lib/curriculumService.js index 9b99d28..4816b94 100644 --- a/src/lib/curriculumService.js +++ b/src/lib/curriculumService.js @@ -1,250 +1,352 @@ import * as db from './db'; +import { callLLM, cachedSystem } from './llm'; +import { EMIT_CURRICULUM_SCHEDULE_TOOL, EMIT_TOPIC_ENRICHMENT_TOOL } from './llmTools'; /** - * Default quarterly theme structure for auto-generating a curriculum. - * Each quarter has a name and a list of thematic blocks. + * Get the current curriculum week (1-26) based on an ISO week number. */ -const DEFAULT_QUARTERS = [ - { - quarter: 1, - name: 'Foundation & Governance', - themes: [ - 'Company Purpose & Values', - 'Governance', - 'People & Culture', - 'Recruitment', - ], - }, - { - quarter: 2, - name: 'Compliance, Legal & Finance', - themes: [ - 'Privacy', - 'Compliance', - 'Quality', - 'Finance', - ], - }, - { - quarter: 3, - name: 'Technology & Operations', - themes: [ - 'Strategy', - 'Infrastructure', - 'Workplace', - 'Service Management', - 'Software Delivery', - ], - }, - { - quarter: 4, - name: 'Business, Marketing & Sustainability', - themes: [ - 'Marketing', - 'Networking', - 'Events', - 'Sustainability', - 'Year Wrap-up', - ], - }, -]; - -/** - * Get the current curriculum year based on a date. - * Uses the calendar year. - */ -export function getCurriculumYear(date = new Date()) { - return date.getFullYear(); +export function getCurriculumWeek(isoWeekNumber) { + return ((isoWeekNumber - 1) % 26) + 1; } /** - * Get the quarter number (1-4) for a given ISO week number. + * Get the current curriculum cycle (1, 2, 3...) based on an ISO week number. */ -export function getQuarterForWeek(weekNumber) { - if (weekNumber <= 13) return 1; - if (weekNumber <= 26) return 2; - if (weekNumber <= 39) return 3; - return 4; +export function getCurriculumCycle(isoWeekNumber) { + return Math.floor((isoWeekNumber - 1) / 26) + 1; } /** - * Get the quarter name for a given week number. + * Groups topics by their theme field and sorts them by complexity_weight ascending. + * Returns: Map */ -export function getQuarterName(weekNumber) { - const q = getQuarterForWeek(weekNumber); - return DEFAULT_QUARTERS[q - 1]?.name || `Quarter ${q}`; -} - -/** - * Get the assigned topic for a given week from the curriculum. - * Returns { topic, curriculumEntry } or { topic: null } if no curriculum exists. - */ -export async function getCurriculumTopic(weekNumber, year) { - const currYear = year ?? getCurriculumYear(); - const entry = await db.getCurriculumWeek(currYear, weekNumber); - - if (!entry || !entry.topic_id) { - return { topic: null, curriculumEntry: entry || null }; +export function buildThemeTopicMap(topics) { + const map = new Map(); + for (const topic of topics) { + if (topic.type === 'fact' || topic.learning_relevance === 'exclude') continue; + const theme = topic.theme || 'General'; + if (!map.has(theme)) { + map.set(theme, []); + } + map.get(theme).push(topic); } - // Resolve the topic from the topics collection (ensure it is not excluded) - const topics = await db.getTopics(); - const topic = topics.find(t => t.id === entry.topic_id && t.learning_relevance !== 'exclude') || null; - - return { topic, curriculumEntry: entry }; -} - -/** - * Get the full curriculum for a year, with resolved topic labels. - */ -export async function getFullCurriculum(year) { - const currYear = year ?? getCurriculumYear(); - const entries = await db.getCurriculum(currYear); - const topics = await db.getTopics(); - const topicMap = Object.fromEntries(topics.map(t => [t.id, t])); - - return entries.map(entry => ({ - ...entry, - topic: topicMap[entry.topic_id] || null, - })); -} - -/** - * Get progress for a user in a given quarter. - * Returns { completed, total, percentage }. - */ -export async function getQuarterProgress(userId, quarter, year) { - const currYear = year ?? getCurriculumYear(); - const curriculum = await db.getCurriculum(currYear); - const quarterWeeks = curriculum.filter(w => w.quarter === quarter); - - let completed = 0; - for (const week of quarterWeeks) { - const done = await db.getLearnDone(userId, week.week_number); - if (done) completed++; + // Sort within each theme by complexity_weight ascending + for (const [theme, themeTopics] of map.entries()) { + themeTopics.sort((a, b) => (a.complexity_weight || 3) - (b.complexity_weight || 3)); } - return { - completed, - total: quarterWeeks.length, - percentage: quarterWeeks.length > 0 - ? Math.round((completed / quarterWeeks.length) * 100) - : 0, - }; + return map; } /** - * Get overall annual progress for a user. - * Returns { completed, total, percentage }. + * Validates a 26-week schedule against the provided topics. + * Checks for exactly 26 weeks, duration range, theme existence, and topic existence. + * Returns { valid: boolean, errors: string[] } */ -export async function getYearProgress(userId, year) { - const currYear = year ?? getCurriculumYear(); - const curriculum = await db.getCurriculum(currYear); - - if (curriculum.length === 0) { - return { completed: 0, total: 52, percentage: 0 }; +export function validateSchedule(schedule, topics) { + const errors = []; + if (!Array.isArray(schedule) || schedule.length !== 26) { + errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`); } - let completed = 0; - for (const week of curriculum) { - const done = await db.getLearnDone(userId, week.week_number); - if (done) completed++; - } + const validThemes = new Set(topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude').map(t => t.theme || 'General')); + const validTopicIds = new Set(topics.map(t => t.id)); - return { - completed, - total: curriculum.length, - percentage: Math.round((completed / curriculum.length) * 100), - }; -} + const scheduledThemes = new Set(); -/** - * Get upcoming weeks from the curriculum (next N weeks after current). - */ -export async function getUpcomingWeeks(currentWeek, count = 4, year) { - const currYear = year ?? getCurriculumYear(); - const curriculum = await getFullCurriculum(currYear); - - return curriculum - .filter(w => w.week_number > currentWeek && w.week_number <= currentWeek + count) - .sort((a, b) => a.week_number - b.week_number); -} - -/** - * Auto-generate a 52-week curriculum from available topics. - * Distributes topics evenly across quarters, adds review weeks at 13, 26, 39, 52. - */ -export async function autoGenerateCurriculum(year) { - const currYear = year ?? getCurriculumYear(); - const topics = await db.getTopics(); - - // Filter out 'fact' type topics and 'exclude' relevance topics - const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'); - - const weeks = []; - const reviewWeeks = [13, 26, 39, 52]; - - // Distribute topics across the 48 non-review weeks. - let topicIndex = 0; - - for (let w = 1; w <= 52; w++) { - const quarter = getQuarterForWeek(w); - - if (reviewWeeks.includes(w)) { - // Review / recap week - weeks.push({ - week_number: w, - topic_id: '', - theme: `Q${quarter} Review`, - quarter, - is_review_week: true, - sort_order: w, - }); - } else if (topicIndex < learningTopics.length) { - const topic = learningTopics[topicIndex]; - weeks.push({ - week_number: w, - topic_id: topic.id, - theme: topic.type || 'General', - quarter, - is_review_week: false, - sort_order: w, - }); - topicIndex++; - } else if (learningTopics.length > 0) { - // If we have more weeks than topics, cycle through topics again - const topic = learningTopics[topicIndex % learningTopics.length]; - weeks.push({ - week_number: w, - topic_id: topic.id, - theme: `${topic.type || 'General'} (Deep Dive)`, - quarter, - is_review_week: false, - sort_order: w, - }); - topicIndex++; + for (let i = 0; i < (schedule || []).length; i++) { + const week = schedule[i]; + if (week.week_number !== i + 1) { + errors.push(`Week ${i + 1} has incorrect week_number: ${week.week_number}`); + } + if (week.estimated_duration < 15 || week.estimated_duration > 45) { + errors.push(`Week ${week.week_number} has out-of-range duration: ${week.estimated_duration}`); + } + if (!validThemes.has(week.theme)) { + errors.push(`Week ${week.week_number} references unknown theme: ${week.theme}`); + } + scheduledThemes.add(week.theme); + + if (!week.topic_ids || week.topic_ids.length === 0) { + errors.push(`Week ${week.week_number} has no topic_ids.`); } else { - // No topics at all - weeks.push({ - week_number: w, - topic_id: '', - theme: 'Unassigned', - quarter, - is_review_week: false, - sort_order: w, - }); + for (const tId of week.topic_ids) { + if (!validTopicIds.has(tId)) { + errors.push(`Week ${week.week_number} references unknown topic_id: ${tId}`); + } + } } } - await db.bulkSetCurriculum(currYear, weeks); - return weeks; + // Check coverage + for (const t of validThemes) { + if (!scheduledThemes.has(t)) { + errors.push(`Theme '${t}' is missing from the schedule.`); + } + } + + return { valid: errors.length === 0, errors }; } /** - * Check if a curriculum exists for the given year. + * Computes coverage stats for a schedule. */ -export async function hasCurriculum(year) { - const currYear = year ?? getCurriculumYear(); - const entries = await db.getCurriculum(currYear); - return entries.length > 0; +export function computeCoverageStats(schedule, topics) { + const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'); + const kbThemes = new Set(learningTopics.map(t => t.theme || 'General')); + + const scheduledThemes = new Set(); + const scheduledTopics = new Set(); + + for (const w of schedule || []) { + scheduledThemes.add(w.theme); + (w.topic_ids || []).forEach(t => scheduledTopics.add(t)); + } + + return { + themes_kb: kbThemes.size, + themes_scheduled: scheduledThemes.size, + topics_kb: learningTopics.length, + topics_scheduled: scheduledTopics.size, + }; +} + +/** + * Auto-generate a 26-week curriculum draft. + */ +export async function generateCurriculumDraft(reason) { + const topics = await db.getTopics(); + const themeMap = buildThemeTopicMap(topics); + + if (themeMap.size === 0) { + throw new Error('No valid topics or themes found to generate a curriculum.'); + } + + // Build the prompt context + let contextParts = []; + for (const [theme, themeTopics] of themeMap.entries()) { + const avgWeight = themeTopics.reduce((sum, t) => sum + (t.complexity_weight || 3), 0) / themeTopics.length; + let listStr = themeTopics.map((t, idx) => ` ${idx + 1}. ${t.id} (weight: ${t.complexity_weight || 3}, ${t.difficulty || 'intermediate'})`).join('\n'); + contextParts.push(`Theme "${theme}" (${themeTopics.length} topics, avg complexity ${avgWeight.toFixed(1)}):\n${listStr}`); + } + + const userPrompt = `KB Snapshot:\n${contextParts.join('\n\n')}\n\nGeneration reason: "${reason || 'Initial curriculum generation'}"`; + + const SYSTEM_PROMPT = `You are a curriculum architect for Respellion's internal learning platform. +You receive a knowledge base snapshot organized by themes, each containing an ordered list of topics. Produce a 26-week learning schedule. + +Rules: +- Exactly 26 week slots, numbered 1-26 +- Every theme must appear at least once +- Themes with more topics may span multiple weeks +- Introductory themes in the first half, advanced in the second half +- Complexity should increase progressively across the 26 weeks +- Each week: one theme, 1+ topic IDs (from that theme only), duration 15-45 min +- Include a one-sentence rationale per week explaining its position +- Do NOT invent theme or topic references — use only the provided values +- Emit via emit_curriculum_schedule tool — no prose`; + + // Try generation + let result; + try { + result = await callLLM({ + task: 'curriculum.generate', + tier: 'standard', + system: cachedSystem(SYSTEM_PROMPT), + user: userPrompt, + tools: [EMIT_CURRICULUM_SCHEDULE_TOOL], + toolChoice: { type: 'tool', name: EMIT_CURRICULUM_SCHEDULE_TOOL.name }, + maxTokens: 8192, + temperature: 0, + }); + } catch (err) { + throw new Error(`AI generation failed: ${err.message}`); + } + + const emitted = result.toolUses[0]?.input; + if (!emitted || !emitted.weeks) { + throw new Error('The AI did not emit a valid curriculum schedule.'); + } + + const schedule = emitted.weeks; + + // Validate + const validation = validateSchedule(schedule, topics); + if (!validation.valid) { + throw new Error(`Generated schedule failed validation:\n- ${validation.errors.join('\n- ')}`); + } + + const stats = computeCoverageStats(schedule, topics); + + // Reject any existing draft to enforce single-draft rule + const existingDraft = await db.getDraftCurriculumVersion(); + if (existingDraft) { + await db.updateCurriculumVersion(existingDraft.id, { status: 'superseded' }); + } + + const nextVersionNum = await db.getNextVersionNumber(); + + return db.createCurriculumVersion({ + version_number: nextVersionNum, + status: 'draft', + generation_reason: reason || '', + schedule: schedule, + coverage_stats: stats, + }); +} + +/** + * Confirm a draft curriculum version, making it active. + */ +export async function confirmVersion(versionId, adminUserId) { + const version = await db.getCurriculumVersion(versionId); + if (!version || version.status !== 'draft') { + throw new Error('Invalid version or not a draft.'); + } + + const currentActive = await db.getActiveCurriculumVersion(); + if (currentActive) { + await db.updateCurriculumVersion(currentActive.id, { status: 'superseded' }); + } + + return db.updateCurriculumVersion(versionId, { + status: 'active', + confirmed_by: adminUserId, + confirmed_at: new Date().toISOString(), + }); +} + +/** + * Reject a draft curriculum version. + */ +export async function rejectVersion(versionId) { + const version = await db.getCurriculumVersion(versionId); + if (!version || version.status !== 'draft') { + throw new Error('Invalid version or not a draft.'); + } + + return db.updateCurriculumVersion(versionId, { status: 'superseded' }); +} + +export async function getActiveVersion() { + return db.getActiveCurriculumVersion(); +} + +export async function getDraftVersion() { + return db.getDraftCurriculumVersion(); +} + +export async function getVersionHistory() { + return db.getCurriculumVersions(); +} + +/** + * Get the assigned topics and metadata for a given ISO week number. + */ +export async function getCurrentWeekContent(isoWeekNumber) { + const activeVersion = await db.getActiveCurriculumVersion(); + if (!activeVersion || !activeVersion.schedule) { + return null; + } + + const weekNumber = getCurriculumWeek(isoWeekNumber); + const cycle = getCurriculumCycle(isoWeekNumber); + + const scheduleWeek = activeVersion.schedule.find(w => w.week_number === weekNumber); + if (!scheduleWeek) return null; + + const topics = await db.getTopics(); + const weekTopics = scheduleWeek.topic_ids + .map(id => topics.find(t => t.id === id)) + .filter(Boolean); + + return { + cycle, + weekNumber, + theme: scheduleWeek.theme, + topics: weekTopics, + estimatedDuration: scheduleWeek.estimated_duration, + rationale: scheduleWeek.week_rationale + }; +} + +/** + * Track progress for the current cycle based on completed weeks. + */ +export async function getYearProgress(userId, isoWeekNumber) { + const activeVersion = await db.getActiveCurriculumVersion(); + if (!activeVersion) { + return { completed: 0, total: 26, percentage: 0 }; + } + + const currentCycle = getCurriculumCycle(isoWeekNumber); + const cycleStartWeek = (currentCycle - 1) * 26 + 1; + const cycleEndWeek = currentCycle * 26; + + let completed = 0; + for (let w = cycleStartWeek; w <= cycleEndWeek; w++) { + const done = await db.getLearnDone(userId, w); + if (done) completed++; + } + + return { + completed, + total: 26, + percentage: Math.round((completed / 26) * 100), + }; +} + +/** + * One-off AI backfill for theme, complexity_weight, difficulty. + */ +export async function enrichTopicsForCurriculum() { + const allTopics = await db.getTopics(); + const unenriched = allTopics.filter(t => !t.theme && t.type !== 'fact' && t.learning_relevance !== 'exclude'); + + if (unenriched.length === 0) { + return { enriched: 0, skipped: allTopics.length }; + } + + const BATCH_SIZE = 20; // enrich in batches to avoid token limits + let totalEnriched = 0; + + const SYSTEM = `You are an AI knowledge categorizer. Your task is to enrich a batch of topics with a theme (subject domain), complexity_weight (1-5), and difficulty (introductory, intermediate, advanced). Return the enriched data via emit_topic_enrichment tool.`; + + for (let i = 0; i < unenriched.length; i += BATCH_SIZE) { + const batch = unenriched.slice(i, i + BATCH_SIZE); + const batchJson = JSON.stringify(batch.map(t => ({ id: t.id, label: t.label, description: t.description }))); + + try { + const result = await callLLM({ + task: 'topic.enrich', + tier: 'standard', + system: cachedSystem(SYSTEM), + user: `Enrich these topics:\n${batchJson}`, + tools: [EMIT_TOPIC_ENRICHMENT_TOOL], + toolChoice: { type: 'tool', name: EMIT_TOPIC_ENRICHMENT_TOOL.name }, + maxTokens: 4096, + }); + + const enrichedBatch = result.toolUses[0]?.input?.topics; + if (enrichedBatch && Array.isArray(enrichedBatch)) { + for (const update of enrichedBatch) { + const original = allTopics.find(t => t.id === update.id); + if (original) { + await db.saveTopics([{ + ...original, + theme: update.theme, + complexity_weight: update.complexity_weight, + difficulty: update.difficulty + }]); + totalEnriched++; + } + } + } + } catch (err) { + console.warn('Batch enrichment failed:', err.message); + } + } + + return { enriched: totalEnriched, skipped: allTopics.length - totalEnriched }; } diff --git a/src/lib/db.js b/src/lib/db.js index dd8e05c..eb0fb93 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -246,8 +246,55 @@ export function setSetting(key, value) { return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(value) }); } -// ── Curriculum ──────────────────────────────────────────────────────────────── +// ── Curriculum Versions (v2) ────────────────────────────────────────────────── +export async function getCurriculumVersions(status) { + try { + const opts = { sort: '-version_number' }; + if (status) opts.filter = `status="${status}"`; + return await pb.collection('curriculum_versions').getFullList(opts); + } catch { return []; } +} + +export async function getCurriculumVersion(id) { + try { + return await pb.collection('curriculum_versions').getOne(id); + } catch { return null; } +} + +export async function getActiveCurriculumVersion() { + try { + return await pb.collection('curriculum_versions').getFirstListItem('status="active"'); + } catch { return null; } +} + +export async function getDraftCurriculumVersion() { + try { + return await pb.collection('curriculum_versions').getFirstListItem('status="draft"'); + } catch { return null; } +} + +export async function createCurriculumVersion(data) { + return pb.collection('curriculum_versions').create(data); +} + +export async function updateCurriculumVersion(id, data) { + return pb.collection('curriculum_versions').update(id, data); +} + +export async function getNextVersionNumber() { + try { + const latest = await pb.collection('curriculum_versions').getFirstListItem('', { + sort: '-version_number', + fields: 'version_number', + }); + return (latest?.version_number || 0) + 1; + } catch { return 1; } +} + +// ── Curriculum (legacy, v1 — deprecated) ────────────────────────────────────── + +/** @deprecated Use curriculum_versions (v2) instead. */ export async function getCurriculum(year) { try { return await pb.collection('curriculum').getFullList({ @@ -257,6 +304,7 @@ export async function getCurriculum(year) { } catch { return []; } } +/** @deprecated Use curriculum_versions (v2) instead. */ export async function getCurriculumWeek(year, weekNumber) { try { return await pb.collection('curriculum').getFirstListItem( @@ -265,11 +313,13 @@ export async function getCurriculumWeek(year, weekNumber) { } catch { return null; } } +/** @deprecated Use curriculum_versions (v2) instead. */ export function setCurriculumWeek(year, weekNumber, data) { return pbUpsert('curriculum', `year=${year} && week_number=${weekNumber}`, data, { year, week_number: weekNumber, ...data }); } +/** @deprecated Use curriculum_versions (v2) instead. */ export async function deleteCurriculumWeek(year, weekNumber) { try { const r = await pb.collection('curriculum').getFirstListItem( @@ -279,6 +329,7 @@ export async function deleteCurriculumWeek(year, weekNumber) { } catch { /* nothing to delete */ } } +/** @deprecated Use curriculum_versions (v2) instead. */ export async function bulkSetCurriculum(year, weeks) { // Delete all existing entries for this year first const existing = await getCurriculum(year); diff --git a/src/lib/learningService.js b/src/lib/learningService.js index 7415b9a..0e11b5d 100644 --- a/src/lib/learningService.js +++ b/src/lib/learningService.js @@ -9,7 +9,7 @@ import { ARTICLE_PATCH_TOOLS, } from './llmTools'; import { applyAndValidate } from './articlePatches'; -import { getCurriculumTopic } from './curriculumService'; +import { getCurrentWeekContent } 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. @@ -32,23 +32,27 @@ const INSTRUCTIONS_BY_TYPE = { }; /** - * Get the assigned topic for a given week. - * Curriculum-first: checks the curriculum collection for the current year. + * Get the assigned primary topic for a given week. + * Curriculum v2: checks the active curriculum version for the given ISO week. * Falls back to hash-based assignment if no curriculum is configured. */ -export async function getAssignedTopic(userId, weekNumber) { +export async function getAssignedTopic(userId, isoWeekNumber) { try { - const { topic } = await getCurriculumTopic(weekNumber); - if (topic && topic.learning_relevance !== 'exclude') return topic; + const weekContent = await getCurrentWeekContent(isoWeekNumber); + if (weekContent && weekContent.topics && weekContent.topics.length > 0) { + // For single-topic compatibility, return the first topic + return weekContent.topics[0]; + } } catch (e) { console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message); } + // Fallback hash-based assignment 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}`; + const str = `${userId}:${isoWeekNumber}`; let hash = 0; for (let i = 0; i < str.length; i++) { hash = (hash << 5) - hash + str.charCodeAt(i); @@ -58,6 +62,24 @@ export async function getAssignedTopic(userId, weekNumber) { return topics[index]; } +/** + * Get all assigned topics for a given week. + */ +export async function getAssignedTopics(userId, isoWeekNumber) { + try { + const weekContent = await getCurrentWeekContent(isoWeekNumber); + if (weekContent && weekContent.topics && weekContent.topics.length > 0) { + return weekContent.topics; + } + } catch (e) { + console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message); + } + + // Fallback hash-based assignment + const topic = await getAssignedTopic(userId, isoWeekNumber); + return topic ? [topic] : []; +} + export async function getCachedContent(topicId) { return db.getContent(topicId); } diff --git a/src/lib/llm.js b/src/lib/llm.js index b230e09..b9d5fd4 100644 --- a/src/lib/llm.js +++ b/src/lib/llm.js @@ -177,6 +177,22 @@ const SIMULATION_INFOGRAPHIC = { const SIMULATION_TOOL_STUBS = { emit_knowledge_graph: SIMULATION_EXTRACTION_GRAPH, + emit_curriculum_schedule: { + weeks: Array.from({ length: 26 }, (_, i) => ({ + week_number: i + 1, + theme: i < 13 ? 'Privacy' : 'Governance', + topic_ids: ['sim-topic'], + estimated_duration: 30, + week_rationale: `Simulated rationale for week ${i + 1}.` + })) + }, + emit_topic_enrichment: { + topics: [ + { id: 'radicale-transparantie', theme: 'Culture', complexity_weight: 2, difficulty: 'introductory' }, + { id: 'kennisbeheer', theme: 'Process', complexity_weight: 4, difficulty: 'advanced' }, + { id: 'wekelijkse-sessie', theme: 'Process', complexity_weight: 3, difficulty: 'intermediate' }, + ] + }, emit_learning_article: { article: SIMULATION_ARTICLE }, emit_learning_slides: { slides: [SIMULATION_SLIDE] }, diff --git a/src/lib/llmSchemas.js b/src/lib/llmSchemas.js index 359faad..6e84f29 100644 --- a/src/lib/llmSchemas.js +++ b/src/lib/llmSchemas.js @@ -30,6 +30,29 @@ export const extractionResultSchema = z.object({ relations: z.array(extractionRelationSchema), }); +const curriculumWeekSchema = z.object({ + week_number: z.number().int().min(1).max(26), + theme: z.string().min(1), + topic_ids: z.array(z.string().min(1)).min(1), + estimated_duration: z.number().int().min(15).max(45), + week_rationale: z.string().min(1), +}); + +export const curriculumScheduleSchema = z.object({ + weeks: z.array(curriculumWeekSchema).length(26), +}); + +const topicEnrichmentSchemaDef = z.object({ + id: z.string().min(1), + theme: z.string().min(1), + complexity_weight: z.number().int().min(1).max(5), + difficulty: z.enum(['introductory', 'intermediate', 'advanced']), +}); + +export const topicEnrichmentSchema = z.object({ + topics: z.array(topicEnrichmentSchemaDef).min(1), +}); + const articleSectionSchema = z.object({ heading: z.string().min(1), @@ -185,6 +208,8 @@ export const replaceTakeawaysPatchSchema = z.object({ */ export const toolSchemaRegistry = { emit_knowledge_graph: extractionResultSchema, + emit_curriculum_schedule: curriculumScheduleSchema, + emit_topic_enrichment: topicEnrichmentSchema, emit_learning_article: learningArticleSchema, emit_learning_slides: learningSlidesSchema, emit_learning_infographic: learningInfographicSchema, diff --git a/src/lib/llmTools.js b/src/lib/llmTools.js index b7f03db..1a972c9 100644 --- a/src/lib/llmTools.js +++ b/src/lib/llmTools.js @@ -46,6 +46,58 @@ export const EMIT_KNOWLEDGE_GRAPH_TOOL = { }, }; +export const EMIT_CURRICULUM_SCHEDULE_TOOL = { + name: 'emit_curriculum_schedule', + description: 'Emit a 26-week curriculum schedule. One theme per week, with an ordered subset of topics from that theme.', + input_schema: { + type: 'object', + properties: { + weeks: { + type: 'array', + items: { + type: 'object', + properties: { + week_number: { type: 'integer', minimum: 1, maximum: 26 }, + theme: { type: 'string' }, + topic_ids: { type: 'array', items: { type: 'string' }, minItems: 1 }, + estimated_duration: { type: 'integer', minimum: 15, maximum: 45 }, + week_rationale: { type: 'string' }, + }, + required: ['week_number', 'theme', 'topic_ids', 'estimated_duration', 'week_rationale'], + }, + minItems: 26, + maxItems: 26, + }, + }, + required: ['weeks'], + }, +}; + +export const EMIT_TOPIC_ENRICHMENT_TOOL = { + name: 'emit_topic_enrichment', + description: 'Enrich a batch of topics with a theme, complexity_weight, and difficulty.', + input_schema: { + type: 'object', + properties: { + topics: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + theme: { type: 'string' }, + complexity_weight: { type: 'integer', minimum: 1, maximum: 5 }, + difficulty: { type: 'string', enum: ['introductory', 'intermediate', 'advanced'] }, + }, + required: ['id', 'theme', 'complexity_weight', 'difficulty'], + }, + minItems: 1, + }, + }, + required: ['topics'], + }, +}; + const articleSectionSchema = { type: 'object', diff --git a/src/lib/testService.js b/src/lib/testService.js index 7d3e4b2..ee5fcbc 100644 --- a/src/lib/testService.js +++ b/src/lib/testService.js @@ -1,7 +1,7 @@ import * as db from './db'; import { callLLM, cachedSystem } from './llm'; import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools'; -import { getCurriculumTopic, getQuarterForWeek } from './curriculumService'; +import { getCurrentWeekContent } from './curriculumService'; import { shuffle, sample } from './random'; const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform. @@ -85,38 +85,26 @@ Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and return emitted.questions; } -async function selectTestTopics(userId, weekNumber) { +async function selectTestTopics(userId, isoWeekNumber) { const allTopics = await db.getTopics(); const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'); if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false }; try { - const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber); + const weekContent = await getCurrentWeekContent(isoWeekNumber); - if (curriculumEntry?.is_review_week) { - const quarter = getQuarterForWeek(weekNumber); - const curriculum = await db.getCurriculum(new Date().getFullYear()); - const quarterTopicIds = curriculum - .filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week) - .map(w => w.topic_id); - const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id)); - return { - primaryTopic: quarterTopics[0] || topics[0], - reviewTopics: quarterTopics.slice(1), - isReviewWeek: true, - }; - } - - if (topic) { - const others = topics.filter(t => t.id !== topic.id); + if (weekContent && weekContent.topics && weekContent.topics.length > 0) { + const primaryTopic = weekContent.topics[0]; // Use first topic as primary for now + const others = topics.filter(t => t.id !== primaryTopic.id); const reviewTopics = sample(others, Math.min(5, others.length)); - return { primaryTopic: topic, reviewTopics, isReviewWeek: false }; + return { primaryTopic, reviewTopics, isReviewWeek: false }; } } catch (e) { console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message); } - const str = `${userId}:${weekNumber}`; + // Fallback hash-based assignment + const str = `${userId}:${isoWeekNumber}`; let hash = 0; for (let i = 0; i < str.length; i++) { hash = (hash << 5) - hash + str.charCodeAt(i); diff --git a/src/pages/Admin/index.jsx b/src/pages/Admin/index.jsx index 9a587d6..6100a25 100644 --- a/src/pages/Admin/index.jsx +++ b/src/pages/Admin/index.jsx @@ -178,8 +178,8 @@ const Admin = () => { {activeTab === 'curriculum' && (
-

Annual Curriculum

-

Plan and manage the 52-week learning schedule. All employees follow the same weekly topic.

+

Curriculum

+

AI-generated 26-week learning cycle. Review and activate curriculum versions.

)} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index de22cac..0b2e18f 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -6,7 +6,7 @@ import Button from '../components/ui/Button'; import Tag from '../components/ui/Tag'; import * as db from '../lib/db'; import { getAssignedTopic } from '../lib/learningService'; -import { getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService'; +import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService'; const Dashboard = () => { const { state } = useApp(); @@ -22,6 +22,7 @@ const Dashboard = () => { activity: [], yearProgress: null, hasCurriculum: false, + theme: '', }); useEffect(() => { @@ -57,23 +58,35 @@ const Dashboard = () => { let yearProgress = null; let curriculumExists = false; try { - curriculumExists = await checkHasCurriculum(); + const activeVersion = await getActiveVersion(); + curriculumExists = !!activeVersion; if (curriculumExists) { - yearProgress = await getYearProgress(currentUser.id); + yearProgress = await getYearProgress(currentUser.id, weekNumber); } } catch (e) { console.warn('[Dashboard] Could not load curriculum data:', e.message); } - setDashData({ topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumExists }); + setDashData({ + topic, + learnDone, + testResult, + top3, + myRank, + myPoints, + activity, + yearProgress, + hasCurriculum: curriculumExists, + theme: topic?.theme || '', + }); }; load(); }, [currentUser, weekNumber]); - const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive } = dashData; - const currentQuarter = getQuarterForWeek(weekNumber); - const quarterName = getQuarterName(weekNumber); + const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive, theme } = dashData; + const currentCycle = getCurriculumCycle(weekNumber); + const currWeek = getCurriculumWeek(weekNumber); return (
@@ -81,12 +94,12 @@ const Dashboard = () => {

Welcome, {currentUser?.name}

{curriculumActive - ? `Week ${weekNumber} · ${quarterName}` + ? `Cycle ${currentCycle} · Week ${currWeek} of 26` : `Here is your overview for week ${weekNumber}.`}

- {/* Annual Progress Bar (only when curriculum exists) */} + {/* Cycle Progress Bar (only when curriculum exists) */} {curriculumActive && yearProgress && (
@@ -107,21 +120,21 @@ const Dashboard = () => {
-

Annual Progress

+

Cycle Progress

{yearProgress.completed} of {yearProgress.total} weeks completed

- Q{currentQuarter} - {52 - weekNumber} weeks remaining + Cycle {currentCycle} + {26 - currWeek} weeks remaining
{/* Visual week progress bar */}
- {Array.from({ length: 52 }, (_, i) => { + {Array.from({ length: 26 }, (_, i) => { const w = i + 1; - const isCurrent = w === weekNumber; - const isPast = w < weekNumber; + const isCurrent = w === currWeek; + const isPast = w < currWeek; return (
{ @@ -17,7 +17,7 @@ const Leren = () => { const [allTopics, setAllTopics] = useState([]); // View state - const [view, setView] = useState('overview'); // overview, detail, creating + const [view, setView] = useState('overview'); // overview, detail const [activeTopic, setActiveTopic] = useState(null); const [content, setContent] = useState(null); @@ -25,9 +25,6 @@ const Leren = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - // Custom Topic - const [customTopicQuery] = useState(''); - // Weekly status const [weeklyDone, setWeeklyDone] = useState(false); const [sessionDone, setSessionDone] = useState(false); @@ -39,9 +36,8 @@ const Leren = () => { // Curriculum state const [hasCurriculum, setHasCurriculum] = useState(false); - const [upcoming, setUpcoming] = useState([]); - const [quarterProgress, setQuarterProgress] = useState(null); const [yearProgress, setYearProgress] = useState(null); + const [weekContent, setWeekContent] = useState(null); useEffect(() => { if (state.currentUser) { @@ -57,17 +53,15 @@ const Leren = () => { // Load curriculum data try { - const currExists = await checkHasCurriculum(); - setHasCurriculum(currExists); - if (currExists) { - const [upcomingData, qProgress, yProgress] = await Promise.all([ - getUpcomingWeeks(state.weekNumber, 4), - getQuarterProgress(state.currentUser.id, getQuarterForWeek(state.weekNumber)), - getYearProgress(state.currentUser.id), + const activeVersion = await getActiveVersion(); + setHasCurriculum(!!activeVersion); + if (activeVersion) { + const [yProgress, wc] = await Promise.all([ + getYearProgress(state.currentUser.id, state.weekNumber), + getCurrentWeekContent(state.weekNumber), ]); - setUpcoming(upcomingData); - setQuarterProgress(qProgress); setYearProgress(yProgress); + setWeekContent(wc); } } catch (e) { console.warn('[Learn] Could not load curriculum data:', e.message); @@ -262,21 +256,10 @@ const Leren = () => { ); } - // ── Creating Topic Loading ───────────────────────────────── - if (view === 'creating') { - return ( -
- -

Architecting New Topic

-

The AI is creating a structure for "{customTopicQuery}"...

-
- ); - } - // ── Overview ────────────────────────────────────────────── const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact' && t.learning_relevance !== 'exclude'); - const currentQuarter = getQuarterForWeek(state.weekNumber); - const currentQuarterName = getQuarterName(state.weekNumber); + const currentCycle = getCurriculumCycle(state.weekNumber); + const currWeek = getCurriculumWeek(state.weekNumber); return (
@@ -284,7 +267,7 @@ const Leren = () => {

Learning Station

{hasCurriculum - ? `Week ${state.weekNumber} · ${currentQuarterName}` + ? `Cycle ${currentCycle} · Week ${currWeek} of 26` : 'Complete at least 1 topic per week. Explore more from the library!'}

@@ -296,9 +279,9 @@ const Leren = () => { )} {/* Progress Cards (only shown when curriculum exists) */} - {hasCurriculum && yearProgress && quarterProgress && ( + {hasCurriculum && yearProgress && (
- {/* Year Progress */} + {/* Cycle Progress */}
@@ -315,45 +298,22 @@ const Leren = () => { {yearProgress.percentage}%
-
Annual Progress
+
Cycle Progress
{yearProgress.completed}/{yearProgress.total} weeks
- {/* Quarter Progress */} - -
- - - - - - {quarterProgress.percentage}% - -
-
Q{currentQuarter} Progress
-
{quarterProgress.completed}/{quarterProgress.total} weeks
-
- {/* Current Week */} -
{state.weekNumber}
+
{currWeek}
Current Week
- {/* Status */} - - -
- {weeklyDone ? 'Complete' : 'In Progress'} -
-
This Week
+ {/* Theme */} + + +
{weekContent?.theme || 'General'}
+
Current Theme
)} @@ -376,7 +336,7 @@ const Leren = () => { {weeklyDone ? 'Completed' : 'Required'} {hasCurriculum && ( - Week {state.weekNumber} + Theme: {weekContent?.theme || 'General'} )}

{assignedTopic.label}

@@ -390,35 +350,6 @@ const Leren = () => {
)} - {/* Upcoming Schedule (only when curriculum exists) */} - {hasCurriculum && upcoming.length > 0 && ( -
-

- Coming Up -

-
- {upcoming.map(week => ( - -
- Week {week.week_number} - {week.is_review_week && Review} -
- {week.topic ? ( - <> -

{week.topic.label}

-

{week.theme}

- - ) : week.is_review_week ? ( -

{week.theme}

- ) : ( -

Unassigned

- )} -
- ))} -
-
- )} - {/* Other Available Topics */} {otherTopics.length > 0 && (
diff --git a/src/store/AppContext.jsx b/src/store/AppContext.jsx index 0ece8b1..4eeddd6 100644 --- a/src/store/AppContext.jsx +++ b/src/store/AppContext.jsx @@ -23,8 +23,6 @@ function appReducer(state, action) { return { ...state, currentUser: action.payload }; case 'LOGOUT': return { ...state, currentUser: null }; - case 'ADVANCE_WEEK': - return { ...state, weekNumber: state.weekNumber + 1 }; default: return state; }