From 8e01b21a505ecbf38eaeb827eb90c595a6e5d1b4 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 16:59:11 +0200 Subject: [PATCH 01/27] feat: implement RAG-enabled chat hook and admin file upload component --- AI_AGENT.md | 12 ++++----- README.md | 9 +++---- src/components/admin/UploadZone.jsx | 9 ++++++- src/components/chat/useChat.js | 15 +++++++++-- src/lib/api.js | 39 ----------------------------- 5 files changed, 30 insertions(+), 54 deletions(-) delete mode 100644 src/lib/api.js diff --git a/AI_AGENT.md b/AI_AGENT.md index 9342334..90567d7 100644 --- a/AI_AGENT.md +++ b/AI_AGENT.md @@ -49,7 +49,7 @@ All persistent data lives in **PocketBase**. The data access layer is in `src/li ## 3. The AI Integration (Anthropic) The application calls the Anthropic API via a proxy to avoid CORS issues. -* **Location:** `src/lib/api.js`. +* **Location:** `src/lib/llm.js`. * **Proxy:** In Docker, `/api/anthropic` is proxied via Caddy to the Anthropic API endpoint. In local dev, configure `vite.config.js` to proxy the same path. The API key is injected server-side by Caddy; there is **no client-side API key**. * **Token limit:** `generateContent` uses `max_tokens: 8192`. Do not lower this — large knowledge-base files need the headroom. * **JSON Enforcement:** Prompts *must* strictly enforce that Claude returns *only* raw JSON. Do not let the AI wrap the response in markdown blocks — a regex strip via `.match(/\{[\s\S]*\}/)` is already applied in all service files. @@ -90,12 +90,10 @@ The app is fully containerized. PocketBase runs as a sidecar service. * **Caddy (reverse proxy):** Handles SPA fallback, injects the Anthropic API key via a `Authorization` header on `/api/anthropic/*` requests, and proxies `/pb/*` to the PocketBase service. Config lives in `Caddyfile`. * **PocketBase URL:** Resolved from `VITE_PB_URL` env var, or falls back to `window.location.origin + '/pb'` (see `src/lib/pb.js`). -## 8. GitHub Knowledge-Base Sync -The Admin upload panel (`src/components/admin/UploadZone.jsx`) can sync markdown/text files directly from a GitHub repository. +## 8. Local File Upload +The Admin upload panel (`src/components/admin/UploadZone.jsx`) allows admins to manually upload markdown/text files via a drag-and-drop interface. -* **Default folder:** `docs/knowledge-base/` of the `respellion/employee-handbook` repo. This is persisted in the `settings` collection under the key `github:url` so admins can change it from the UI. -* **Change detection:** Each file's SHA is stored as `github:sha:` in `settings`. Files whose SHA differs are marked *Updated*; new files are *New*. Up-to-date files are skipped. -* **Extraction pipeline (`src/lib/extractionPipeline.js`):** Calls `anthropicApi.generateContent` with a strict JSON-only system prompt. To prevent truncated responses on large files, the prompt limits extraction to **max 15 topics** and their most important relations. If the AI returns non-JSON, the file is marked `failed` in the `sources` collection. +* **Extraction pipeline (`src/lib/extractionPipeline.js`):** Calls `callLLM` with a strict JSON-only system prompt. To prevent truncated responses on large files, the prompt limits extraction to **max 15 topics** and their most important relations. If the AI returns non-JSON, the file is marked `failed` in the `sources` collection. * **Deduplication:** A file already in `sources` with `status: completed` will throw and not be re-processed. Delete the source record first to force a re-analysis. * **Do not increase topic cap beyond 15** without also verifying the `max_tokens: 8192` budget is sufficient for the expected file size. @@ -109,7 +107,7 @@ The platform ships a global chatbot avatar called **R42**, rendered as the Respe * `useChat.js` — owns the message list, persists to `chat:thread:{userId}`, calls `anthropicApi.chat()`. `buildKbContext` is async (PocketBase), so `send()` is fully async. * `prompts.js` — system prompt, greeting, and the `propose_graph_delta` tool spec. * `rag.js` — fetches `kb:topics` + `kb:relations` from PocketBase; lazy-loads `kb:content:{id}` only when a topic is mentioned. Returns `{ context, topics }` so `validateDelta` can reuse the fetched topics without a second round-trip. -* **Multi-turn API:** `anthropicApi.chat(systemPrompt, messages, { tools })` in `src/lib/api.js`. Returns the raw Anthropic response (`{ content: [...], stop_reason }`) so callers can read both text blocks and `tool_use` blocks. No API key header — Caddy proxy injects it server-side, matching the existing `generateContent` pattern. +* **Multi-turn API:** `callLLM({ task, system, messages, tools })` in `src/lib/llm.js`. Returns a structured response containing extracted `toolUses` and text. No API key header — Caddy proxy injects it server-side. * **Quiz-integrity rule:** `src/pages/Testen.jsx` sets `quiz:active:{userId}=true` on start and clears it on every non-quiz phase + unmount, plus dispatches a `respellion:quiz-state` event. Never bypass this — letting users ask R42 mid-quiz would break scoring. * **Graph refinement:** when R42's `tool_use` block proposes a `propose_graph_delta`, `rag.js` validates (no duplicate ids, no orphan relations, caps 3 topics / 5 relations) and surfaces a confirmation chip inline. * **Admin user clicks Ja** — `kbStore.applyDelta` writes to PocketBase via `db.upsertTopic` / `db.addRelation` immediately. diff --git a/README.md b/README.md index 2c7f63c..0a22f45 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ An internal AI-powered learning platform that keeps Respellion employees up to d - **Weekly Test** — AI-generated quiz based on the knowledge graph. Results are stored and feed the leaderboard. - **Leaderboard & Gamification** — Points for correct answers, badges for streaks and perfect scores. - **R42 Chatbot** — An always-available AI assistant (backed by Claude) with access to the full knowledge graph. Can propose graph updates that admins approve or reject. -- **Admin Panel** — Manage the knowledge graph, sync from GitHub, review generated content, refine it with AI, and monitor team progress. +- **Admin Panel** — Manage the knowledge graph, upload source files, review generated content, refine it with AI, and monitor team progress. ## Tech Stack @@ -54,12 +54,11 @@ The `Caddyfile` handles: | File | Purpose | |---|---| | `src/lib/learningService.js` | Selective content generation (article / slides / infographic) | -| `src/lib/extractionPipeline.js` | GitHub file → knowledge graph extraction | -| `src/lib/api.js` | Anthropic API wrapper (`generateContent` + `chat`) | +| `src/lib/extractionPipeline.js` | Uploaded file → knowledge graph extraction | +| `src/lib/llm.js` | Core Anthropic LLM wrapper | | `src/lib/db.js` | All PocketBase data access | -| `src/lib/giteaService.js` | GitHub API client (folder listing + raw file fetch) | | `src/store/AppContext.jsx` | Global state; computes ISO week number on load | -| `src/components/admin/UploadZone.jsx` | GitHub sync UI (default folder: `docs/knowledge-base/`) | +| `src/components/admin/UploadZone.jsx` | Drag-and-drop file upload UI | | `AI_AGENT.md` | Detailed context guide for AI coding agents | ## Content Types diff --git a/src/components/admin/UploadZone.jsx b/src/components/admin/UploadZone.jsx index c6a3fb9..b17e447 100644 --- a/src/components/admin/UploadZone.jsx +++ b/src/components/admin/UploadZone.jsx @@ -36,9 +36,16 @@ const UploadZone = ({ onUploadComplete }) => { }) .catch((err) => { const isCancelled = err?.name === 'AbortError'; + let errorMsg = err?.message || 'Unknown error'; + if (err?.name === 'LLMTruncatedError') { + errorMsg = 'File is too large for the AI context window. Please split into smaller chunks.'; + } else if (err?.name === 'LLMValidationError') { + errorMsg = 'AI output was malformed (not JSON). Please try again.'; + } + setQueue((q) => q.map((item) => item.id === next.id - ? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : err.message } + ? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : errorMsg } : item )); }) diff --git a/src/components/chat/useChat.js b/src/components/chat/useChat.js index f1341f1..788bddd 100644 --- a/src/components/chat/useChat.js +++ b/src/components/chat/useChat.js @@ -193,13 +193,24 @@ export function useChat({ user, isAdmin }) { } catch (e) { console.error('[R42] chat error', e); setErrored(true); - const isKey = /api key/i.test(e?.message || ''); + + let errorContent = STRINGS.errorGeneric; + const errorMsg = e?.message || ''; + + if (e?.name === 'LLMTruncatedError') { + errorContent = 'Mijn circuits zijn overbelast (Token Limiet bereikt). Kun je je vraag korter of specifieker maken?'; + } else if (e?.name === 'LLMValidationError') { + errorContent = 'Mijn antwoord was helaas beschadigd of incorrect geformatteerd. Kun je het nog eens proberen?'; + } else if (/api key/i.test(errorMsg)) { + errorContent = STRINGS.errorNoKey; + } + setMessages(prev => [ ...prev, { id: `m_${Date.now()}_e`, role: 'error', - content: isKey ? STRINGS.errorNoKey : STRINGS.errorGeneric, + content: errorContent, ts: Date.now(), }, ]); diff --git a/src/lib/api.js b/src/lib/api.js deleted file mode 100644 index 67a5354..0000000 --- a/src/lib/api.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Back-compatibility shim for the legacy `anthropicApi` interface. - * - * All real work lives in `./llm.js`. Existing callers (extractionPipeline, - * learningService, testService, KnowledgeGraph, useChat) keep working - * unchanged; new code should import `callLLM` from `./llm.js` directly. - */ - -import { callLLM } from './llm'; - -export const anthropicApi = { - async generateContent(systemPrompt, userMessage /*, maxRetries */) { - const { text } = await callLLM({ - task: 'legacy.generateContent', - tier: 'standard', - system: systemPrompt, - user: userMessage, - maxTokens: 8192, - temperature: 0, - }); - return text; - }, - - async chat(systemPrompt, messages, opts = {}) { - const r = await callLLM({ - task: 'legacy.chat', - tier: 'standard', - system: systemPrompt, - messages, - tools: opts.tools, - maxTokens: 1024, - temperature: 0.3, - }); - const content = []; - if (r.text) content.push({ type: 'text', text: r.text }); - for (const tu of r.toolUses) content.push({ type: 'tool_use', name: tu.name, input: tu.input }); - return { content, stop_reason: r.stopReason }; - }, -}; From c5e23c77cdb902de793aef9277cd0b2699803217 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 19:50:20 +0200 Subject: [PATCH 02/27] 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; } From 10d5066be84075b72c1f51f1de2a885f1a37121a Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 19:58:18 +0200 Subject: [PATCH 03/27] feat: add curriculum management service and database integration for 26-week schedule generation --- src/lib/curriculumService.js | 96 ++++++++++++++++++++++++------------ src/lib/db.js | 3 ++ 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/src/lib/curriculumService.js b/src/lib/curriculumService.js index 4816b94..37d5335 100644 --- a/src/lib/curriculumService.js +++ b/src/lib/curriculumService.js @@ -147,34 +147,56 @@ Rules: - Do NOT invent theme or topic references — use only the provided values - Emit via emit_curriculum_schedule tool — no prose`; - // Try generation + // Try generation with a retry mechanism if validation fails 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}`); + let schedule; + let validationErrors = []; + + for (let attempt = 1; attempt <= 2; attempt++) { + let prompt = userPrompt; + if (attempt > 1) { + prompt = `${userPrompt}\n\nWARNING: The previous generation attempt failed validation with the following errors. Please correct them:\n- ${validationErrors.join('\n- ')}`; + } + + try { + result = await callLLM({ + task: 'curriculum.generate', + tier: 'standard', + system: cachedSystem(SYSTEM_PROMPT), + user: prompt, + tools: [EMIT_CURRICULUM_SCHEDULE_TOOL], + toolChoice: { type: 'tool', name: EMIT_CURRICULUM_SCHEDULE_TOOL.name }, + maxTokens: 8192, + temperature: 0, + }); + } catch (err) { + if (attempt === 2) { + throw new Error(`AI generation failed: ${err.message}`); + } + continue; + } + + const emitted = result.toolUses[0]?.input; + if (!emitted || !emitted.weeks) { + validationErrors = ['The AI did not emit a valid curriculum schedule structure.']; + if (attempt === 2) { + throw new Error('The AI did not emit a valid curriculum schedule.'); + } + continue; + } + + schedule = emitted.weeks; + const validation = validateSchedule(schedule, topics); + if (validation.valid) { + validationErrors = []; + break; + } else { + validationErrors = validation.errors; + } } - 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- ')}`); + if (validationErrors.length > 0) { + throw new Error(`Generated schedule failed validation after retry:\n- ${validationErrors.join('\n- ')}`); } const stats = computeCoverageStats(schedule, topics); @@ -206,15 +228,24 @@ export async function confirmVersion(versionId, adminUserId) { } const currentActive = await db.getActiveCurriculumVersion(); - if (currentActive) { - await db.updateCurriculumVersion(currentActive.id, { status: 'superseded' }); - } - return db.updateCurriculumVersion(versionId, { + // Set the new version to active first to ensure we never have zero active versions + const updated = await db.updateCurriculumVersion(versionId, { status: 'active', confirmed_by: adminUserId, confirmed_at: new Date().toISOString(), }); + + // Supercede old active version gracefully + if (currentActive) { + try { + await db.updateCurriculumVersion(currentActive.id, { status: 'superseded' }); + } catch (e) { + console.warn('[Curriculum] Failed to supersede old active version, but new version was successfully activated:', e.message); + } + } + + return updated; } /** @@ -257,8 +288,9 @@ export async function getCurrentWeekContent(isoWeekNumber) { if (!scheduleWeek) return null; const topics = await db.getTopics(); + const topicMap = new Map(topics.map(t => [t.id, t])); const weekTopics = scheduleWeek.topic_ids - .map(id => topics.find(t => t.id === id)) + .map(id => topicMap.get(id)) .filter(Boolean); return { @@ -333,12 +365,12 @@ export async function enrichTopicsForCurriculum() { for (const update of enrichedBatch) { const original = allTopics.find(t => t.id === update.id); if (original) { - await db.saveTopics([{ + await db.upsertTopic({ ...original, theme: update.theme, complexity_weight: update.complexity_weight, difficulty: update.difficulty - }]); + }); totalEnriched++; } } diff --git a/src/lib/db.js b/src/lib/db.js index eb0fb93..b3dabb2 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -29,6 +29,9 @@ export async function saveTopics(topics) { description: t.description, learning_relevance: t.learning_relevance || 'standard', relevance_locked: t.relevance_locked === true, + theme: t.theme || '', + complexity_weight: t.complexity_weight || 3, + difficulty: t.difficulty || 'intermediate', }, { requestKey: null }); } } From 5965793f115bd2c3084185df1419d5a3c266326a Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 21:14:32 +0200 Subject: [PATCH 04/27] feat: add specification for micro learning generation and remove deprecated pipeline planning files --- AI_PIPELINE_HARDENING_PLAN.md | 573 ------------------------------ curriculum-spec-agnostic.md | 334 ----------------- micro-learning-generation-spec.md | 556 +++++++++++++++++++++++++++++ 3 files changed, 556 insertions(+), 907 deletions(-) delete mode 100644 AI_PIPELINE_HARDENING_PLAN.md delete mode 100644 curriculum-spec-agnostic.md create mode 100644 micro-learning-generation-spec.md diff --git a/AI_PIPELINE_HARDENING_PLAN.md b/AI_PIPELINE_HARDENING_PLAN.md deleted file mode 100644 index 9394bdf..0000000 --- a/AI_PIPELINE_HARDENING_PLAN.md +++ /dev/null @@ -1,573 +0,0 @@ -# AI Pipeline Hardening — Implementation Plan - -> **Audience:** an AI agent executing this plan against the Respellion Learning Platform. -> **Owner before this work:** Raymond Verhoef (rve@respellion.nl). -> **Source of truth for repo conventions:** [`AI_AGENT.md`](AI_AGENT.md). Read it before starting. - -This plan upgrades the platform's interaction with the Anthropic API: how prompts are built, how responses are parsed, how the model is retried, and how outputs are validated. It is broken into six phases that can be implemented and shipped independently. Each phase ends with verifiable acceptance criteria. - -> **Status (2026-05-20):** Phases 1–5 implemented and shipped. Phase 6 (eval harness) is intentionally **out of scope** for this initiative — the production pipeline is hardened to the level the platform needs, and a golden-set runner can be reopened later as a stand-alone task if regression risk grows. The repo no longer carries any TODO from this plan. - ---- - -## 0. Operating principles - -These rules govern every phase. Re-read them before you commit. - -1. **PocketBase is the source of truth.** No persistent state in localStorage (see [`AI_AGENT.md`](AI_AGENT.md) §2). The Anthropic API is proxied via Caddy; **never** add `x-api-key` headers in frontend code. -2. **No behaviour regressions.** Existing UI flows (extraction, weekly learning, weekly quiz, R42 chat, handbook sync, analyze-graph) must keep working after every phase. Phases are additive. -3. **Schema-first.** Where the model produces structured output, define a JSON Schema (Zod) and validate every response. Reject (don't paper over) malformed output. -4. **Single LLM entry point.** After Phase 1 there is exactly one module that talks to `/api/anthropic/v1/messages`. All callers go through it. -5. **No silent truncation.** If `stop_reason === 'max_tokens'` on a structured-output call, throw — never persist a partial parse. -6. **Cache what is stable, vary what is dynamic.** Use prompt caching for system prompts and KB context. User messages are never cached. -7. **Comments and docs:** follow the repo's terse style. Don't add explanatory comments unless the *why* is non-obvious. -8. **Migrations:** when changing a PocketBase schema, add a migration in `pb_migrations/` following the existing timestamp prefix convention. Never edit shipped migrations. -9. **Stop and ask** if you encounter a decision the plan doesn't cover (e.g. a model deprecation, a missing collection, a failing test that looks pre-existing). - -### Files you will touch (or create) across all phases - -| Path | Purpose | -|---|---| -| `src/lib/llm.js` *(new, Phase 1)* | Single Anthropic client wrapper | -| `src/lib/llmSchemas.js` *(new, Phase 1)* | Zod schemas for every structured task | -| `src/lib/llmRetry.js` *(new, Phase 1)* | Retry + backoff + abort policy | -| `src/lib/random.js` *(new, Phase 4)* | Fisher–Yates shuffle + RNG helpers | -| `src/lib/api.js` | Becomes a thin re-export from `llm.js` (back-compat) | -| `src/lib/extractionPipeline.js` | Migrated to `llm.js` + tool use + overlap chunking | -| `src/lib/learningService.js` | Migrated to `llm.js` + tool use + patch-refine | -| `src/lib/testService.js` | Migrated to `llm.js` + tool use + dedup + shuffle fix | -| `src/components/admin/KnowledgeGraph.jsx` | `analyzeGraph` → tool use + dry-run preview | -| `src/components/chat/rag.js` | Retrieval (TF-IDF) + `lookup_topic` tool | -| `src/components/chat/prompts.js` | Split system prompt into cacheable + dynamic | -| `src/components/chat/useChat.js` | Wire retrieval + truncation | -| `pb_migrations/*` | Schema additions for `llm_calls`, question `difficulty`, topic `relevance_locked` | -| `evals/` *(new, Phase 6)* | Golden-set eval harness | - ---- - -## Phase 1 — Foundation (single LLM client + robust parsing) - -**Goal:** every LLM call goes through one module that handles retry, timeout, abort, JSON extraction, and schema validation. No behaviour change visible to the user. - -### 1.1 Create `src/lib/llmRetry.js` - -Implements the retry policy used by `llm.js`. - -**Behaviour:** -- Exponential backoff with full jitter, base 1000ms, cap 16000ms. -- Retries only on these HTTP statuses: `408, 425, 429, 500, 502, 503, 504, 529`. -- Honours `Retry-After` header (seconds or HTTP date). If present and ≤ 60s, use it; if > 60s, fail fast. -- Default `maxRetries = 4`. -- Does **not** retry on `AbortError`. - -**Exported interface:** - -```js -// withRetry: (fn: (attempt:number) => Promise, opts?) => Promise -// RetryableError(status, retryAfterMs) -export async function withRetry(fn, { maxRetries = 4, signal } = {}) { ... } -export class RetryableError extends Error { constructor(status, retryAfterMs) { ... } } -``` - -### 1.2 Create `src/lib/llmSchemas.js` - -One Zod schema per structured task. Install Zod (`npm i zod`). - -Required schemas (names + shape match what callers already produce — do not change field names): - -- `extractionResultSchema` — `{ topics: Topic[], relations: Relation[] }` matching the existing `SYSTEM_PROMPT` in [`extractionPipeline.js`](src/lib/extractionPipeline.js). -- `handbookResultSchema` — same shape, but `relation.type` enum unified to `related_to | depends_on | part_of | executed_by` (see Phase 3 task 3.5 — for now the schema accepts both `executes` and `executed_by`, normalize `executes → executed_by` post-validation). -- `learningArticleSchema`, `learningSlidesSchema`, `learningInfographicSchema`, `learningAllSchema` matching [`learningService.js`](src/lib/learningService.js). -- `quizQuestionsSchema` — `{ questions: Question[] }` with `options.length === 4` and `correctIndex ∈ [0,3]`. -- `customTopicSchema` — `{ label, type: 'concept'|'role'|'process', description }`. -- `graphActionsSchema` — `{ merges, deletions, newRelations, relevanceUpdates }` matching [`KnowledgeGraph.jsx:329`](src/components/admin/KnowledgeGraph.jsx). -- `proposeGraphDeltaSchema` — matches `PROPOSE_GRAPH_DELTA_TOOL.input_schema` in [`prompts.js`](src/components/chat/prompts.js). - -**Acceptance:** every schema has at least one happy-path Vitest test in `src/lib/__tests__/llmSchemas.test.js` (add `vitest` if not present). - -### 1.3 Create `src/lib/llm.js` - -The single Anthropic client. All other modules must call only this one. - -**Public interface:** - -```js -// Task tier — used to pick a model from settings. -// 'fast' → admin:model:fast (default: claude-haiku-4-5-20251001) -// 'standard' → admin:model:standard (default: claude-sonnet-4-6) -// 'reasoning' → admin:model:reasoning (default: claude-opus-4-7) -export async function callLLM({ - task, // string, e.g. 'extract.source' — used for logging only - tier = 'standard', - system, // string OR Array<{ type:'text', text:string, cache_control?:{type:'ephemeral'} }> - messages, // [{ role, content }] OR omitted (use `user`) - user, // shorthand for [{role:'user', content: user}] - tools, // optional Anthropic tool definitions - toolChoice, // optional, e.g. { type:'tool', name:'emit_knowledge_graph' } - schema, // optional Zod schema for text→JSON path (used only when no tool) - maxTokens = 4096, - temperature = 0, - signal, // AbortSignal -}): Promise<{ - text: string, - toolUses: Array<{ name, input }>, - stopReason: string, - usage: { input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens }, - requestId: string | null, - model: string, - durationMs: number, -}> -``` - -**Key requirements:** - -1. **Simulation mode** preserved: if `storage.get('admin:use_simulation') === true`, return a deterministic stub (use existing `simulateResponse` payload for backward compatibility — branch on `task` prefix to return a matching stub). -2. **Fetch** with `AbortController`; default **60-second timeout** if caller didn't pass a signal. -3. **Retry** through `withRetry` (Phase 1.1). -4. **Auth-portal detection** preserved: if response is not `application/json`, throw `Your session has expired. Please refresh the page and log in again.` exactly as today. -5. **No truncation acceptance**: if `stop_reason === 'max_tokens'` AND caller passed `schema` OR `toolChoice` requested a tool, throw `LLMTruncatedError`. -6. **Robust JSON extraction** when caller passed `schema` (and no tool was used): use `parseStructuredText(text)` that - - strips ```` ```json ```` and ```` ``` ```` fences, - - finds the outermost balanced JSON value (object **or** array) via a tiny brace-matching scan, not regex, - - throws `LLMOutputError` if no balanced JSON found, - - runs Zod `schema.parse` on the result. -7. **Tool path:** when `tools` is provided and the model emits `tool_use`, return them under `toolUses`. Validate each tool's input against the corresponding Zod schema if the caller wired one in (via `toolSchemas: { [toolName]: ZodSchema }`). -8. **Logging:** after every call, append a row to a new PocketBase collection `llm_calls` (best-effort — never block on this; catch and console.debug failures). Fields: `task, model, tier, duration_ms, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, stop_reason, ok, error_msg`. See Phase 5 task 5.6 for the migration. -9. **Custom errors**: `LLMHttpError`, `LLMTruncatedError`, `LLMOutputError`, `LLMValidationError`. All extend `Error` and set `name` for `instanceof` checks. - -### 1.4 Make `src/lib/api.js` a thin shim - -Replace the existing `anthropicApi.generateContent` and `anthropicApi.chat` implementations with calls into `llm.js`. Preserve the exact exported names and return shapes so no caller breaks. - -```js -// api.js after Phase 1 -export const anthropicApi = { - async generateContent(systemPrompt, userMessage, maxRetries = 1) { - const { text } = await callLLM({ - task: 'legacy.generateContent', - tier: 'standard', - system: systemPrompt, - user: userMessage, - maxTokens: 8192, - temperature: 0, - }); - return text; - }, - async chat(systemPrompt, messages, opts = {}) { - const r = await callLLM({ - task: 'legacy.chat', - tier: 'standard', - system: systemPrompt, - messages, - tools: opts.tools, - maxTokens: 1024, - temperature: 0.3, // chat default — see Phase 5 - }); - return { - content: [ - ...(r.text ? [{ type:'text', text: r.text }] : []), - ...r.toolUses.map(tu => ({ type:'tool_use', name: tu.name, input: tu.input })), - ], - stop_reason: r.stopReason, - }; - }, -}; -``` - -### 1.5 Update default model + tiered settings - -- Replace `DEFAULT_MODEL = 'claude-sonnet-4-20250514'` with the three tier defaults above. -- In **Admin → Settings**, add three model selects (`fast`, `standard`, `reasoning`). Read existing `admin:model` as a legacy fallback for `standard` (so existing users don't lose their override). - -### Phase 1 acceptance criteria - -- [ ] `npm run lint` passes; `npm run test` passes (Vitest). -- [ ] Every existing user flow (extraction, weekly content, weekly quiz, R42, handbook sync, analyze graph) still works against the live API. -- [ ] `grep -r "fetch.*anthropic" src/` returns only `src/lib/llm.js`. -- [ ] Simulation mode toggle still returns stubbed responses for all flows. -- [ ] Manually verify: kill the network mid-call → request aborts within 60s and surfaces a clear error message. -- [ ] Manually verify: rate-limit the proxy (429 + `Retry-After: 5`) → call retries once after ~5s and then succeeds. - ---- - -## Phase 2 — Prompt caching & tool-based structured outputs - -**Goal:** structured-output tasks no longer parse JSON out of prose. Large stable prompts are cached. - -### 2.1 Migrate extraction to tool use - -In [`extractionPipeline.js`](src/lib/extractionPipeline.js): - -- Replace the "Return JSON only" instruction with a tool: `emit_knowledge_graph` whose `input_schema` mirrors `extractionResultSchema`. -- Replace `anthropicApi.generateContent(...)` with `callLLM({ ..., tools:[emitKnowledgeGraphTool], toolChoice:{ type:'tool', name:'emit_knowledge_graph' } })`. -- Read the validated object from `toolUses[0].input`. -- Same migration for `analyzeHandbookDelta` (tool `emit_handbook_delta`). -- Delete every `responseText.match(/\{[\s\S]*\}/)` site. - -### 2.2 Migrate learning, quiz, custom-topic, graph-actions to tool use - -Same pattern, in: - -- [`learningService.js`](src/lib/learningService.js): tools `emit_learning_article`, `emit_learning_slides`, `emit_learning_infographic`, `emit_learning_all`, `emit_custom_topic`. -- [`testService.js`](src/lib/testService.js): tool `emit_quiz_questions`. -- [`KnowledgeGraph.jsx:297`](src/components/admin/KnowledgeGraph.jsx): tool `emit_graph_actions`. - -### 2.3 Prompt caching - -Pass `system` as an array of blocks so the stable parts can be cached: - -```js -system: [ - { type:'text', text: STABLE_SYSTEM_HEADER, cache_control: { type:'ephemeral' } }, - { type:'text', text: dynamicPart }, // not cached -], -``` - -Apply caching to: -- Extraction `SYSTEM_PROMPT` and `HANDBOOK_SYSTEM_PROMPT` (both fully stable → cache the whole block). -- R42 system prompt — split into three blocks: stable preamble (cached), KB context (cached *only* while the graph hasn't changed; bust by appending a short hash of the topic IDs+labels — Phase 5 details), and per-turn role line (not cached). - -### 2.4 Patch-based learning refinement - -Refactor `refineLearningContent` ([`learningService.js:147`](src/lib/learningService.js:147)) from "return the full updated JSON" to **patch operations** via tools: - -- `set_section(heading: string, body: string)` — replace one section by heading match. -- `add_section(heading: string, body: string, position: 'start'|'end')`. -- `remove_section(heading: string)`. -- `replace_takeaways(items: string[])`. -- `set_intro(intro: string)`. - -Apply patches client-side to the cached object. Re-validate against `learningArticleSchema` after patching; reject the whole turn if invalid. - -### Phase 2 acceptance criteria - -- [ ] No regex JSON extraction left in `src/`: `grep -rn "match(/\\\\{\\[\\\\s\\\\S\\]\\*\\\\}/)" src/` returns nothing. -- [ ] Token usage telemetry shows `cache_read_input_tokens > 0` on the second extraction call within 5 minutes (cache hit). -- [ ] Re-running extraction on a known source produces the same topic count ±10% as before this phase. -- [ ] `refineLearningContent` round-trip ("make the intro shorter") produces only the changed section in the diff against the prior cached content. - ---- - -## Phase 3 — Extraction quality - -**Goal:** fewer near-duplicate topics, no silent truncation, adaptive throttling, unified vocabulary. - -### 3.1 Sentence-aware chunking with overlap - -Replace `chunkText` in [`extractionPipeline.js:87`](src/lib/extractionPipeline.js:87): - -- Target **~2000 input tokens per chunk**. Approximate as `chars / 4`. Configurable via `MAX_CHUNK_CHARS = 8000`. -- **200-token overlap** between chunks (`OVERLAP_CHARS = 800`). -- Split on sentence boundaries (`/(?<=[.!?])\s+/`) first; fall back to paragraph boundary if a sentence is too long; never produce a chunk larger than `MAX_CHUNK_CHARS`. -- Add a guard: if a single sentence exceeds `MAX_CHUNK_CHARS`, hard-split at character boundary and log a warning. - -### 3.2 Stateful extraction - -Before each chunk after the first, prepend to the user message: - -```text -Already-extracted topic IDs (do NOT create new IDs for these — reuse them if the same concept appears here): -- software-engineer -- onboarding-buddy -... -``` - -Cap the list at 200 IDs by recency to keep token cost bounded. The model will then reuse IDs instead of inventing variants like `software-developer`. - -### 3.3 Adaptive throttling - -Replace the hard `setTimeout(r, 12000)` in [`extractionPipeline.js:127`](src/lib/extractionPipeline.js:127) and the 15s sleep in [`KnowledgeGraph.jsx:274`](src/components/admin/KnowledgeGraph.jsx:274) with a shared token-bucket limiter in `src/lib/llmRetry.js`: - -```js -export const extractionLimiter = createLimiter({ rps: 5/60, burst: 1 }); // 5 req/min -// usage: await extractionLimiter.acquire(); -``` - -`callLLM` accepts an optional `limiter` param that it `await`s before fetch. On 429 with `Retry-After`, the limiter is paused for that duration. - -### 3.4 Preserve admin-edited relevance - -Add a migration introducing `relevance_locked: bool` on `topics`. Set it to `true` whenever an admin edits `learning_relevance` via the UI ([`KnowledgeGraph.jsx`](src/components/admin/KnowledgeGraph.jsx) edit handler — locate by searching for `setLearningRelevance` or the relevance form field). - -In `mergeKnowledgeGraph` ([`extractionPipeline.js:167`](src/lib/extractionPipeline.js:167)), when `relevance_locked`, never overwrite `learning_relevance`. - -### 3.5 Unify relation vocabulary - -Pick **one** set: `related_to | depends_on | part_of | executed_by`. Migrate: - -- `HANDBOOK_SYSTEM_PROMPT` ([`extractionPipeline.js:42`](src/lib/extractionPipeline.js:42)) — change `executes` to `executed_by` and swap the source/target in the prompt example. -- Write a one-shot migration script `pb_migrations/_normalize_relation_types.js` that rewrites any existing `executes` relation to `executed_by` and swaps `source ↔ target`. -- Verify R42's `validateDelta` ([`rag.js:108`](src/components/chat/rag.js:108)) already enforces this set (it does) — no change needed there. - -### 3.6 Cancellation - -Add a "Cancel" button to the source-processing UI in `ContentManager.jsx` / `UploadZone.jsx` (locate the one that displays extraction progress). Wire it to abort the in-flight `callLLM` via the `signal` it receives. On cancel, set source status to `cancelled` (add to status enum migration). - -### Phase 3 acceptance criteria - -- [ ] Running extraction twice on the same `sources/ROLES.md` produces zero new topics on the second run (idempotency through reused IDs). -- [ ] Locked-relevance topics survive re-extraction. -- [ ] No fixed `setTimeout` ≥ 5s anywhere in `src/` (`grep -rn "setTimeout" src/`). -- [ ] Cancelling an extraction mid-run leaves the source in `cancelled` state, not `processing`. -- [ ] `pb_migrations` includes the relation-vocabulary normalization and the `relevance_locked` column. - ---- - -## Phase 4 — Quiz & content quality - -**Goal:** quiz questions are positionally unbiased, deduped, and difficulty-tagged. Random helpers are correct. - -### 4.1 Random helpers - -Create `src/lib/random.js`: - -```js -export function shuffle(arr) { /* Fisher–Yates, returns NEW array */ } -export function sample(arr, n) { /* unbiased sample without replacement */ } -export function pickInt(min, maxInclusive) { /* uniform integer */ } -``` - -Replace every `.sort(() => 0.5 - Math.random())` with `shuffle(arr)`: - -- [`testService.js:122`](src/lib/testService.js:122) and [`testService.js:163`](src/lib/testService.js:163). -- Any other site found by `grep -rn "0.5 - Math.random()" src/`. - -### 4.2 Debias `correctIndex` in quiz prompt - -In [`testService.js:81`](src/lib/testService.js:81): - -- Change the example in the prompt to use `"correctIndex": 2` (not 0). -- Add to the prompt: *"Distribute correctIndex roughly evenly across 0, 1, 2, and 3. Do not place the correct answer at the same position more than 4 out of 10 times."* -- After parsing, run a check: if more than 50% of the batch share the same `correctIndex`, log a warning and re-roll up to 2 times. - -### 4.3 Difficulty field - -- Add to `quizQuestionsSchema`: `difficulty: 'easy'|'medium'|'hard'`. -- Update the prompt to require difficulty on every question (current prompt says "4 easy, 4 medium, 2 hard" but never tagged — now tag it). -- Migration: add `difficulty` to the `quiz_banks.questions[]` element. PocketBase stores `questions` as JSON, so the migration is a no-op at the column level; older records get `difficulty: 'medium'` on read (add a normalizer in `db.js`). - -### 4.4 Question dedup - -In `forceGenerateTopicQuestions` ([`testService.js:65`](src/lib/testService.js:65)): - -- Normalize question text (lowercase, strip punctuation, collapse whitespace) → `normKey`. -- Before persisting, drop any new question whose `normKey` matches an existing bank question. -- Log dropped duplicates with `console.debug('[quiz] dropped duplicate:', text)`. - -### 4.5 Quality gate - -In the same function, after schema validation: - -- Reject the whole batch if any question has fewer than 4 distinct options. -- Reject if any option contains `"all of the above"`, `"none of the above"`, `"both A and B"` (case-insensitive). -- Reject if `explanation.trim().length < 20`. -- Surface the rejection to the admin UI with a "Retry" button. - -### 4.6 Custom topic ID hygiene - -In `generateCustomTopic` ([`learningService.js:177`](src/lib/learningService.js:177)): - -- Generate kebab-case ID from the polished label, not `Date.now()`. -- Collision check against existing topics (append `-2`, `-3`, … if needed). -- Default `learning_relevance: 'standard'` when the model omits it. - -### Phase 4 acceptance criteria - -- [ ] No `.sort(() => 0.5 - Math.random())` anywhere in `src/`. -- [ ] Sample of 50 fresh quiz questions across 5 topics: no position holds >40% of correct answers. -- [ ] Re-running quiz generation for the same topic does not grow the bank with semantic duplicates. -- [ ] Custom topics created via R42 use kebab-case IDs and pass schema validation. - ---- - -## Phase 5 — R42 retrieval & telemetry - -**Goal:** R42 stops shipping the entire KG every turn; conversations are bounded; every call is logged. - -### 5.1 TF-IDF retrieval in the browser - -Create `src/lib/retrieval.js`: - -```js -export function buildIndex(topics) { /* TF-IDF over label + description */ } -export function retrieveTopK(index, query, k = 10) { /* returns Topic[] */ } -``` - -Implementation: a small dependency-free TF-IDF — tokenize on `/[a-zA-Z0-9-]+/`, lowercase, drop stopwords (Dutch + English short list). Cache the index on the `topics` array reference. About 100 lines. - -### 5.2 Rewrite `buildKbContext` - -In [`rag.js:11`](src/components/chat/rag.js:11): - -- Use `retrieveTopK(index, userMessage, 10)` to pick which topics go into the system prompt. -- Always include any topic whose ID or label is mentioned verbatim (existing behaviour). -- Drop the full "every topic" dump. -- Return `{ context, retrievedTopics, allTopics }` — `validateDelta` continues to use `allTopics`. - -### 5.3 `lookup_topic` tool - -Add a second R42 tool in [`prompts.js`](src/components/chat/prompts.js): - -```js -export const LOOKUP_TOPIC_TOOL = { - name: 'lookup_topic', - description: 'Fetch the full description and any deeper learning content for a topic. Use when the retrieved context does not contain enough to answer.', - input_schema: { type:'object', properties: { id:{type:'string'} }, required:['id'] }, -}; -``` - -In `useChat.js`, when the model emits a `lookup_topic`, fetch via `db.getTopics()` + `db.getContent(id)` and append a `tool_result` block, then call `callLLM` again with the extended messages. Cap to **3 lookup hops per turn** to avoid loops. - -### 5.4 R42 prompt cache busting - -KB-context block is cached as ephemeral. The cache key is automatic per text, so any change busts it. **Append a stable suffix** to the KB block: `"\n[kb_hash: ]"`. This guarantees the block content changes the moment a topic is added/removed, even if the included topic list looks similar. - -### 5.5 Conversation truncation - -In `useChat.js`: - -- Keep last **12 turns** verbatim in `apiMessages`. -- Older messages: if more than 12 turns exist, summarize the older ones with a cheap (`tier: 'fast'`) call and prepend a single `{ role:'system'-equivalent inside the user history is not allowed by Anthropic — instead prepend a user/assistant pair }` block, OR simply drop older turns. **Default: drop older turns and prepend a one-line `assistant` message saying "(earlier conversation truncated)".** Summarization is an optional follow-up. - -### 5.6 `llm_calls` collection - -Add a migration `pb_migrations/_created_llm_calls.js` for collection `llm_calls`: - -| Field | Type | Notes | -|---|---|---| -| `task` | text | e.g. `extract.source` | -| `model` | text | resolved model ID | -| `tier` | text | `fast` / `standard` / `reasoning` | -| `duration_ms` | number | | -| `input_tokens` | number | | -| `output_tokens` | number | | -| `cache_read_tokens` | number | | -| `cache_create_tokens` | number | | -| `stop_reason` | text | | -| `ok` | bool | | -| `error_msg` | text | nullable | - -Wire `callLLM` to write to it (best-effort, never throws). Add a minimal `Admin → Diagnostics` view that shows the last 100 calls and aggregate cost (using public Anthropic prices in a constant; refresh manually). - -### 5.7 R42 defaults - -- `temperature: 0.3` for R42 chat in `callLLM`. -- `maxTokens: 2048` for R42 (text + tool budget). - -### Phase 5 acceptance criteria - -- [ ] R42 system prompt is ≤ 4000 tokens regardless of KG size (verify on a graph with 200+ topics). -- [ ] Adding a topic in the admin UI causes the **next** R42 call to show `cache_read_tokens === 0` for the KB block, then subsequent calls to show non-zero. -- [ ] R42 successfully answers a question about a topic whose ID was not in the user's message by emitting `lookup_topic` (manual verification). -- [ ] `Admin → Diagnostics` shows recent LLM calls with model, tokens, duration. - ---- - -## Phase 6 — Eval harness (NOT IMPLEMENTED — out of scope) - -> Phase 6 was deliberately skipped. The acceptance criteria for Phases 1–5 give enough confidence in extraction, content, quiz, R42, and telemetry that a golden-set harness is not currently load-bearing. Leave this section intact as a starting point for a future, separately-scoped initiative. - -**Goal:** prompt or model changes can be measured before they ship. - -### 6.1 Golden sets - -Create `evals/` at the repo root: - -``` -evals/ - extraction/ - cases/ - roles-handbook.txt - governance-handbook.txt - expected/ - roles-handbook.json # { mustContain: ['software-engineer', ...], minTopics: 20 } - governance-handbook.json - quiz/ - cases/ - software-engineer.json # topic to generate quiz for - rubric.md # human-readable quality rubric - chat/ - scripts/ - ask-about-known-topic.json - ask-about-unknown-topic.json - propose-new-role.json -``` - -10 extraction cases, 5 quiz topics, 10 chat scripts is enough to start. - -### 6.2 Runner - -`evals/run.mjs` — Node script that: - -1. Loads each case. -2. Invokes the same code path the app uses (import from `src/lib/*` directly; reuse the simulation toggle off). -3. Compares against expectations: - - **Extraction:** `mustContain` IDs present? `minTopics` met? No `stop_reason: 'max_tokens'`? - - **Quiz:** distribution of `correctIndex`, mean explanation length, banned phrases. - - **Chat:** for each script, did the expected tool fire? Did the reply contain expected anchors? -4. Writes `evals/results/.json` and a Markdown diff against the previous baseline. - -Add `npm run eval` to `package.json`. - -### 6.3 Prompt versioning - -In each prompt module, export `PROMPT_VERSION = '2026-05-20-001'`. Persist it on the artifact (`content.data.prompt_version`, `quiz_banks.questions[i].prompt_version`, `topics.metadata.prompt_version`). Add an admin button "Mark stale content for regeneration" that lists artifacts whose version is older than the current. - -### Phase 6 acceptance criteria - -- [ ] `npm run eval` runs end-to-end against the live API and produces a result file. -- [ ] CI (or a manual check) runs evals on every change to `src/lib/llm.js`, prompts, or schemas. -- [ ] Each AI-generated artifact in PocketBase carries a `prompt_version` and is filterable by it. - ---- - -## Cross-phase verification checklist - -After every phase, run this short checklist before merging: - -1. **Build:** `npm run build` succeeds. -2. **Lint:** `npm run lint` clean. -3. **Tests:** `npm run test` green. -4. **Smoke flows** in dev (simulation off, real API key): - - Add a source via `Admin → Sources`, extract, verify topics appear. - - Visit `Leren` for the current week, generate article. Then slides. - - Visit `Testen`, generate weekly quiz. Submit. Score lands on leaderboard. - - Open R42, ask a known and an unknown question; propose a new topic; admin accepts it. - - Run `Admin → Knowledge Graph → Analyze & Optimize Graph`. - - Run `Admin → Knowledge Graph → Sync Handbook` (small repo or mock). -5. **Simulation toggle:** flip simulation mode on and confirm no real API calls happen (Network tab). - ---- - -## Rollback strategy - -Every phase is shippable on its own. If a phase introduces a regression: - -- **Phase 1–2:** `git revert` the merge commit. `api.js` retains the legacy interface, so callers that haven't migrated still work. -- **Phase 3:** revert + the relation-vocabulary migration is reversible (rerun the reverse swap). -- **Phase 4:** revert; quiz schema additions are forward-compatible (older readers ignore `difficulty`). -- **Phase 5:** revert + drop the `llm_calls` collection if undesired. -- **Phase 6:** purely additive; remove the `evals/` folder if abandoned. - -Never `git push --force` to `main`. PR-per-phase. - ---- - -## Out of scope (do not do as part of this plan) - -- Replacing PocketBase. Stays as-is. -- Server-side embeddings or a vector store. Phase 5 deliberately uses in-browser TF-IDF. -- Streaming responses. Mentioned as a future improvement; not in this plan. -- Multi-tenant changes. The platform serves one company. -- UI redesign of the Admin pages beyond what each phase requires. - ---- - -## Glossary - -- **Tier** — coarse model class (`fast`/`standard`/`reasoning`) mapped to a concrete Anthropic model ID via admin settings. -- **Tool use** — Anthropic's structured-output mechanism. The model emits a `tool_use` content block whose `input` is schema-valid JSON. -- **Prompt caching** — Anthropic feature where blocks marked `cache_control: { type:'ephemeral' }` are reused across requests at lower input cost. 5-minute TTL. -- **TF-IDF** — Term-frequency / inverse-document-frequency. A classic IR scoring function used here as a cheap retrieval signal. -- **KB context** — The block in R42's system prompt that lists topics and relations from the knowledge graph. -- **Delta** — A proposed addition to the knowledge graph emitted by R42 via the `propose_graph_delta` tool. diff --git a/curriculum-spec-agnostic.md b/curriculum-spec-agnostic.md deleted file mode 100644 index 4d238d6..0000000 --- a/curriculum-spec-agnostic.md +++ /dev/null @@ -1,334 +0,0 @@ -# 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/micro-learning-generation-spec.md b/micro-learning-generation-spec.md new file mode 100644 index 0000000..0fa24aa --- /dev/null +++ b/micro-learning-generation-spec.md @@ -0,0 +1,556 @@ +# Micro learning generation specification + +## Purpose + +This document defines the functional behaviour of AI-generated micro learning +content. 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 + +**Topic** +An atomic unit of knowledge. The source material for all micro learning +generation. A Topic has a title, a body (one or more paragraphs of content), +a difficulty level, and a set of key terms. Topics live within a knowledge +base organised into broader Themes. + +**Micro learning** +A single generated learning artifact derived from one Topic. One micro +learning covers exactly one Topic in exactly one format type. Multiple micro +learnings can exist per Topic — one per type. + +**Micro learning type** +The format and cognitive approach of the artifact. Ten types are defined. +Each type targets a different learning mechanism (recall, application, +comparison, reflection, and so on). See section — the ten types. + +**Generation** +The act of producing micro learning content from a Topic using an AI model. +Generation always targets a specific type and produces structured output +matching that type's schema. + +**Status lifecycle** +Every micro learning passes through a defined status sequence: +``` +queued → generated → published + ↘ rejected +``` + +- queued: generation has been requested but not yet executed +- generated: AI has produced output, awaiting review +- published: content is available to employees +- rejected: content was reviewed and discarded; generation can be retriggered + +--- + +## Functional requirements + +### 1. Generation triggers + +Generation is triggered in two situations: + +**Batch trigger — Theme approval** +When an admin approves a batch of Topics (grouped under a Theme), generation +is queued for all 10 types for every Topic in that batch. This is the primary +trigger. An approved Topic that has not had all 10 types generated is +considered incomplete. + +**Individual trigger — manual regeneration** +An admin may request regeneration of a specific type for a specific Topic at +any time. This replaces the existing generated artifact for that type with a +newly generated one. The replacement does not affect other types for the same +Topic. + +--- + +### 2. One artifact per topic per type + +The system maintains at most one published artifact per Topic per type at any +time. A Topic with all 10 types published has exactly 10 micro learning records. + +If a type is rejected and regenerated, the previous artifact is superseded by +the new one. Rejected artifacts may be retained for audit but are never shown +to employees. + +--- + +### 3. Generation is asynchronous + +Generation must not block the user interface. Each generation request is +queued and processed in the background. The requester receives a job reference +and can poll or subscribe to status updates. + +Status must be reportable at two levels: +- Per Topic: how many of the 10 types are in each status +- Per job: current processing state of the batch + +--- + +### 4. Content quality constraints + +The AI must follow these constraints for all types: + +- Content must be derived from the Topic body — the AI must not introduce + facts, claims, or examples that are not grounded in the provided Topic +- Language must match the Topic's difficulty level: + - introductory: plain language, no assumed prior knowledge + - intermediate: assumes familiarity with basic concepts in the domain + - advanced: technical precision, assumes domain competence +- Length must be appropriate to the type — see per-type requirements below +- Content must be written for the employee audience, not for a general public +- Tone must be direct and factual — no motivational filler, no excessive hedging + +--- + +### 5. Structured output + +Every type produces structured output — not free prose. The structure is +defined per type in the content schemas section. The AI must produce output +that strictly conforms to the schema for the requested type. + +Output validation must occur before persistence. Any output that fails +schema validation must be: +1. Retried once with a stricter prompt +2. If retry also fails: marked as failed with a reason, not persisted + +--- + +### 6. Admin review flow + +Generated content enters a review queue before becoming available to +employees. Admins may: + +- Read the generated content +- Approve it → status moves to published +- Reject it → status moves to rejected, regeneration can be requested +- Edit it inline → edits are saved, status moves to published + +Bulk approval is permitted: an admin may approve all generated artifacts for +a Theme in a single action. Individual review remains available for any +artifact. + +--- + +### 7. Employee access + +Employees see only published micro learnings. The set of available types for +a Topic is the set of types with published status. If a Topic has 7 of 10 +types published, employees see 7 options. + +Employees choose one type per Topic per session. Multiple types can be +completed in a single session. Completion of one type does not prevent +completing another type for the same Topic. + +--- + +## The ten types + +Each type is described with its learning mechanism, required content +structure, constraints, and the AI prompt intent. + +--- + +### 1. Concept explainer + +**Learning mechanism:** comprehension — understanding what something is + +**Structure:** +``` +paragraphs: array of 2–3 strings +example: string +``` + +**Constraints:** +- Paragraphs explain the concept in plain terms, building from definition + to context to implication +- Example is concrete and specific — not hypothetical ("for example, when + a circle needs to..."), not generic ("this is used in many situations") +- Total length: 150–250 words across paragraphs + example +- No bullet points — flowing prose only + +**Prompt intent:** explain this concept clearly to someone encountering it +for the first time, then make it concrete with a real example from the +Topic's domain + +--- + +### 2. Scenario quiz + +**Learning mechanism:** application — using knowledge to reason through a +realistic situation + +**Structure:** +``` +scenario: string +options: array of 3–4 objects + label: string (A, B, C, D) + text: string + correct: boolean + explanation: string +``` + +**Constraints:** +- Scenario is a realistic, specific situation — not abstract ("imagine a + team..."), not trivial ("what is the definition of...") +- Exactly one correct option +- All incorrect options must be plausible — they should represent common + mistakes or reasonable misunderstandings, not obvious wrong answers +- Each explanation justifies why the option is correct or incorrect — + the explanation teaches, not just confirms +- Scenario length: 60–100 words +- Option text: 15–30 words each + +**Prompt intent:** create a situation where someone must apply this Topic's +knowledge to make a decision, then explain the reasoning behind each choice + +--- + +### 3. Common misconceptions + +**Learning mechanism:** correction — replacing wrong mental models with +accurate ones + +**Structure:** +``` +items: array of 3–5 objects + misconception: string + correction: string +``` + +**Constraints:** +- Each misconception must be genuinely held by people learning this Topic — + not a strawman, not an obvious error +- Correction explains why the misconception is wrong and what is true + instead — it does not just negate the misconception +- Misconception: 1 sentence +- Correction: 2–3 sentences +- Items must be distinct — no overlapping misconceptions + +**Prompt intent:** identify the most common wrong beliefs people hold about +this Topic and correct each one with an accurate explanation + +--- + +### 4. Step-by-step how-to + +**Learning mechanism:** procedural — learning a process by following steps + +**Structure:** +``` +steps: array of objects + number: integer + instruction: string +``` + +**Constraints:** +- Each step is a single action — not a compound instruction ("do A and + then B" should be two steps) +- Steps are ordered — the sequence matters and must be correct +- Step count: 4–8 steps +- Instruction length: 1–2 sentences per step +- Only applicable to Topics that describe a process or procedure — if the + Topic does not contain a process, this type must not be generated + +**Prompt intent:** decompose the process described in this Topic into a +clear ordered sequence of actions that someone can follow + +--- + +### 5. Comparison card + +**Learning mechanism:** differentiation — understanding how two related +concepts differ and when each applies + +**Structure:** +``` +subject_a: string +subject_b: string +dimensions: array of 4–6 objects + label: string + a: string + b: string +``` + +**Constraints:** +- subject_a and subject_b must both be present in the Topic — do not + compare a Topic concept against an external concept not covered in + the KB +- Each dimension is a meaningful axis of comparison — not trivial + ("name: A is called X, B is called Y") +- Each cell (a and b per dimension) is 1–2 sentences +- Dimensions must cover: purpose/intent, typical use, key difference, + and at least one practical implication +- Only applicable to Topics that discuss two comparable concepts + +**Prompt intent:** surface the meaningful differences between the two +concepts in this Topic across dimensions that matter for applying them + +--- + +### 6. Reflection prompt + +**Learning mechanism:** metacognition — connecting new knowledge to +existing experience and internalising it + +**Structure:** +``` +prompt: string +model_answer: string +``` + +**Constraints:** +- Prompt is an open question that cannot be answered with a fact — it + requires the employee to connect the Topic to their own work or experience +- Prompt must be specific to the Topic's content — not generic + ("how does this apply to your work?") +- Model answer demonstrates what a strong reflective response looks like — + it is not the only correct answer, but it shows the depth expected +- Prompt length: 1–2 sentences +- Model answer length: 100–150 words + +**Prompt intent:** write a question that prompts the employee to connect +this Topic's content to their own professional context, then provide an +example of a thoughtful response + +--- + +### 7. Spaced repetition flashcard set + +**Learning mechanism:** recall — retrieving knowledge from memory through +repeated low-stakes testing + +**Structure:** +``` +cards: array of 5–10 objects + question: string + answer: string +``` + +**Constraints:** +- Each card tests one discrete fact, term, or concept from the Topic +- Questions must be answerable with the Topic's content alone — no + external knowledge required +- Answers are concise — 1–2 sentences maximum +- Questions must not overlap — each card tests something distinct +- Mix of question types across the set: definition questions, application + questions, and relationship questions (how does X relate to Y) +- No trick questions + +**Prompt intent:** identify the most important facts, terms, and +relationships in this Topic and create one card per item, written for +repeated low-stakes recall practice + +--- + +### 8. Case study mini-analysis + +**Learning mechanism:** analytical — applying concepts to evaluate a +realistic scenario in depth + +**Structure:** +``` +scenario: string +questions: array of 2–4 strings +``` + +**Constraints:** +- Scenario is a fictional but realistic situation (150–200 words) that + directly involves the concepts in the Topic +- Scenario must be ambiguous enough to require analysis — not a situation + with an obvious single correct answer +- Questions guide analysis — they do not ask for definitions or facts, + they ask the employee to evaluate, judge, or decide +- Questions build on each other — the set forms a coherent analytical + arc, not isolated questions + +**Prompt intent:** create a realistic situation that puts the Topic's +concepts in tension or under pressure, then ask questions that require +the employee to think critically rather than recall facts + +--- + +### 9. Glossary anchor + +**Learning mechanism:** vocabulary — precise understanding of domain terms +including how they are correctly and incorrectly applied + +**Structure:** +``` +term: string +definition: string +correct_use: string +misuse: string +``` + +**Constraints:** +- Term must be a key term from the Topic — taken from the Topic's key + terms list if available, otherwise identified from the Topic body +- Definition is precise — suitable for a reference glossary, not + conversational +- correct_use is a concrete sentence showing the term used correctly + in context +- misuse is a concrete sentence showing a common way the term is used + incorrectly, followed by a brief note on why it is wrong +- Definition: 2–3 sentences +- correct_use and misuse: 1–2 sentences each + +**Prompt intent:** define this term precisely, then anchor the definition +with a correct example and a counterexample that shows a common misuse + +--- + +### 10. Myth vs. evidence + +**Learning mechanism:** critical thinking — evaluating a commonly held +belief against evidence + +**Structure:** +``` +myth: string +evidence: string +sources: array of strings (may be empty if no external sources in Topic) +``` + +**Constraints:** +- Myth is a specific, commonly held false claim related to the Topic — + stated as a confident assertion, not a question +- Evidence is the factual rebuttal — it explains what is actually true + and why the myth persists +- Evidence does not simply negate the myth — it provides the accurate + alternative and ideally explains the origin of the misconception +- Myth: 1–2 sentences, stated assertively +- Evidence: 3–5 sentences +- Sources: only include sources that are explicitly referenced in the + Topic body — do not fabricate citations + +**Prompt intent:** identify a specific false claim that people commonly +believe about this Topic's subject, state it directly, then dismantle it +with evidence and explain why the misconception exists + +--- + +## AI prompt requirements + +The following requirements apply regardless of AI model or provider. + +**Input the AI must receive for every generation call:** +- Topic title +- Topic body (full text) +- Topic difficulty level (introductory / intermediate / advanced) +- Topic key terms (list) +- The specific micro learning type being generated +- The content schema for that type (what structured output is expected) + +**Output the AI must produce:** +- Structured output strictly conforming to the type's schema +- No preamble, no explanation, no prose outside the schema +- All content grounded in the provided Topic body — no external facts introduced + +**Constraints on AI behaviour:** +- Output must be validated against the type's schema before persistence +- On validation failure: retry once with a stricter prompt, then surface + a failure status rather than persisting invalid output +- The AI must not add fields not present in the schema +- The AI must not omit required fields +- For types with constraints on count (flashcard_set: 5–10 cards, + scenario_quiz: 3–4 options), the count must be within the specified range + +**One generation call per type:** +Each type requires a separate AI call. Do not attempt to generate multiple +types in a single call. This ensures clean validation and clean failure +isolation — a failure in one type does not affect others. + +--- + +## Data model (logical) + +These are logical entities. Implementation may map them to any storage system. + +**MicroLearning** +``` +id +topic → Topic +type one of the ten type identifiers +content structured data matching the type's schema +status queued | generated | published | rejected +generation_model identifier of the model used (for audit) +generated_at datetime +published_at datetime (null until published) +updated_at datetime +``` + +**GenerationJob** +``` +id +topic → Topic +types_requested list of type identifiers +status queued | processing | done | failed +progress + types_total integer + types_done integer + types_failed integer +error string (null unless failed) +created_at datetime +completed_at datetime (null until done or failed) +``` + +--- + +## Admin capabilities summary + +| Capability | When available | +|---|---| +| Trigger batch generation for a Theme | After Theme approval | +| Trigger individual type regeneration | Any time, per Topic per type | +| View generation job status | During and after generation | +| Review generated content | After generation | +| Approve individual artifact | After generation | +| Reject individual artifact | After generation | +| Edit content inline before publishing | After generation | +| Bulk approve all artifacts in a Theme | After generation | +| View per-Topic generation coverage | Any time | + +--- + +## Behaviours that must never occur + +- Generated content is persisted without schema validation +- An artifact with status rejected is shown to employees +- A generation call produces output that introduces facts not in the Topic +- Multiple published artifacts exist for the same Topic and type simultaneously +- A how-to is generated for a Topic that contains no process or procedure +- A comparison card is generated for a Topic that contains only one concept +- Generation blocks the admin interface — it must always be asynchronous +- A failed generation is silently ignored — failure must be surfaced and + reportable + +--- + +## Acceptance criteria + +1. Trigger batch generation for a Theme with 4 Topics → 40 artifacts queued + (10 per Topic) +2. All 40 artifacts reach generated status without error +3. Each artifact's content validates against its type's schema +4. A generated concept_explainer contains 2–3 paragraphs and one example, + all grounded in the Topic body +5. A generated scenario_quiz contains exactly one correct option and + explanations for all options +6. A generated flashcard_set contains between 5 and 10 cards with no + overlapping questions +7. An admin rejects one artifact → status is rejected → artifact is not + visible to employees +8. Admin triggers regeneration of the rejected artifact → new artifact + generated → previous rejected artifact superseded +9. Admin edits a generated artifact inline → edits saved → status published +10. Admin bulk-approves all artifacts for a Theme → all move to published +11. A Topic with no process content does not have a how-to artifact generated + (generation is skipped or flagged, not forced) +12. A validation failure on AI output triggers one retry → if retry fails, + artifact status is set to failed with reason, nothing is persisted +13. Generation job status reflects accurate progress counts throughout + the batch process +14. Employee view of a Topic shows only published artifact types From e9f37056b675e365687aab7f3b62011a1eb23804 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 22:26:13 +0200 Subject: [PATCH 05/27] feat: implement text extraction pipeline and centralized LLM interface for knowledge graph generation --- src/lib/extractionPipeline.js | 1 + src/lib/llm.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js index 499f65e..68d8e4e 100644 --- a/src/lib/extractionPipeline.js +++ b/src/lib/extractionPipeline.js @@ -149,6 +149,7 @@ export async function processSourceText(textContent, sourceName, { signal } = {} tools: [EMIT_KNOWLEDGE_GRAPH_TOOL], toolChoice: { type: 'tool', name: EMIT_KNOWLEDGE_GRAPH_TOOL.name }, maxTokens: 8192, + timeoutMs: 180_000, limiter: extractionLimiter, signal, }); diff --git a/src/lib/llm.js b/src/lib/llm.js index b9d5fd4..f2ee235 100644 --- a/src/lib/llm.js +++ b/src/lib/llm.js @@ -349,7 +349,7 @@ export async function callLLM(options) { async () => { if (limiter) await limiter.acquire({ signal }); const timeoutCtl = new AbortController(); - const timer = setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), timeoutMs); + const timer = setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'TimeoutError')), timeoutMs); const fetchSignal = linkSignals(signal, timeoutCtl.signal); try { From b07c4808a68836a4934ad3c3090622652d1cc124 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 22:38:46 +0200 Subject: [PATCH 06/27] feat: implement knowledge extraction pipeline and centralized LLM client service --- sources/ROLES copy.md | 181 ++++++++++++++++++++++++++++++++++ src/lib/extractionPipeline.js | 6 +- src/lib/llm.js | 11 ++- 3 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 sources/ROLES copy.md diff --git a/sources/ROLES copy.md b/sources/ROLES copy.md new file mode 100644 index 0000000..e394bb5 --- /dev/null +++ b/sources/ROLES copy.md @@ -0,0 +1,181 @@ +# Respellion — Roles & Accountabilities + +Generated from `src/db/seed.ts` (source: GlassFrog export *Respellion Governance - 2026-04-26.pdf*). + +Roles marked **(structural)** are constitutionally-required (Circle Lead, Facilitator, Secretary, Circle Rep). + +--- + +## Respellion Anchor Circle + +**Purpose:** Een maatschappij waarin open source technologie wordt ingezet om duurzame oplossingen te bouwen. + +### Circle Lead *(structural)* + +- **Purpose:** Een maatschappij waarin open source technologie wordt ingezet om duurzame oplossingen te bouwen. + +### Facilitator *(structural)* + +- **Purpose:** Circle governance and operational practices aligned with the Constitution. +- **Accountabilities:** + - Facilitating the Circle's regular Tactical Meetings + - Facilitating the Circle's Governance Process + - Triggering new elections for the Circle's elected Roles after each election term expires + - Auditing a Sub-Circle's meetings and records on request and declaring a Process Breakdown if one is discovered + +### Secretary *(structural)* + +- **Purpose:** Stabilize the Circle's constitutionally-required records and meetings. +- **Domains:** All governance records of the Circle +- **Accountabilities:** + - Scheduling regular Tactical Meetings for the Circle + - Capturing and publishing Tactical Meeting outputs + - Scheduling Governance Meetings for the Circle + - Capturing and publishing the outputs of the Circle's Governance Process + - Interpreting the Constitution and anything under its authority upon request + +--- + +## Respellion Operations *(sub-circle of Anchor)* + + +### Chief Azure + +- **Purpose:** Ensure a maintainable and available Azure environment for Respellion. +- **Filler:** Robert van Diest +- **Accountabilities:** + - Monitoring availability of the Respellion Azure environment + - Communicating with CSP (ALSO) + - Maintaining the infrastructure for internal and customer purposes + - Following up any alerts from Azure + - Coordinating changes in resources above SLA with service manager + - Communicating to Respellion stakeholders about the Azure environment + +### Circle Lead *(structural)* + +- **Purpose:** The Circle Lead holds the Purpose of the overall Circle. +- **Filler:** Patrick Smulders + +### Event owner + +- **Purpose:** Making sure Respellion colleagues have an awesome event experience. +- **Accountabilities:** + - Drafting list of congresses + - Making sure there is a budget and using that budget + - Making sure everything about a congress visit is facilitated (stay, transport, tickets, etc) + - Being transparant about the budget and the use of it + - Taking ownership for small team events + +### Facilitator *(structural)* + +- **Purpose:** Circle governance and operational practices aligned with the Constitution. +- **Accountabilities:** + - Facilitating the Circle's regular Tactical Meetings + - Facilitating the Circle's Governance Process + - Triggering new elections for the Circle's elected Roles after each election term expires + - Auditing a Sub-Circle's meetings and records on request and declaring a Process Breakdown if one is discovered + +### Internal Auditor + +- **Purpose:** Making sure that independent evaluations of risks, processes, and controls within Respellion are conducted regularly. The goal is to ensure effective and efficient operations and to ensure compliance with laws and regulations. +- **Accountabilities:** + - Identifying and assessing risks within Respellion + - Analyzing the impact of these risks on operational and financial processes + - Developing an annual (internal) audit plan based on risk analysis, including scope and objectives + - Conducting operational, financial and compliance audits + - Collecting and analyzing data to assess effectiveness of internal controls + - Preparing audit reports (findings, conclusions, recommendations) + - Presenting results to management and colleagues + - Monitoring the implementation of recommendations + - Evaluating the effectiveness of corrective actions + - Ensuring compliance with internal guidelines and external law and regulations + - Advising on improvements to processes and controls to ensure compliance + - Contributing to the development of awareness of internal controls within the organization + - Ensuring colleagues are aware of the importance of risk management and compliance + +### Marketing + +- **Purpose:** Respellion has an outstanding corporate reputation that alligns with the core values and manifest. +- **Filler:** Raymond Verhoef +- **Accountabilities:** + - Maintaining our exposure on social media + - Maintaining the website on functional level + - Developing relevant exposure on the website + - Improving the corporate reputation in a structured and managed way + +### Networker + +- **Purpose:** Maintain a valuable and effective network of stakeholders. +- **Filler:** Patrick Smulders +- **Accountabilities:** + - Maintaining a network of partners (ICT brokers, IT companies) + - Maintaining a network of potential customers + - Monitoring a transparent sales funnel for potential tenders + - Monitoring a transparent sales funnel for potential individual assignments + - Aligning the sales funnel with the financial budget administration and forecast + - Being transparent (communication) about the accountabilities above + +### People Officer + +- **Purpose:** Making sure Respellion has/maintains the organizational culture in which individuals can be the best versions of themselves. And making sure Respellion has/maintains an unique and attractive organizational structure. +- **Filler:** Raymond Verhoef +- **Domains:** An asset, process, or other thing this Role may exclusively control and regulate as its property, for its purpose. +- **Accountabilities:** + - Maintaining the performance model (salary etc) + - Facilitating the monthly celebration meeting + - Guarding the Respellion culture/DNA + - Designing employment contracts + - Maintaining an effective onboarding program + - Approving leave requests + - Maintaining the employee handbook + - Maintaining a comprehensive sick leave administration + - Maintaining a comprehensive leave administration + - Taking initiatives to improve people wellbeing + - Increasing the connection with people's social system at home + - Making sure that successes are being celebrated + +### Privacy Officer + +- **Purpose:** To ensure Respellion maintains the highest standards of data privacy compliance while enabling the organization to effectively use data in alignment with our values of trust, courage, self-discipline, and entrepreneurship. +- **Filler:** Jos van Aalderen +- **Accountabilities:** + - Developing, implementing, and maintaining a comprehensive privacy program aligned with GDPR, Dutch privacy laws, and other applicable regulations + - Monitoring and interpreting changes in privacy legislation and updating organizational policies accordingly + - Serving as the primary point of contact for supervisory authorities (like the Dutch Data Protection Authority) + - Conducting regular privacy impact assessments for new and existing processing activities + - Maintaining records of processing activities as required by Article 30 of GDPR + - Identifying privacy risks across the organization and recommending appropriate risk mitigation strategies + - Managing privacy incident response, including breach notification procedures + - Reviewing data processing agreements with vendors and partners to ensure privacy requirements are adequately addressed + - Developing and delivering privacy training programs for all team members + - Establishing and overseeing processes for handling data subject rights requests (access, rectification, erasure, etc.) + - Ensuring timely responses to privacy inquiries from customers, employees, and other stakeholders + +### Process guardian + +- **Purpose:** Making sure Respellions organizational processes are transparant, compliant and scalable. +- **Filler:** Raymond Verhoef +- **Accountabilities:** + - Maintaining a file structure for documents that facilitates compliance and transparancy + - Maintaining a comprehensive quality management system that complies with potentional ISO regulations + - Advising the circle about effective software platforms + - Advising the circle about (auditing) the quality management system(s) + - Advising the circle about any other relevant internal processes + - Organizing internal audits as described in ISO9001:2015 chapter 9 + +### Recruiter + +- **Purpose:** Recruiting new employees who fit the Respellion's culture. +- **Fillers:** Jos van Aalderen, Patrick Smulders +- **Accountabilities:** + - Selecting potential employees via necessary channels + - Running the first telephone conversations in the application process + - Organizing the introduction interview of the application process + - Organizing the capacity interview of the application process + - Monitoring the application process + - Placing job vacancies on external websites + - Making sure the application process is done within two weeks + - Using a LinkedIn recruiter subscription + - Placing posts for Respellion employer branding + - Drafting job descriptions to attract potential employees + - Reporting regularly on the recruiting process \ No newline at end of file diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js index 68d8e4e..fcc0adc 100644 --- a/src/lib/extractionPipeline.js +++ b/src/lib/extractionPipeline.js @@ -24,10 +24,8 @@ const EXTRACTION_SYSTEM_PROMPT = `You are an AI knowledge extractor for Respelli You receive a source text. Extract every distinct concept, role, and process from it and emit them through the emit_knowledge_graph tool. CRITICAL INSTRUCTIONS FOR COMPLETENESS: -- Extract EVERY SINGLE distinct role, process, and concept described or mentioned in the source text. -- DO NOT summarise, skip, truncate, or omit any items. -- If the document contains 29 roles, the topics array must contain exactly 29 role topics. -- Completeness is paramount. Failing to extract all topics loses critical company knowledge. +- Extract up to 15 of the most important distinct roles, processes, and concepts described or mentioned in the source text. +- Do not exceed 15 topics per chunk to prevent the response from being truncated. - Facts should be integrated into the descriptions of other topics — never extracted as standalone topics. - Keep descriptions concise (max 3 sentences) so the response fits. diff --git a/src/lib/llm.js b/src/lib/llm.js index f2ee235..2691ad0 100644 --- a/src/lib/llm.js +++ b/src/lib/llm.js @@ -348,8 +348,12 @@ export async function callLLM(options) { result = await withRetry( async () => { if (limiter) await limiter.acquire({ signal }); + let didTimeout = false; const timeoutCtl = new AbortController(); - const timer = setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'TimeoutError')), timeoutMs); + const timer = setTimeout(() => { + didTimeout = true; + timeoutCtl.abort(); + }, timeoutMs); const fetchSignal = linkSignals(signal, timeoutCtl.signal); try { @@ -381,6 +385,11 @@ export async function callLLM(options) { } return await response.json(); + } catch (err) { + if (didTimeout) { + throw new Error('Timeout'); + } + throw err; } finally { if (timer) clearTimeout(timer); } From 967c68d27df884937beea27659127614ca0b953e Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 23:09:58 +0200 Subject: [PATCH 07/27] feat: add curriculum management admin dashboard with AI generation and draft approval workflows --- pb_migrations/1780700000_sources_progress.js | 21 ++++ src/components/admin/CurriculumManager.jsx | 4 + src/components/admin/UploadZone.jsx | 115 ++++++++++++++++++- src/hooks/useBeforeUnload.js | 22 ++++ src/lib/db.js | 18 +++ src/lib/extractionPipeline.js | 12 ++ 6 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 pb_migrations/1780700000_sources_progress.js create mode 100644 src/hooks/useBeforeUnload.js diff --git a/pb_migrations/1780700000_sources_progress.js b/pb_migrations/1780700000_sources_progress.js new file mode 100644 index 0000000..f971385 --- /dev/null +++ b/pb_migrations/1780700000_sources_progress.js @@ -0,0 +1,21 @@ +/// +migrate((app) => { + const sources = app.findCollectionByNameOrId("sources"); + + sources.fields.add(new Field({ + "hidden": false, + "id": "json_progress", + "maxSize": 0, + "name": "progress", + "presentable": false, + "required": false, + "system": false, + "type": "json" + })); + + app.save(sources); +}, (app) => { + const sources = app.findCollectionByNameOrId("sources"); + sources.fields.removeById("json_progress"); + app.save(sources); +}); diff --git a/src/components/admin/CurriculumManager.jsx b/src/components/admin/CurriculumManager.jsx index 912b94c..b03bba4 100644 --- a/src/components/admin/CurriculumManager.jsx +++ b/src/components/admin/CurriculumManager.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useBeforeUnload } from '../../hooks/useBeforeUnload'; import { Calendar, Wand2, CheckCircle2, Loader, AlertTriangle, Play, Sparkles, X, Clock, Target } from 'lucide-react'; import Card from '../ui/Card'; import Button from '../ui/Button'; @@ -26,6 +27,9 @@ const CurriculumManager = () => { const [generationReason, setGenerationReason] = useState(''); + // Prevent tab close during long AI operations + useBeforeUnload(isGenerating || isEnriching); + const currentIsoWeek = (() => { const d = new Date(); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); diff --git a/src/components/admin/UploadZone.jsx b/src/components/admin/UploadZone.jsx index b17e447..d677cb9 100644 --- a/src/components/admin/UploadZone.jsx +++ b/src/components/admin/UploadZone.jsx @@ -1,6 +1,9 @@ import { useEffect, useRef, useState } from 'react'; -import { UploadCloud, CheckCircle, AlertCircle, Loader2, X, Clock, Minus } from 'lucide-react'; +import { UploadCloud, CheckCircle, AlertCircle, Loader2, X, Clock, Minus, AlertTriangle, Trash2 } from 'lucide-react'; import { processSourceText } from '../../lib/extractionPipeline'; +import { pb } from '../../lib/pb'; +import * as db from '../../lib/db'; +import { useBeforeUnload } from '../../hooks/useBeforeUnload'; import Card from '../ui/Card'; import Button from '../ui/Button'; @@ -11,10 +14,31 @@ const UploadZone = ({ onUploadComplete }) => { const [queue, setQueue] = useState([]); const [processingId, setProcessingId] = useState(null); const [rejectedNote, setRejectedNote] = useState(null); + const [orphanedSources, setOrphanedSources] = useState([]); const fileInputRef = useRef(null); const abortRef = useRef(null); + // ── Tab-close guard ───────────────────────────────────────────────────────── + useBeforeUnload(processingId !== null); + + // ── Detect orphaned sources on mount ──────────────────────────────────────── + useEffect(() => { + db.getOrphanedSources().then(setOrphanedSources); + }, []); + + const handleResetOrphan = async (id) => { + await db.updateSourceStatus(id, 'failed', 'Interrupted — tab was closed during extraction'); + setOrphanedSources((prev) => prev.filter((s) => s.id !== id)); + if (onUploadComplete) onUploadComplete(); + }; + + const handleDeleteOrphan = async (id) => { + await db.deleteSource(id); + setOrphanedSources((prev) => prev.filter((s) => s.id !== id)); + if (onUploadComplete) onUploadComplete(); + }; + // ── Processing loop ──────────────────────────────────────────────────────── useEffect(() => { @@ -28,10 +52,38 @@ const UploadZone = ({ onUploadComplete }) => { const controller = new AbortController(); abortRef.current = controller; + // Poll for progress updates from PocketBase while processing + let progressInterval; + let lastSourceId = null; + next.file.text() - .then((text) => processSourceText(text, next.name, { signal: controller.signal })) + .then(async (text) => { + // Start processing — get source ID from the pipeline to poll progress + const resultPromise = processSourceText(text, next.name, { signal: controller.signal }); + + // Find the newly created source record to track its progress + const sources = await db.getSources(); + const sourceRec = sources.find(s => s.name === next.name && s.status === 'processing'); + if (sourceRec) { + lastSourceId = sourceRec.id; + progressInterval = setInterval(async () => { + try { + const updated = await pb.collection('sources').getOne(lastSourceId); + if (updated.progress) { + setQueue((q) => q.map((item) => + item.id === next.id + ? { ...item, progress: updated.progress } + : item + )); + } + } catch { /* source may have been deleted */ } + }, 2000); + } + + return resultPromise; + }) .then(() => { - setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'done' } : item)); + setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'done', progress: null } : item)); if (onUploadComplete) onUploadComplete(); }) .catch((err) => { @@ -45,11 +97,12 @@ const UploadZone = ({ onUploadComplete }) => { setQueue((q) => q.map((item) => item.id === next.id - ? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : errorMsg } + ? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : errorMsg, progress: null } : item )); }) .finally(() => { + if (progressInterval) clearInterval(progressInterval); abortRef.current = null; setProcessingId(null); }); @@ -62,7 +115,7 @@ const UploadZone = ({ onUploadComplete }) => { let rejected = 0; for (const file of fileList) { if (file.type === 'text/plain' || file.name.endsWith('.md')) { - accepted.push({ id: crypto.randomUUID(), file, name: file.name, status: 'pending', error: null }); + accepted.push({ id: crypto.randomUUID(), file, name: file.name, status: 'pending', error: null, progress: null }); } else { rejected++; } @@ -109,6 +162,52 @@ const UploadZone = ({ onUploadComplete }) => { return (
+ {/* ─── Orphaned Sources Warning ─── */} + {orphanedSources.length > 0 && ( + +
+ +
+

Interrupted extraction{orphanedSources.length > 1 ? 's' : ''} detected

+

+ {orphanedSources.length === 1 + ? 'A source extraction was interrupted (tab closed or browser crashed). You can delete it and re-upload the file.' + : `${orphanedSources.length} source extractions were interrupted. Delete and re-upload the files to retry.`} +

+
+
+
+ {orphanedSources.map((s) => ( +
+
+ {s.name} + {s.progress && ( + + (stopped at chunk {s.progress.current}/{s.progress.total}) + + )} +
+
+ + +
+
+ ))} +
+
+ )} + {/* ─── Drag & Drop Zone ─── */} { {item.name} - {item.status === 'processing' && 'Extracting…'} + {item.status === 'processing' && ( + item.progress + ? `Chunk ${item.progress.current + 1}/${item.progress.total}` + : 'Starting…' + )} {item.status === 'pending' && 'Pending'} {item.status === 'done' && 'Done'} {item.status === 'cancelled' && 'Cancelled'} diff --git a/src/hooks/useBeforeUnload.js b/src/hooks/useBeforeUnload.js new file mode 100644 index 0000000..7061cef --- /dev/null +++ b/src/hooks/useBeforeUnload.js @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +/** + * Prevent accidental tab/window closure while a long-running operation + * is in progress. Shows the browser's native "Leave site?" confirmation. + * + * @param {boolean} active — true while the operation is running + */ +export function useBeforeUnload(active) { + useEffect(() => { + if (!active) return; + + const handler = (e) => { + e.preventDefault(); + // Legacy browsers require returnValue to be set + e.returnValue = ''; + }; + + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [active]); +} diff --git a/src/lib/db.js b/src/lib/db.js index b3dabb2..9c0d11b 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -133,6 +133,24 @@ export async function updateSourceStatus(id, status, error = '') { return pb.collection('sources').update(id, { status, error }); } +export async function updateSourceProgress(id, progress) { + return pb.collection('sources').update(id, { progress }); +} + +/** + * Find sources stuck in 'processing' status — likely orphaned by a tab close. + * Sources older than 5 minutes in 'processing' state are considered stale. + */ +export async function getOrphanedSources() { + try { + const all = await pb.collection('sources').getFullList({ + filter: 'status="processing"', + }); + const fiveMinAgo = Date.now() - 5 * 60 * 1000; + return all.filter(s => new Date(s.updated || s.created).getTime() < fiveMinAgo); + } catch { return []; } +} + export async function deleteSource(id) { return pb.collection('sources').delete(id); } diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js index fcc0adc..f3e93b3 100644 --- a/src/lib/extractionPipeline.js +++ b/src/lib/extractionPipeline.js @@ -128,6 +128,9 @@ export async function processSourceText(textContent, sourceName, { signal } = {} const chunks = chunkText(textContent); console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`); + // Persist initial progress so other sessions/reloads can see it + await db.updateSourceProgress(sourceId, { current: 0, total: chunks.length, message: 'Starting extraction...' }); + const existingTopics = await db.getTopics(); const knownTopics = existingTopics.map((t) => ({ id: t.id, label: t.label })); @@ -138,6 +141,14 @@ export async function processSourceText(textContent, sourceName, { signal } = {} if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError'); console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`); + + // Update progress before each chunk + await db.updateSourceProgress(sourceId, { + current: i, + total: chunks.length, + message: `Extracting chunk ${i + 1} of ${chunks.length}...`, + }); + const hint = buildKnownIdsHint(knownTopics); const result = await callLLM({ task: 'extract.source', @@ -168,6 +179,7 @@ export async function processSourceText(textContent, sourceName, { signal } = {} } } + await db.updateSourceProgress(sourceId, { current: chunks.length, total: chunks.length, message: 'Merging results...' }); await mergeKnowledgeGraph(existingTopics, { topics: allExtractedTopics, relations: allExtractedRelations }); await db.updateSourceStatus(sourceId, 'completed'); From f55ec950aacf4254c732c8151438a126bb0ff38b Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 23:16:01 +0200 Subject: [PATCH 08/27] feat: implement curriculum service for auto-generating, validating, and managing 26-week learning schedules --- src/lib/curriculumService.js | 59 +++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/src/lib/curriculumService.js b/src/lib/curriculumService.js index 37d5335..3444f1d 100644 --- a/src/lib/curriculumService.js +++ b/src/lib/curriculumService.js @@ -45,7 +45,9 @@ export function buildThemeTopicMap(topics) { * Returns { valid: boolean, errors: string[] } */ export function validateSchedule(schedule, topics) { - const errors = []; + const errors = []; // Hard errors — schedule is unusable + const warnings = []; // Soft warnings — schedule is usable but imperfect + if (!Array.isArray(schedule) || schedule.length !== 26) { errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`); } @@ -63,9 +65,7 @@ export function validateSchedule(schedule, topics) { 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}`); - } + // Allow AI-merged theme names — only flag truly unknown themes as warnings scheduledThemes.add(week.theme); if (!week.topic_ids || week.topic_ids.length === 0) { @@ -79,14 +79,19 @@ export function validateSchedule(schedule, topics) { } } - // Check coverage + // Theme coverage — soft warnings, not hard errors + // When there are more themes than 26 weeks, the AI must merge some. + const missingThemes = []; for (const t of validThemes) { if (!scheduledThemes.has(t)) { - errors.push(`Theme '${t}' is missing from the schedule.`); + missingThemes.push(t); } } + if (missingThemes.length > 0) { + warnings.push(`${missingThemes.length} theme(s) not directly scheduled (topics may appear under merged themes): ${missingThemes.join(', ')}`); + } - return { valid: errors.length === 0, errors }; + return { valid: errors.length === 0, errors, warnings }; } /** @@ -131,31 +136,35 @@ export async function generateCurriculumDraft(reason) { 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 userPrompt = `KB Snapshot (${themeMap.size} themes, ${topics.length} total topics):\n${contextParts.join('\n\n')}\n\nGeneration reason: "${reason || 'Initial curriculum generation'}"`; + + // If there are more themes than 26 weeks, the AI must merge related themes + const mergeInstruction = themeMap.size > 26 + ? `\n- IMPORTANT: There are ${themeMap.size} themes but only 26 weeks. You MUST merge closely related themes into combined weeks. For example, combine "Data Privacy" and "Legal Compliance" into one week. Use any theme name from the merged themes, and include topic_ids from both themes in that week.` + : `\n- Every theme must appear at least once`; 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 +- Exactly 26 week slots, numbered 1-26${mergeInstruction} - 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 +- Each week: one theme name, 1+ topic IDs (topics may come from the named theme or a closely related merged theme), 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 +- Do NOT invent topic IDs — use only the provided topic IDs - Emit via emit_curriculum_schedule tool — no prose`; // Try generation with a retry mechanism if validation fails let result; let schedule; - let validationErrors = []; + let validationResult = { valid: false, errors: [], warnings: [] }; for (let attempt = 1; attempt <= 2; attempt++) { let prompt = userPrompt; - if (attempt > 1) { - prompt = `${userPrompt}\n\nWARNING: The previous generation attempt failed validation with the following errors. Please correct them:\n- ${validationErrors.join('\n- ')}`; + if (attempt > 1 && validationResult.errors.length > 0) { + prompt = `${userPrompt}\n\nWARNING: The previous generation attempt failed validation with the following errors. Please correct them:\n- ${validationResult.errors.join('\n- ')}`; } try { @@ -178,7 +187,7 @@ Rules: const emitted = result.toolUses[0]?.input; if (!emitted || !emitted.weeks) { - validationErrors = ['The AI did not emit a valid curriculum schedule structure.']; + validationResult = { valid: false, errors: ['The AI did not emit a valid curriculum schedule structure.'], warnings: [] }; if (attempt === 2) { throw new Error('The AI did not emit a valid curriculum schedule.'); } @@ -186,17 +195,19 @@ Rules: } schedule = emitted.weeks; - const validation = validateSchedule(schedule, topics); - if (validation.valid) { - validationErrors = []; - break; - } else { - validationErrors = validation.errors; + validationResult = validateSchedule(schedule, topics); + if (validationResult.valid) { + break; // Hard errors resolved — warnings are acceptable } } - if (validationErrors.length > 0) { - throw new Error(`Generated schedule failed validation after retry:\n- ${validationErrors.join('\n- ')}`); + if (!validationResult.valid) { + throw new Error(`Generated schedule failed validation after retry:\n- ${validationResult.errors.join('\n- ')}`); + } + + // Log warnings but don't fail + if (validationResult.warnings.length > 0) { + console.warn('[Curriculum] Schedule generated with warnings:', validationResult.warnings); } const stats = computeCoverageStats(schedule, topics); From 8930ac03ae6af3445f4bad41ec11aa47b3897fc0 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 23:29:18 +0200 Subject: [PATCH 09/27] rename micro-learning-generation-spec.md to micro-learning-spec.md --- micro-learning-generation-spec.md | 556 ------------------------------ micro-learning-spec.md | 359 +++++++++++++++++++ 2 files changed, 359 insertions(+), 556 deletions(-) delete mode 100644 micro-learning-generation-spec.md create mode 100644 micro-learning-spec.md diff --git a/micro-learning-generation-spec.md b/micro-learning-generation-spec.md deleted file mode 100644 index 0fa24aa..0000000 --- a/micro-learning-generation-spec.md +++ /dev/null @@ -1,556 +0,0 @@ -# Micro learning generation specification - -## Purpose - -This document defines the functional behaviour of AI-generated micro learning -content. 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 - -**Topic** -An atomic unit of knowledge. The source material for all micro learning -generation. A Topic has a title, a body (one or more paragraphs of content), -a difficulty level, and a set of key terms. Topics live within a knowledge -base organised into broader Themes. - -**Micro learning** -A single generated learning artifact derived from one Topic. One micro -learning covers exactly one Topic in exactly one format type. Multiple micro -learnings can exist per Topic — one per type. - -**Micro learning type** -The format and cognitive approach of the artifact. Ten types are defined. -Each type targets a different learning mechanism (recall, application, -comparison, reflection, and so on). See section — the ten types. - -**Generation** -The act of producing micro learning content from a Topic using an AI model. -Generation always targets a specific type and produces structured output -matching that type's schema. - -**Status lifecycle** -Every micro learning passes through a defined status sequence: -``` -queued → generated → published - ↘ rejected -``` - -- queued: generation has been requested but not yet executed -- generated: AI has produced output, awaiting review -- published: content is available to employees -- rejected: content was reviewed and discarded; generation can be retriggered - ---- - -## Functional requirements - -### 1. Generation triggers - -Generation is triggered in two situations: - -**Batch trigger — Theme approval** -When an admin approves a batch of Topics (grouped under a Theme), generation -is queued for all 10 types for every Topic in that batch. This is the primary -trigger. An approved Topic that has not had all 10 types generated is -considered incomplete. - -**Individual trigger — manual regeneration** -An admin may request regeneration of a specific type for a specific Topic at -any time. This replaces the existing generated artifact for that type with a -newly generated one. The replacement does not affect other types for the same -Topic. - ---- - -### 2. One artifact per topic per type - -The system maintains at most one published artifact per Topic per type at any -time. A Topic with all 10 types published has exactly 10 micro learning records. - -If a type is rejected and regenerated, the previous artifact is superseded by -the new one. Rejected artifacts may be retained for audit but are never shown -to employees. - ---- - -### 3. Generation is asynchronous - -Generation must not block the user interface. Each generation request is -queued and processed in the background. The requester receives a job reference -and can poll or subscribe to status updates. - -Status must be reportable at two levels: -- Per Topic: how many of the 10 types are in each status -- Per job: current processing state of the batch - ---- - -### 4. Content quality constraints - -The AI must follow these constraints for all types: - -- Content must be derived from the Topic body — the AI must not introduce - facts, claims, or examples that are not grounded in the provided Topic -- Language must match the Topic's difficulty level: - - introductory: plain language, no assumed prior knowledge - - intermediate: assumes familiarity with basic concepts in the domain - - advanced: technical precision, assumes domain competence -- Length must be appropriate to the type — see per-type requirements below -- Content must be written for the employee audience, not for a general public -- Tone must be direct and factual — no motivational filler, no excessive hedging - ---- - -### 5. Structured output - -Every type produces structured output — not free prose. The structure is -defined per type in the content schemas section. The AI must produce output -that strictly conforms to the schema for the requested type. - -Output validation must occur before persistence. Any output that fails -schema validation must be: -1. Retried once with a stricter prompt -2. If retry also fails: marked as failed with a reason, not persisted - ---- - -### 6. Admin review flow - -Generated content enters a review queue before becoming available to -employees. Admins may: - -- Read the generated content -- Approve it → status moves to published -- Reject it → status moves to rejected, regeneration can be requested -- Edit it inline → edits are saved, status moves to published - -Bulk approval is permitted: an admin may approve all generated artifacts for -a Theme in a single action. Individual review remains available for any -artifact. - ---- - -### 7. Employee access - -Employees see only published micro learnings. The set of available types for -a Topic is the set of types with published status. If a Topic has 7 of 10 -types published, employees see 7 options. - -Employees choose one type per Topic per session. Multiple types can be -completed in a single session. Completion of one type does not prevent -completing another type for the same Topic. - ---- - -## The ten types - -Each type is described with its learning mechanism, required content -structure, constraints, and the AI prompt intent. - ---- - -### 1. Concept explainer - -**Learning mechanism:** comprehension — understanding what something is - -**Structure:** -``` -paragraphs: array of 2–3 strings -example: string -``` - -**Constraints:** -- Paragraphs explain the concept in plain terms, building from definition - to context to implication -- Example is concrete and specific — not hypothetical ("for example, when - a circle needs to..."), not generic ("this is used in many situations") -- Total length: 150–250 words across paragraphs + example -- No bullet points — flowing prose only - -**Prompt intent:** explain this concept clearly to someone encountering it -for the first time, then make it concrete with a real example from the -Topic's domain - ---- - -### 2. Scenario quiz - -**Learning mechanism:** application — using knowledge to reason through a -realistic situation - -**Structure:** -``` -scenario: string -options: array of 3–4 objects - label: string (A, B, C, D) - text: string - correct: boolean - explanation: string -``` - -**Constraints:** -- Scenario is a realistic, specific situation — not abstract ("imagine a - team..."), not trivial ("what is the definition of...") -- Exactly one correct option -- All incorrect options must be plausible — they should represent common - mistakes or reasonable misunderstandings, not obvious wrong answers -- Each explanation justifies why the option is correct or incorrect — - the explanation teaches, not just confirms -- Scenario length: 60–100 words -- Option text: 15–30 words each - -**Prompt intent:** create a situation where someone must apply this Topic's -knowledge to make a decision, then explain the reasoning behind each choice - ---- - -### 3. Common misconceptions - -**Learning mechanism:** correction — replacing wrong mental models with -accurate ones - -**Structure:** -``` -items: array of 3–5 objects - misconception: string - correction: string -``` - -**Constraints:** -- Each misconception must be genuinely held by people learning this Topic — - not a strawman, not an obvious error -- Correction explains why the misconception is wrong and what is true - instead — it does not just negate the misconception -- Misconception: 1 sentence -- Correction: 2–3 sentences -- Items must be distinct — no overlapping misconceptions - -**Prompt intent:** identify the most common wrong beliefs people hold about -this Topic and correct each one with an accurate explanation - ---- - -### 4. Step-by-step how-to - -**Learning mechanism:** procedural — learning a process by following steps - -**Structure:** -``` -steps: array of objects - number: integer - instruction: string -``` - -**Constraints:** -- Each step is a single action — not a compound instruction ("do A and - then B" should be two steps) -- Steps are ordered — the sequence matters and must be correct -- Step count: 4–8 steps -- Instruction length: 1–2 sentences per step -- Only applicable to Topics that describe a process or procedure — if the - Topic does not contain a process, this type must not be generated - -**Prompt intent:** decompose the process described in this Topic into a -clear ordered sequence of actions that someone can follow - ---- - -### 5. Comparison card - -**Learning mechanism:** differentiation — understanding how two related -concepts differ and when each applies - -**Structure:** -``` -subject_a: string -subject_b: string -dimensions: array of 4–6 objects - label: string - a: string - b: string -``` - -**Constraints:** -- subject_a and subject_b must both be present in the Topic — do not - compare a Topic concept against an external concept not covered in - the KB -- Each dimension is a meaningful axis of comparison — not trivial - ("name: A is called X, B is called Y") -- Each cell (a and b per dimension) is 1–2 sentences -- Dimensions must cover: purpose/intent, typical use, key difference, - and at least one practical implication -- Only applicable to Topics that discuss two comparable concepts - -**Prompt intent:** surface the meaningful differences between the two -concepts in this Topic across dimensions that matter for applying them - ---- - -### 6. Reflection prompt - -**Learning mechanism:** metacognition — connecting new knowledge to -existing experience and internalising it - -**Structure:** -``` -prompt: string -model_answer: string -``` - -**Constraints:** -- Prompt is an open question that cannot be answered with a fact — it - requires the employee to connect the Topic to their own work or experience -- Prompt must be specific to the Topic's content — not generic - ("how does this apply to your work?") -- Model answer demonstrates what a strong reflective response looks like — - it is not the only correct answer, but it shows the depth expected -- Prompt length: 1–2 sentences -- Model answer length: 100–150 words - -**Prompt intent:** write a question that prompts the employee to connect -this Topic's content to their own professional context, then provide an -example of a thoughtful response - ---- - -### 7. Spaced repetition flashcard set - -**Learning mechanism:** recall — retrieving knowledge from memory through -repeated low-stakes testing - -**Structure:** -``` -cards: array of 5–10 objects - question: string - answer: string -``` - -**Constraints:** -- Each card tests one discrete fact, term, or concept from the Topic -- Questions must be answerable with the Topic's content alone — no - external knowledge required -- Answers are concise — 1–2 sentences maximum -- Questions must not overlap — each card tests something distinct -- Mix of question types across the set: definition questions, application - questions, and relationship questions (how does X relate to Y) -- No trick questions - -**Prompt intent:** identify the most important facts, terms, and -relationships in this Topic and create one card per item, written for -repeated low-stakes recall practice - ---- - -### 8. Case study mini-analysis - -**Learning mechanism:** analytical — applying concepts to evaluate a -realistic scenario in depth - -**Structure:** -``` -scenario: string -questions: array of 2–4 strings -``` - -**Constraints:** -- Scenario is a fictional but realistic situation (150–200 words) that - directly involves the concepts in the Topic -- Scenario must be ambiguous enough to require analysis — not a situation - with an obvious single correct answer -- Questions guide analysis — they do not ask for definitions or facts, - they ask the employee to evaluate, judge, or decide -- Questions build on each other — the set forms a coherent analytical - arc, not isolated questions - -**Prompt intent:** create a realistic situation that puts the Topic's -concepts in tension or under pressure, then ask questions that require -the employee to think critically rather than recall facts - ---- - -### 9. Glossary anchor - -**Learning mechanism:** vocabulary — precise understanding of domain terms -including how they are correctly and incorrectly applied - -**Structure:** -``` -term: string -definition: string -correct_use: string -misuse: string -``` - -**Constraints:** -- Term must be a key term from the Topic — taken from the Topic's key - terms list if available, otherwise identified from the Topic body -- Definition is precise — suitable for a reference glossary, not - conversational -- correct_use is a concrete sentence showing the term used correctly - in context -- misuse is a concrete sentence showing a common way the term is used - incorrectly, followed by a brief note on why it is wrong -- Definition: 2–3 sentences -- correct_use and misuse: 1–2 sentences each - -**Prompt intent:** define this term precisely, then anchor the definition -with a correct example and a counterexample that shows a common misuse - ---- - -### 10. Myth vs. evidence - -**Learning mechanism:** critical thinking — evaluating a commonly held -belief against evidence - -**Structure:** -``` -myth: string -evidence: string -sources: array of strings (may be empty if no external sources in Topic) -``` - -**Constraints:** -- Myth is a specific, commonly held false claim related to the Topic — - stated as a confident assertion, not a question -- Evidence is the factual rebuttal — it explains what is actually true - and why the myth persists -- Evidence does not simply negate the myth — it provides the accurate - alternative and ideally explains the origin of the misconception -- Myth: 1–2 sentences, stated assertively -- Evidence: 3–5 sentences -- Sources: only include sources that are explicitly referenced in the - Topic body — do not fabricate citations - -**Prompt intent:** identify a specific false claim that people commonly -believe about this Topic's subject, state it directly, then dismantle it -with evidence and explain why the misconception exists - ---- - -## AI prompt requirements - -The following requirements apply regardless of AI model or provider. - -**Input the AI must receive for every generation call:** -- Topic title -- Topic body (full text) -- Topic difficulty level (introductory / intermediate / advanced) -- Topic key terms (list) -- The specific micro learning type being generated -- The content schema for that type (what structured output is expected) - -**Output the AI must produce:** -- Structured output strictly conforming to the type's schema -- No preamble, no explanation, no prose outside the schema -- All content grounded in the provided Topic body — no external facts introduced - -**Constraints on AI behaviour:** -- Output must be validated against the type's schema before persistence -- On validation failure: retry once with a stricter prompt, then surface - a failure status rather than persisting invalid output -- The AI must not add fields not present in the schema -- The AI must not omit required fields -- For types with constraints on count (flashcard_set: 5–10 cards, - scenario_quiz: 3–4 options), the count must be within the specified range - -**One generation call per type:** -Each type requires a separate AI call. Do not attempt to generate multiple -types in a single call. This ensures clean validation and clean failure -isolation — a failure in one type does not affect others. - ---- - -## Data model (logical) - -These are logical entities. Implementation may map them to any storage system. - -**MicroLearning** -``` -id -topic → Topic -type one of the ten type identifiers -content structured data matching the type's schema -status queued | generated | published | rejected -generation_model identifier of the model used (for audit) -generated_at datetime -published_at datetime (null until published) -updated_at datetime -``` - -**GenerationJob** -``` -id -topic → Topic -types_requested list of type identifiers -status queued | processing | done | failed -progress - types_total integer - types_done integer - types_failed integer -error string (null unless failed) -created_at datetime -completed_at datetime (null until done or failed) -``` - ---- - -## Admin capabilities summary - -| Capability | When available | -|---|---| -| Trigger batch generation for a Theme | After Theme approval | -| Trigger individual type regeneration | Any time, per Topic per type | -| View generation job status | During and after generation | -| Review generated content | After generation | -| Approve individual artifact | After generation | -| Reject individual artifact | After generation | -| Edit content inline before publishing | After generation | -| Bulk approve all artifacts in a Theme | After generation | -| View per-Topic generation coverage | Any time | - ---- - -## Behaviours that must never occur - -- Generated content is persisted without schema validation -- An artifact with status rejected is shown to employees -- A generation call produces output that introduces facts not in the Topic -- Multiple published artifacts exist for the same Topic and type simultaneously -- A how-to is generated for a Topic that contains no process or procedure -- A comparison card is generated for a Topic that contains only one concept -- Generation blocks the admin interface — it must always be asynchronous -- A failed generation is silently ignored — failure must be surfaced and - reportable - ---- - -## Acceptance criteria - -1. Trigger batch generation for a Theme with 4 Topics → 40 artifacts queued - (10 per Topic) -2. All 40 artifacts reach generated status without error -3. Each artifact's content validates against its type's schema -4. A generated concept_explainer contains 2–3 paragraphs and one example, - all grounded in the Topic body -5. A generated scenario_quiz contains exactly one correct option and - explanations for all options -6. A generated flashcard_set contains between 5 and 10 cards with no - overlapping questions -7. An admin rejects one artifact → status is rejected → artifact is not - visible to employees -8. Admin triggers regeneration of the rejected artifact → new artifact - generated → previous rejected artifact superseded -9. Admin edits a generated artifact inline → edits saved → status published -10. Admin bulk-approves all artifacts for a Theme → all move to published -11. A Topic with no process content does not have a how-to artifact generated - (generation is skipped or flagged, not forced) -12. A validation failure on AI output triggers one retry → if retry fails, - artifact status is set to failed with reason, nothing is persisted -13. Generation job status reflects accurate progress counts throughout - the batch process -14. Employee view of a Topic shows only published artifact types diff --git a/micro-learning-spec.md b/micro-learning-spec.md new file mode 100644 index 0000000..86fc755 --- /dev/null +++ b/micro-learning-spec.md @@ -0,0 +1,359 @@ +# Micro learning specification + +## Purpose + +This document defines what micro learnings are, how employees experience +them, and how they behave within a learning session. It covers the learner +perspective — interaction patterns, completion rules, and the relationship +between micro learnings, topics, and sessions. + +This document is application-agnostic. It does not describe how micro +learnings are generated, stored, or administered. For those topics see the +micro learning generation specification. + +--- + +## What a micro learning is + +A micro learning is a short, focused learning interaction derived from a +single Topic in the knowledge base. It presents the content of that Topic +through one specific format designed to activate a particular cognitive +process. + +A micro learning has three defining characteristics: + +**Single topic scope** +Every micro learning covers exactly one Topic. It does not introduce content +from other Topics, even related ones. If an employee needs to understand a +related concept, they access that Topic's micro learnings separately. + +**Single format** +Each micro learning uses one format type. The type determines the cognitive +demand: comprehension, application, recall, or reflection. An employee +choosing different types for the same Topic is not repeating content — they +are engaging the same knowledge through different cognitive processes. + +**Short duration** +A micro learning is designed to be completed in a single sitting of five to +fifteen minutes. It is not a course, a module, or a chapter. It is one +focused interaction. + +--- + +## Relationship to topics and sessions + +``` +Theme (one per weekly session) + └── Topic A + │ ├── Concept explainer ← micro learning + │ ├── Scenario quiz ← micro learning + │ ├── Flashcard set ← micro learning + │ └── Reflection prompt ← micro learning + └── Topic B + ├── Concept explainer + ├── Scenario quiz + ├── Flashcard set + └── Reflection prompt +``` + +One session covers one Theme. A Theme contains multiple Topics. Each Topic +has up to four micro learnings — one per type. The employee selects which +type to engage with per Topic per session. + +--- + +## Employee choice + +The employee is never assigned a specific micro learning type. For each +Topic in a session, the employee sees the available types and selects one. +This choice is made fresh each time — there is no default, no locked +sequence, and no penalty for choosing the same type repeatedly. + +The choice is meaningful: +- An employee who wants to understand a concept for the first time will + likely choose the concept explainer +- An employee who already understands the concept and wants to test + themselves will choose the scenario quiz +- An employee revisiting a Topic in a later cycle will choose differently + than they did in the first cycle + +The system records which types the employee has used per Topic across their +history. This history informs curriculum variation in subsequent cycles but +does not restrict choice in the current session. + +--- + +## Completion + +### What counts as complete + +Completion is defined per micro learning — one completion record is created +when an employee finishes one type for one Topic. + +Each type has its own completion trigger: + +| Type | Completion trigger | +|---|---| +| Concept explainer | Employee reaches the end of the content | +| Scenario quiz | Employee selects an answer and views the explanation | +| Flashcard set | Employee views all cards in the set at least once | +| Reflection prompt | Employee submits a response (any response) | + +Completion is not quality-gated. The employee does not need to answer +correctly or respond thoughtfully. The act of engaging with the full micro +learning constitutes completion. Learning quality is served by the content +design and the spaced repetition mechanic — not by enforced correctness. + +### Multiple completions per topic + +An employee may complete more than one type for the same Topic in the same +session or across sessions. Each type completion is recorded independently. + +Completing a second type for a Topic the employee has already completed does +not overwrite the first. Both records exist. Both contribute to the +employee's engagement history. + +There is no requirement to complete all four types for a Topic. The minimum +for a Topic to count as engaged within a session is one completed type. + +### Completion and session progress + +A session is considered complete when every Topic in the week's Theme has at +least one completed micro learning type. The employee does not need to +complete all types for all Topics — one type per Topic is the threshold. + +This threshold is intentionally low. The goal is consistent engagement with +the full breadth of the curriculum, not exhaustive coverage of every format +per session. + +--- + +## The four types — learner perspective + +### Concept explainer + +The employee reads a structured explanation of the Topic. + +The content moves through three stages: what the concept is, why it exists +or matters, and what it looks like in practice. The final element is always +a concrete example anchored in the employee's domain. + +The employee reads from start to finish. There is no interaction required +beyond reading. Completion triggers when the employee reaches the end. + +This type is appropriate when: +- The employee is encountering the Topic for the first time +- The employee wants to refresh their understanding before attempting + another type +- The Topic is definitional or structural in nature + +--- + +### Scenario quiz + +The employee reads a realistic workplace situation and selects the best +response from three or four options. + +The scenario is specific to the employee's domain — it involves the kinds +of decisions, tensions, or situations the employee might actually face. The +options are plausible — the incorrect answers represent common mistakes or +reasonable misreadings, not obvious errors. + +After selecting an answer, the employee sees an explanation for every option +— not just the one they chose. The explanation for the incorrect options +teaches as much as the explanation for the correct one. + +There is exactly one correct answer. The employee is not penalised for a +wrong selection beyond seeing the explanation that corrects it. + +Completion triggers when the employee selects an answer and views the +explanations. Selecting and immediately closing before reading explanations +does not constitute completion. + +This type is appropriate when: +- The employee wants to test whether they can apply the concept +- The Topic involves a process, a decision, or a governance question + where context determines the right response +- The employee wants active engagement rather than passive reading + +--- + +### Flashcard set + +The employee moves through a set of five to ten question-and-answer cards, +each covering one discrete fact, term, or relationship from the Topic. + +Each card presents a question. The employee considers their answer, then +reveals the answer on the card and evaluates whether they knew it. There is +no automated scoring — the employee self-assesses. + +The cards cover a mix of question types: what something is (definition), +how something works (application), and how two things relate (relationship). +This mix ensures the set tests different aspects of the Topic rather than +repeating the same cognitive demand. + +Completion triggers when the employee has viewed both sides of every card +in the set at least once. The employee may move through the cards in any +order. Skipping a card and returning to it later counts — what matters is +that all cards are seen. + +Flashcard sets are designed for repeated use. An employee who completed a +Topic's flashcard set in week 3 may return to it in week 15 and use it +again as a retrieval practice exercise. Each return creates a new completion +record. + +This type is appropriate when: +- The Topic contains terminology, definitions, or facts the employee needs + to retain +- The employee is in a later cycle and wants to maintain knowledge rather + than relearn it +- The employee has limited time and wants a quick retrieval check + +--- + +### Reflection prompt + +The employee reads an open question that asks them to connect the Topic's +content to their own professional experience. They write a response in a +free-text field, then compare their response to a model answer. + +The question cannot be answered with a fact. It requires the employee to +think about how the concept applies to their own context — their team, their +role, their past experience, or a situation they have encountered. There is +no single correct answer. + +After writing their response, the employee reveals the model answer. The +model answer is not a rubric or a correction — it is an example of a +thoughtful response that shows the depth and specificity expected. The +employee compares their thinking to the model and draws their own +conclusions. + +Completion triggers when the employee submits a response. The system does +not evaluate the content of the response. An employee who writes a single +sentence has completed the micro learning in the same way as an employee +who writes three paragraphs. The value is in the act of reflection, not +in the output. + +This type is appropriate when: +- The Topic describes a practice, a structure, or a principle the employee + is expected to apply in their work +- The employee wants to move beyond understanding into internalisation +- The Topic involves holacratic roles, governance, or process — areas where + personal application is the goal + +--- + +## Behaviour across cycles + +In the first cycle, the employee encounters each Topic for the first time. +Their natural tendency will be to use the concept explainer to build +understanding before using other types. + +In subsequent cycles, the employee already has a foundation. The curriculum +may vary the recommended type based on the employee's history — surfacing +types they have not used — but the employee retains full choice. + +The flashcard set is the type most suited to later cycles. An employee who +understood a Topic in cycle 1 can use the flashcard set in cycle 2 and 3 +purely for retrieval practice, maintaining knowledge without re-reading +explanatory content. + +The reflection prompt gains depth in later cycles. An employee who has +spent six months applying a holacratic principle will reflect more +concretely in cycle 2 than they did in cycle 1. + +--- + +## What a micro learning is not + +**Not a test** +The scenario quiz includes a correct answer but its purpose is to surface +reasoning, not to grade performance. No score is recorded. No minimum is +required to complete a session. + +**Not a course** +A micro learning does not have prerequisites, chapters, or progression within +itself. It is a single interaction. Depth comes from the curriculum's +sequencing of Topics over 26 weeks, not from within any individual micro +learning. + +**Not a substitute for the knowledge library** +The knowledge library contains the full Topic body — the source material. +A micro learning is a derived interaction from that material. An employee +who wants to read the full source goes to the library. An employee who wants +a focused learning interaction uses a micro learning. + +**Not locked to a session** +Employees may access micro learnings for any published Topic from the +knowledge library at any time, regardless of where they are in the +curriculum. Completing a micro learning outside a session still records +a completion and contributes to the employee's history. + +--- + +## Data model (logical) + +These are logical entities described from the learner's perspective. +Implementation may map them to any storage system. + +**MicroLearning** (the artifact) +``` +id +topic → Topic +type concept_explainer | scenario_quiz | + flashcard_set | reflection_prompt +content structured data per type schema +status published (only published items are visible to employees) +``` + +**MicroLearningCompletion** (the event) +``` +id +employee → Employee +micro_learning → MicroLearning +topic → Topic +type type identifier at time of completion +completed_at datetime +session_week integer — curriculum week when completed +cycle integer — which cycle +``` + +Completions are append-only. They are never updated or deleted. Each +represents a discrete learning event in the employee's history. + +--- + +## Behaviours that must never occur + +- An employee sees a micro learning that has not been published +- A completion is recorded before the employee reaches the completion + trigger for that type (e.g. recording completion before all flashcards + are viewed) +- A completion record is modified or deleted after creation +- The employee is prevented from choosing a type they have already + completed for a Topic +- The employee's response to a reflection prompt is evaluated, scored, + or shown to anyone other than the employee themselves +- A micro learning introduces content that is not present in its source Topic + +--- + +## Acceptance criteria + +1. An employee accessing a Topic sees only published micro learning types +2. An employee can complete a concept explainer by reading to the end — + no further interaction required +3. An employee who selects an incorrect answer in a scenario quiz sees + explanations for all options, not only their chosen option +4. An employee cannot complete a flashcard set without viewing all cards +5. An employee who submits any text in a reflection prompt completes the + micro learning — the content of the response is not evaluated +6. Completing one type for a Topic does not remove or replace the + completion record for another type for the same Topic +7. A Topic with one completed type counts as engaged for session progress +8. An employee can access and complete micro learnings from the knowledge + library outside of their scheduled session week +9. Each completion creates exactly one record — submitting a reflection + prompt twice creates two records, not one updated record +10. An employee in cycle 2 can complete a flashcard set for a Topic they + completed in cycle 1 — the new completion is recorded independently From 55bcb689e74d023d76884fa835b976afa4437499 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 24 May 2026 23:40:59 +0200 Subject: [PATCH 10/27] feat: implement micro-learning system with content delivery components, completion tracking hooks, and database schema migrations --- .../1780800000_created_micro_learnings.js | 183 ++++++++++++++++ .../1780800001_deleted_legacy_collections.js | 23 ++ .../micro_learning/ConceptExplainer.jsx | 45 ++++ .../micro_learning/FlashcardSet.jsx | 75 +++++++ .../micro_learning/MicroLearningContainer.jsx | 62 ++++++ .../micro_learning/MicroLearningSelector.jsx | 89 ++++++++ .../micro_learning/ReflectionPrompt.jsx | 55 +++++ .../micro_learning/ScenarioQuiz.jsx | 62 ++++++ src/hooks/useMicroLearningCompletions.js | 53 +++++ src/hooks/useMicroLearnings.js | 17 ++ src/pages/Leren.jsx | 202 ++++-------------- 11 files changed, 700 insertions(+), 166 deletions(-) create mode 100644 pb_migrations/1780800000_created_micro_learnings.js create mode 100644 pb_migrations/1780800001_deleted_legacy_collections.js create mode 100644 src/components/micro_learning/ConceptExplainer.jsx create mode 100644 src/components/micro_learning/FlashcardSet.jsx create mode 100644 src/components/micro_learning/MicroLearningContainer.jsx create mode 100644 src/components/micro_learning/MicroLearningSelector.jsx create mode 100644 src/components/micro_learning/ReflectionPrompt.jsx create mode 100644 src/components/micro_learning/ScenarioQuiz.jsx create mode 100644 src/hooks/useMicroLearningCompletions.js create mode 100644 src/hooks/useMicroLearnings.js diff --git a/pb_migrations/1780800000_created_micro_learnings.js b/pb_migrations/1780800000_created_micro_learnings.js new file mode 100644 index 0000000..4ffcff6 --- /dev/null +++ b/pb_migrations/1780800000_created_micro_learnings.js @@ -0,0 +1,183 @@ +/// +migrate((app) => { + // Create micro_learnings collection + const microLearnings = new Collection({ + "id": "pbc_micro_learnings_0", + "name": "micro_learnings", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "rel_topic_id", + "name": "topic_id", + "type": "relation", + "required": true, + "presentable": false, + "options": { + "collectionId": "pbc_2800040823", // the ID for topics from snapshot (we will use topic name later if possible, but let's just use collectionName: "topics") + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "sel_type", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "options": { + "maxSelect": 1, + "values": [ + "concept_explainer", + "scenario_quiz", + "flashcard_set", + "reflection_prompt" + ] + } + }, + { + "system": false, + "id": "json_content", + "name": "content", + "type": "json", + "required": true, + "presentable": false, + "options": {} + }, + { + "system": false, + "id": "sel_status", + "name": "status", + "type": "select", + "required": true, + "presentable": false, + "options": { + "maxSelect": 1, + "values": [ + "draft", + "published" + ] + } + } + ], + "indexes": [], + "listRule": "status = 'published'", + "viewRule": "status = 'published'", + "createRule": null, + "updateRule": null, + "deleteRule": null + }); + + // Since we don't know the exact ID of topics collection safely across instances without looking it up, + // we can look it up first. + const topicsCollection = app.findCollectionByNameOrId("topics"); + microLearnings.schema.getFieldByName("topic_id").options.collectionId = topicsCollection.id; + + app.save(microLearnings); + + // Create micro_learning_completions collection + const completions = new Collection({ + "id": "pbc_ml_completions_0", + "name": "micro_learning_completions", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "rel_team_member_id", + "name": "team_member_id", + "type": "relation", + "required": true, + "presentable": false, + "options": { + "collectionId": "team_members_placeholder", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "rel_micro_learning_id", + "name": "micro_learning_id", + "type": "relation", + "required": true, + "presentable": false, + "options": { + "collectionId": microLearnings.id, + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "rel_comp_topic_id", + "name": "topic_id", + "type": "relation", + "required": true, + "presentable": false, + "options": { + "collectionId": topicsCollection.id, + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "txt_comp_type", + "name": "type", + "type": "text", + "required": true, + "presentable": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "num_session_week", + "name": "session_week", + "type": "number", + "required": true, + "presentable": false, + "options": { + "min": null, + "max": null, + "noDecimal": true + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != '' && team_member_id.user_id = @request.auth.id", + "viewRule": "@request.auth.id != '' && team_member_id.user_id = @request.auth.id", + "createRule": "@request.auth.id != ''", + "updateRule": null, + "deleteRule": null + }); + + const teamMembersCollection = app.findCollectionByNameOrId("team_members"); + completions.schema.getFieldByName("team_member_id").options.collectionId = teamMembersCollection.id; + + app.save(completions); + +}, (app) => { + const completions = app.findCollectionByNameOrId("micro_learning_completions"); + if (completions) { + app.delete(completions); + } + const microLearnings = app.findCollectionByNameOrId("micro_learnings"); + if (microLearnings) { + app.delete(microLearnings); + } +}) diff --git a/pb_migrations/1780800001_deleted_legacy_collections.js b/pb_migrations/1780800001_deleted_legacy_collections.js new file mode 100644 index 0000000..44aa604 --- /dev/null +++ b/pb_migrations/1780800001_deleted_legacy_collections.js @@ -0,0 +1,23 @@ +/// +migrate((app) => { + const collectionsToDrop = [ + "learn_progress", + "quiz_banks", + "quiz_cache", + "quiz_results" + ]; + + for (const name of collectionsToDrop) { + try { + const collection = app.findCollectionByNameOrId(name); + if (collection) { + app.delete(collection); + } + } catch (err) { + // Ignore if not found + } + } +}, (app) => { + // Downgrade would normally recreate these, but we omit it here for simplicity + // since they are deprecated. +}) diff --git a/src/components/micro_learning/ConceptExplainer.jsx b/src/components/micro_learning/ConceptExplainer.jsx new file mode 100644 index 0000000..e798bb8 --- /dev/null +++ b/src/components/micro_learning/ConceptExplainer.jsx @@ -0,0 +1,45 @@ +import React, { useEffect, useRef } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; // assuming some UI library, otherwise use standard divs + +export default function ConceptExplainer({ content, onComplete }) { + const containerRef = useRef(null); + + // Trigger completion when scrolled to the end + useEffect(() => { + const handleScroll = () => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + if (scrollTop + clientHeight >= scrollHeight - 50) { + onComplete(); + } + }; + + // Check initially in case content is short and doesn't need scrolling + if (containerRef.current && containerRef.current.scrollHeight <= containerRef.current.clientHeight) { + onComplete(); + } + + const currentRef = containerRef.current; + currentRef?.addEventListener('scroll', handleScroll); + return () => currentRef?.removeEventListener('scroll', handleScroll); + }, [onComplete]); + + return ( + + + Concept Explainer + + + {content?.sections?.map((section, i) => ( +
+

{section.title}

+
+
+ ))} + + + ); +} diff --git a/src/components/micro_learning/FlashcardSet.jsx b/src/components/micro_learning/FlashcardSet.jsx new file mode 100644 index 0000000..16a841c --- /dev/null +++ b/src/components/micro_learning/FlashcardSet.jsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { Card, CardContent } from '../ui/card'; +import { Button } from '../ui/button'; + +export default function FlashcardSet({ content, onComplete }) { + const [currentIndex, setCurrentIndex] = useState(0); + const [isFlipped, setIsFlipped] = useState(false); + const [viewedCards, setViewedCards] = useState(new Set()); + + const cards = content?.cards || []; + + const handleFlip = () => { + setIsFlipped(!isFlipped); + + // Once flipped, mark this card as viewed + const newViewed = new Set(viewedCards); + newViewed.add(currentIndex); + setViewedCards(newViewed); + + // If all cards are viewed, trigger completion + if (newViewed.size === cards.length && cards.length > 0) { + onComplete(); + } + }; + + const handleNext = () => { + setIsFlipped(false); + setCurrentIndex((prev) => (prev + 1) % cards.length); + }; + + const handlePrev = () => { + setIsFlipped(false); + setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length); + }; + + if (cards.length === 0) { + return
No flashcards available.
; + } + + const currentCard = cards[currentIndex]; + + return ( +
+
+ Card {currentIndex + 1} of {cards.length} +
+ + + + {isFlipped ? ( +
+

{currentCard.back}

+
+ ) : ( +
+

{currentCard.front}

+
+ )} +
+
+ +
+ Click the card to flip +
+ +
+ + +
+
+ ); +} diff --git a/src/components/micro_learning/MicroLearningContainer.jsx b/src/components/micro_learning/MicroLearningContainer.jsx new file mode 100644 index 0000000..141e28a --- /dev/null +++ b/src/components/micro_learning/MicroLearningContainer.jsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import ConceptExplainer from './ConceptExplainer'; +import ScenarioQuiz from './ScenarioQuiz'; +import FlashcardSet from './FlashcardSet'; +import ReflectionPrompt from './ReflectionPrompt'; +import { useMicroLearningCompletions } from '../../hooks/useMicroLearningCompletions'; + +export default function MicroLearningContainer({ microLearning, sessionWeek, onCompletedSuccessfully }) { + const { recordCompletion } = useMicroLearningCompletions(); + const [completed, setCompleted] = useState(false); + + const handleComplete = async () => { + if (completed) return; // Prevent double recording + + const record = await recordCompletion({ + microLearningId: microLearning.id, + topicId: microLearning.topic_id, + type: microLearning.type, + sessionWeek: sessionWeek + }); + + if (record) { + setCompleted(true); + if (onCompletedSuccessfully) { + onCompletedSuccessfully(record); + } + } + }; + + const renderComponent = () => { + const props = { + content: microLearning.content, + onComplete: handleComplete + }; + + switch (microLearning.type) { + case 'concept_explainer': + return ; + case 'scenario_quiz': + return ; + case 'flashcard_set': + return ; + case 'reflection_prompt': + return ; + default: + return
Unknown micro learning type.
; + } + }; + + return ( +
+ {renderComponent()} + + {completed && ( +
+

Micro Learning Completed!

+

Your progress has been recorded.

+
+ )} +
+ ); +} diff --git a/src/components/micro_learning/MicroLearningSelector.jsx b/src/components/micro_learning/MicroLearningSelector.jsx new file mode 100644 index 0000000..ca22bfc --- /dev/null +++ b/src/components/micro_learning/MicroLearningSelector.jsx @@ -0,0 +1,89 @@ +import React, { useState, useEffect } from 'react'; +import { useMicroLearnings } from '../../hooks/useMicroLearnings'; +import MicroLearningContainer from './MicroLearningContainer'; +import { Button } from '../ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; + +const TYPE_LABELS = { + 'concept_explainer': 'Concept Explainer', + 'scenario_quiz': 'Scenario Quiz', + 'flashcard_set': 'Flashcard Set', + 'reflection_prompt': 'Reflection Prompt' +}; + +const TYPE_DESCRIPTIONS = { + 'concept_explainer': 'Read a structured explanation to understand the concept.', + 'scenario_quiz': 'Apply your knowledge in a realistic workplace scenario.', + 'flashcard_set': 'Test your recall with a set of quick flashcards.', + 'reflection_prompt': 'Connect the topic to your own professional experience.' +}; + +export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCompleted }) { + const { getMicroLearningsByTopic } = useMicroLearnings(); + const [availableMLs, setAvailableMLs] = useState([]); + const [selectedML, setSelectedML] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchMLs = async () => { + setLoading(true); + const data = await getMicroLearningsByTopic(topicId); + setAvailableMLs(data); + setLoading(false); + }; + if (topicId) { + fetchMLs(); + } + }, [topicId]); + + const handleSelection = (ml) => { + setSelectedML(ml); + }; + + if (loading) return
Loading learning formats...
; + + if (availableMLs.length === 0) { + return
No micro learnings available for this topic yet.
; + } + + // If one is selected, render it + if (selectedML) { + return ( +
+ + +
+ ); + } + + // Otherwise, show the selection menu + return ( + + + Choose a Learning Format + + +
+ {availableMLs.map((ml) => ( + handleSelection(ml)} + > + +

{TYPE_LABELS[ml.type] || ml.type}

+

{TYPE_DESCRIPTIONS[ml.type]}

+
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/micro_learning/ReflectionPrompt.jsx b/src/components/micro_learning/ReflectionPrompt.jsx new file mode 100644 index 0000000..39ecd91 --- /dev/null +++ b/src/components/micro_learning/ReflectionPrompt.jsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { Button } from '../ui/button'; + +export default function ReflectionPrompt({ content, onComplete }) { + const [response, setResponse] = useState(''); + const [submitted, setSubmitted] = useState(false); + + const handleSubmit = (e) => { + e.preventDefault(); + if (!response.trim() || submitted) return; + + setSubmitted(true); + onComplete(); // Trigger completion when a response is submitted + }; + + return ( + + + Reflection Prompt + + +
+

{content?.prompt}

+
+ + {!submitted ? ( +
+