diff --git a/AI_AGENT.md b/AI_AGENT.md index 9342334..412cead 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. @@ -131,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/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/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 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/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/pb_migrations/1780800000_created_micro_learnings.js b/pb_migrations/1780800000_created_micro_learnings.js new file mode 100644 index 0000000..82f073a --- /dev/null +++ b/pb_migrations/1780800000_created_micro_learnings.js @@ -0,0 +1,229 @@ +/// +migrate((app) => { + const topicsCollectionId = "pbc_2800040823"; + const teamMembersCollectionId = "pbc_3980519374"; + + // Create micro_learnings collection + const microLearnings = new Collection({ + "id": "pbc_micro_learnings_0", + "name": "micro_learnings", + "type": "base", + "system": false, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text_id_ml", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "system": false, + "id": "rel_topic_id", + "name": "topic_id", + "type": "relation", + "required": true, + "presentable": false, + "collectionId": topicsCollectionId, + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + }, + { + "system": false, + "id": "sel_type", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "maxSelect": 1, + "values": [ + "concept_explainer", + "scenario_quiz", + "flashcard_set", + "reflection_prompt" + ] + }, + { + "system": false, + "id": "json_content", + "name": "content", + "type": "json", + "required": true, + "presentable": false + }, + { + "system": false, + "id": "sel_status", + "name": "status", + "type": "select", + "required": true, + "presentable": false, + "maxSelect": 1, + "values": [ + "draft", + "published" + ] + }, + { + "hidden": false, + "id": "autodate_created", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate_updated", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [], + "listRule": "status = 'published'", + "viewRule": "status = 'published'", + "createRule": null, + "updateRule": null, + "deleteRule": null + }); + + 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, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text_id_mlc", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "system": false, + "id": "rel_team_member_id", + "name": "team_member_id", + "type": "relation", + "required": true, + "presentable": false, + "collectionId": teamMembersCollectionId, + "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, + "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, + "collectionId": topicsCollectionId, + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + }, + { + "system": false, + "id": "txt_comp_type", + "name": "type", + "type": "text", + "required": true, + "presentable": false, + "min": null, + "max": null, + "pattern": "" + }, + { + "system": false, + "id": "num_session_week", + "name": "session_week", + "type": "number", + "required": true, + "presentable": false, + "noDecimal": true + }, + { + "hidden": false, + "id": "autodate_created2", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate_updated2", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [], + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "" + }); + + 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/pb_migrations/1780800002_update_micro_learnings_rules.js b/pb_migrations/1780800002_update_micro_learnings_rules.js new file mode 100644 index 0000000..9cf872e --- /dev/null +++ b/pb_migrations/1780800002_update_micro_learnings_rules.js @@ -0,0 +1,18 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("micro_learnings"); + if (collection) { + collection.createRule = ""; + collection.updateRule = ""; + collection.deleteRule = ""; + return app.save(collection); + } +}, (app) => { + const collection = app.findCollectionByNameOrId("micro_learnings"); + if (collection) { + collection.createRule = null; + collection.updateRule = null; + collection.deleteRule = null; + return app.save(collection); + } +}) 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/components/admin/CurriculumManager.jsx b/src/components/admin/CurriculumManager.jsx index 93d3a10..b03bba4 100644 --- a/src/components/admin/CurriculumManager.jsx +++ b/src/components/admin/CurriculumManager.jsx @@ -1,114 +1,188 @@ -import { useState, useEffect, useMemo } from 'react'; -import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, Loader, AlertTriangle } from 'lucide-react'; +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'; 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(() => { + // Prevent tab close during long AI operations + useBeforeUnload(isGenerating || isEnriching); + + 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 +192,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/components/admin/UploadZone.jsx b/src/components/admin/UploadZone.jsx index c6a3fb9..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,21 +52,57 @@ 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) => { 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, progress: null } : item )); }) .finally(() => { + if (progressInterval) clearInterval(progressInterval); abortRef.current = null; setProcessingId(null); }); @@ -55,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++; } @@ -102,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/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/components/micro_learning/ConceptExplainer.jsx b/src/components/micro_learning/ConceptExplainer.jsx new file mode 100644 index 0000000..e1503ba --- /dev/null +++ b/src/components/micro_learning/ConceptExplainer.jsx @@ -0,0 +1,45 @@ +import React, { useEffect, useRef } from 'react'; +import Card from '../ui/Card'; + +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..c28fdbc --- /dev/null +++ b/src/components/micro_learning/FlashcardSet.jsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import Card 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); + + const newViewed = new Set(viewedCards); + newViewed.add(currentIndex); + setViewedCards(newViewed); + + 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..97a320c --- /dev/null +++ b/src/components/micro_learning/MicroLearningContainer.jsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +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 navigate = useNavigate(); + + 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..55821d7 --- /dev/null +++ b/src/components/micro_learning/MicroLearningSelector.jsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { Loader, BookOpen, Target, Layers, MessageCircle } from 'lucide-react'; +import { useMicroLearnings } from '../../hooks/useMicroLearnings'; +import MicroLearningContainer from './MicroLearningContainer'; +import Card from '../ui/Card'; + +const TYPES = [ + { + key: 'concept_explainer', + label: 'Concept Explainer', + description: 'Read a structured explanation to understand the concept.', + icon: BookOpen, + }, + { + key: 'scenario_quiz', + label: 'Scenario Quiz', + description: 'Apply your knowledge in a realistic workplace scenario.', + icon: Target, + }, + { + key: 'flashcard_set', + label: 'Flashcard Set', + description: 'Test your recall with a set of quick flashcards.', + icon: Layers, + }, + { + key: 'reflection_prompt', + label: 'Reflection Prompt', + description: 'Connect the topic to your own professional experience.', + icon: MessageCircle, + }, +]; + +export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCompleted }) { + const { getOrGenerate } = useMicroLearnings(); + const [selectedML, setSelectedML] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSelection = async (type) => { + setLoading(true); + setError(null); + try { + const record = await getOrGenerate(topicId, type); + setSelectedML(record); + } catch (err) { + console.error('[MicroLearningSelector] Generation failed:', err); + setError(err.message || 'Failed to generate content. Please try again.'); + } finally { + setLoading(false); + } + }; + + // Loading state while AI generates + if (loading) { + return ( + + +

AI is generating your learning module…

+

This may take 10–30 seconds.

+
+ ); + } + + // Error state + if (error) { + return ( + +

Generation failed

+

{error}

+ +
+ ); + } + + // Render selected micro learning + if (selectedML) { + return ( +
+ + +
+ ); + } + + // Type selection menu — always shows all 4 types + return ( + +
+

Choose a Learning Format

+

Select how you want to engage with this topic.

+
+
+ {TYPES.map(({ key, label, description, icon: Icon }) => ( +
handleSelection(key)} + > +
+
+ +
+

{label}

+
+

{description}

+
+ ))} +
+
+ ); +} diff --git a/src/components/micro_learning/ReflectionPrompt.jsx b/src/components/micro_learning/ReflectionPrompt.jsx new file mode 100644 index 0000000..af12411 --- /dev/null +++ b/src/components/micro_learning/ReflectionPrompt.jsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import Card 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(); + }; + + return ( + +
+

Reflection Prompt

+
+
+
+

{content?.prompt}

+
+ + {!submitted ? ( +
+