Merge branch 'main' of https://git.labs.respellion.tech/rve/learning-platform
Some checks failed
On Push to Main / test (push) Successful in 41s
On Push to Main / deploy-dev (push) Has been cancelled
On Push to Main / publish (push) Has been cancelled

This commit is contained in:
RaymondVerhoef
2026-05-26 14:22:08 +02:00
39 changed files with 3070 additions and 976 deletions

View File

@@ -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:<filename>` 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 113 (Q1), 1426 (Q2), 2739 (Q3), 4052 (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.

View File

@@ -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

359
micro-learning-spec.md Normal file
View File

@@ -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

View File

@@ -0,0 +1,202 @@
/// <reference path="../pb_data/types.d.ts" />
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);
});

View File

@@ -0,0 +1,21 @@
/// <reference path="../pb_data/types.d.ts" />
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);
});

View File

@@ -0,0 +1,229 @@
/// <reference path="../pb_data/types.d.ts" />
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);
}
})

View File

@@ -0,0 +1,23 @@
/// <reference path="../pb_data/types.d.ts" />
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.
})

View File

@@ -0,0 +1,18 @@
/// <reference path="../pb_data/types.d.ts" />
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);
}
})

181
sources/ROLES copy.md Normal file
View File

@@ -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

View File

@@ -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 (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
<div className="text-xl font-bold text-teal">{stats.themes_scheduled} / {stats.themes_kb}</div>
<div className="text-xs text-fg-muted">Themes Scheduled</div>
</div>
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
<div className="text-xl font-bold text-purple-600">{stats.topics_scheduled} / {stats.topics_kb}</div>
<div className="text-xs text-fg-muted">Topics Covered</div>
</div>
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
<div className="text-xl font-bold text-amber-600">26</div>
<div className="text-xs text-fg-muted">Total Weeks</div>
</div>
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
<div className="text-xl font-bold text-blue-600">100%</div>
<div className="text-xs text-fg-muted">Perpetual Cycle</div>
</div>
</div>
);
};
// 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 (
<div className="divide-y divide-bg-warm mt-6 border border-bg-warm rounded-[var(--r-lg)] overflow-hidden">
{schedule.map((week) => {
const isCurrent = isActiveVersion && week.week_number === currentWeek;
return (
<div key={week.week_number} className={`p-4 ${isCurrent ? 'bg-teal/5 border-l-4 border-l-teal' : 'bg-bg'}`}>
<div className="flex flex-col md:flex-row md:items-center gap-4">
<div className={`w-12 h-12 rounded-[var(--r-org)] flex items-center justify-center font-mono text-lg font-bold flex-shrink-0 ${isCurrent ? 'bg-teal text-white' : 'bg-bg-warm text-fg'}`}>
{week.week_number}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Tag variant="dark" className="text-xs">{week.theme}</Tag>
{isCurrent && <Tag variant="accent" className="text-[10px]">Current Week</Tag>}
<span className="text-xs text-fg-muted flex items-center gap-1 ml-2">
<Clock size={12} /> {week.estimated_duration}m
</span>
</div>
<div className="font-medium text-sm mb-1">
Topics: {week.topic_ids.join(', ')}
</div>
<p className="text-xs text-fg-muted italic">
Rationale: {week.week_rationale}
</p>
</div>
</div>
</div>
);
})}
</div>
);
};
// 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 (
<div className="space-y-6">
{/* Header with year selector and stats */}
<div className="space-y-6 animate-in fade-in duration-300 pb-20">
{/* Header & Global Status */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Calendar size={20} className="text-teal" />
<select
value={year}
onChange={e => setYear(Number(e.target.value))}
className="text-lg font-bold bg-transparent border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 focus:outline-none focus:border-teal transition-colors"
>
{[year - 1, year, year + 1].map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
<div>
<h1 className="text-2xl font-bold text-teal flex items-center gap-2">
<Calendar size={24} /> 26-Week Curriculum
</h1>
<p className="text-sm text-fg-muted">Manage the AI-generated perpetual learning cycle.</p>
</div>
<div className="flex items-center gap-3">
{saveStatus && <span className="text-teal text-sm font-medium">{saveStatus}</span>}
<Button
onClick={handleAutoGenerate}
variant={curriculum.length > 0 ? 'outline' : 'primary'}
disabled={isGenerating}
>
{isGenerating ? (
<><Loader size={16} className="mr-2 animate-spin" /> Generating...</>
) : (
<><Wand2 size={16} className="mr-2" /> {curriculum.length > 0 ? 'Regenerate' : 'Auto-Generate'} Curriculum</>
)}
</Button>
</div>
</div>
{/* Stats bar */}
<Card className="border border-bg-warm">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-teal">{curriculum.length}</div>
<div className="text-xs text-fg-muted">Total Weeks</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold" style={{ color: '#22c55e' }}>{assignedCount}</div>
<div className="text-xs text-fg-muted">Topics Assigned</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold" style={{ color: '#7c3aed' }}>{reviewCount}</div>
<div className="text-xs text-fg-muted">Review Weeks</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold" style={{ color: unassignedCount > 0 ? '#ef4444' : '#22c55e' }}>{unassignedCount}</div>
<div className="text-xs text-fg-muted">Unassigned</div>
</div>
</div>
{curriculum.length > 0 && (
<div className="mt-4 h-2 rounded-full bg-bg-warm overflow-hidden flex">
{[1, 2, 3, 4].map(q => {
const qWeeks = curriculum.filter(w => w.quarter === q).length;
return (
<div
key={q}
style={{ width: `${(qWeeks / 52) * 100}%`, backgroundColor: QUARTER_COLORS[q].accent }}
className="h-full transition-all"
title={`Q${q}: ${qWeeks} weeks`}
/>
);
})}
{statusMessage && (
<div className="px-4 py-2 bg-teal/10 text-teal rounded-[var(--r-sm)] text-sm font-medium animate-in slide-in-from-top-2">
{statusMessage}
</div>
)}
</Card>
</div>
{/* Empty state */}
{curriculum.length === 0 && (
<Card className="border border-bg-warm text-center py-12">
<Calendar size={48} className="mx-auto text-fg-muted/30 mb-4" />
<h3 className="text-xl font-bold mb-2">No curriculum for {year}</h3>
<p className="text-fg-muted mb-6 max-w-md mx-auto">
Click "Auto-Generate Curriculum" to distribute all knowledge base topics across 52 weeks
with quarterly review periods.
</p>
{topics.length === 0 && (
<div className="flex items-center justify-center gap-2 text-amber-600 text-sm mb-4">
<AlertTriangle size={16} />
<span>No topics in the knowledge base yet. Import sources first.</span>
{/* Draft Preview View */}
{draftVersion && (
<Card className="border-2 border-amber-400 bg-amber-50/30">
<div className="flex items-center justify-between border-b border-amber-200 pb-4 mb-4">
<div>
<h2 className="text-xl font-bold text-amber-700 flex items-center gap-2">
<Sparkles size={20} /> Curriculum Draft Preview
</h2>
<p className="text-sm text-amber-600/80">Generated for: "{draftVersion.generation_reason}"</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleRejectDraft} className="border-amber-300 text-amber-700 hover:bg-amber-100">
<X size={16} className="mr-2" /> Discard
</Button>
<Button onClick={handleConfirmDraft} className="bg-amber-600 text-white hover:bg-amber-700">
<CheckCircle2 size={16} className="mr-2" /> Confirm & Activate
</Button>
</div>
</div>
{renderCoverageStats(draftVersion.coverage_stats)}
{renderScheduleList(draftVersion.schedule, false)}
</Card>
)}
{/* Empty State / Generation Tools */}
{!draftVersion && (
<Card className="border border-bg-warm">
<div className="mb-6">
<h2 className="text-lg font-bold mb-2">Curriculum Generator</h2>
<p className="text-sm text-fg-muted">Generate a new 26-week draft based on the current knowledge base.</p>
</div>
{learningTopics.length === 0 ? (
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 p-4 rounded-[var(--r-sm)] border border-amber-200">
<AlertTriangle size={20} />
<span>No learning topics in the knowledge base. Import sources first before generating a curriculum.</span>
</div>
) : unenrichedCount > 0 ? (
<div className="space-y-4">
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 p-4 rounded-[var(--r-sm)] border border-amber-200">
<AlertTriangle size={20} />
<span>There are {unenrichedCount} topics missing theme and complexity data. Enrich them before generating.</span>
</div>
<Button onClick={handleEnrichTopics} disabled={isEnriching}>
{isEnriching ? <Loader size={16} className="animate-spin mr-2" /> : <Sparkles size={16} className="mr-2" />}
Enrich {unenrichedCount} Topics
</Button>
</div>
) : (
<div className="flex flex-col md:flex-row gap-4 items-end">
<div className="flex-1">
<label className="block text-sm font-medium mb-1 text-fg-muted">Reason for generation (optional)</label>
<input
type="text"
value={generationReason}
onChange={e => 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"
/>
</div>
<Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? (
<><Loader size={16} className="animate-spin mr-2" /> Architecting...</>
) : (
<><Wand2 size={16} className="mr-2" /> Generate Draft</>
)}
</Button>
</div>
)}
</Card>
)}
{/* Quarter sections */}
{curriculum.length > 0 && quarters.map(({ quarter, name, weeks, colors, startWeek, endWeek }) => (
<div key={quarter} className="space-y-0">
<button
onClick={() => toggleQuarter(quarter)}
className={`w-full flex items-center justify-between p-4 rounded-t-[var(--r-lg)] border ${colors.border} ${colors.bg} transition-colors hover:opacity-90`}
>
<div className="flex items-center gap-3">
{expandedQuarters[quarter] ? <ChevronDown size={20} className={colors.text} /> : <ChevronRight size={20} className={colors.text} />}
<div className="text-left">
<h3 className={`font-bold ${colors.text}`}>Q{quarter}: {name}</h3>
<p className="text-xs text-fg-muted">Weeks {startWeek}{endWeek} · {weeks.filter(w => w.topic_id).length} topics assigned</p>
</div>
{/* Active Version View */}
{!draftVersion && activeVersion && (
<Card className="border border-bg-warm">
<div className="flex items-center justify-between border-b border-bg-warm pb-4 mb-4">
<div>
<h2 className="text-xl font-bold flex items-center gap-2">
<Target size={20} className="text-teal" /> Active Curriculum
</h2>
<p className="text-sm text-fg-muted">Version {activeVersion.version_number} · Confirmed {new Date(activeVersion.confirmed_at).toLocaleDateString()}</p>
</div>
<Tag variant="dark" className="text-xs">{weeks.length} weeks</Tag>
</button>
{expandedQuarters[quarter] && (
<Card className={`rounded-t-none border ${colors.border} border-t-0 p-0 overflow-hidden`}>
<div className="divide-y divide-bg-warm">
{/* 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 (
<div
key={weekNum}
className={`flex items-center gap-4 p-3 px-4 transition-colors ${
isCurrent ? 'bg-teal/5 border-l-4 border-l-teal' : ''
} ${isPast ? 'opacity-60' : ''} hover:bg-bg-warm/30`}
>
{/* Week number */}
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-mono text-sm font-bold flex-shrink-0 ${
isCurrent ? 'bg-teal text-white' :
entry?.is_review_week ? 'bg-purple-100 text-purple-700' :
entry?.topic_id ? 'bg-bg-warm text-fg' :
'bg-bg-warm/50 text-fg-muted'
}`}>
{weekNum}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{entry?.is_review_week ? (
<div className="flex items-center gap-2">
<RotateCcw size={14} className="text-purple-600" />
<span className="font-medium text-purple-700">{entry.theme || `Q${quarter} Review`}</span>
</div>
) : entry?.topic ? (
<div>
<span className="font-medium">{entry.topic.label}</span>
<span className="text-xs text-fg-muted ml-2">{entry.theme}</span>
</div>
) : entry?.topic_id ? (
<span className="text-fg-muted italic">Topic: {entry.topic_id} (not found)</span>
) : (
<span className="text-fg-muted">Unassigned</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{isCurrent && <Tag variant="accent" className="text-[10px]">Current</Tag>}
{isPast && entry?.topic_id && <CheckCircle2 size={16} className="text-teal/50" />}
{editingWeek === weekNum ? (
<div className="flex items-center gap-2">
<select
autoFocus
defaultValue={entry?.topic_id || ''}
onChange={e => {
if (e.target.value === '__review__') {
handleToggleReview(weekNum, entry);
setEditingWeek(null);
} else {
handleWeekTopicChange(weekNum, e.target.value);
}
}}
onBlur={() => setEditingWeek(null)}
className="text-sm border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 bg-bg focus:outline-none focus:border-teal max-w-[200px]"
>
<option value=""> Unassigned </option>
<option value="__review__">📋 Review Week</option>
{topics.map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</div>
) : (
<button
onClick={() => setEditingWeek(weekNum)}
className="text-xs text-fg-muted hover:text-teal transition-colors px-2 py-1 rounded hover:bg-bg-warm"
>
Edit
</button>
)}
</div>
</div>
);
})}
</div>
</Card>
)}
<Tag variant="success">Active</Tag>
</div>
{renderCoverageStats(activeVersion.coverage_stats)}
{renderScheduleList(activeVersion.schedule, true)}
</Card>
)}
{!draftVersion && !activeVersion && learningTopics.length > 0 && unenrichedCount === 0 && (
<div className="text-center py-12">
<Calendar size={48} className="mx-auto text-fg-muted/30 mb-4" />
<h3 className="text-xl font-bold mb-2">No active curriculum</h3>
<p className="text-fg-muted max-w-md mx-auto">
Generate your first curriculum draft above to get started.
</p>
</div>
))}
)}
</div>
);
};

View File

@@ -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 (
<div className="space-y-4">
{/* ─── Orphaned Sources Warning ─── */}
{orphanedSources.length > 0 && (
<Card className="border-2 border-amber-300 bg-amber-50/50">
<div className="flex items-start gap-3 mb-3">
<AlertTriangle size={20} className="text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800">Interrupted extraction{orphanedSources.length > 1 ? 's' : ''} detected</p>
<p className="text-sm text-amber-700 mt-1">
{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.`}
</p>
</div>
</div>
<div className="space-y-2">
{orphanedSources.map((s) => (
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-[var(--r-sm)] bg-white/60 text-sm">
<div className="flex-1">
<span className="font-medium">{s.name}</span>
{s.progress && (
<span className="text-amber-600 ml-2 text-xs">
(stopped at chunk {s.progress.current}/{s.progress.total})
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleResetOrphan(s.id)}
className="text-xs text-amber-700 hover:text-amber-900 underline"
>
Mark failed
</button>
<button
onClick={() => handleDeleteOrphan(s.id)}
className="p-1.5 text-red-500 hover:text-red-600 hover:bg-red-50 rounded-[var(--r-sm)] transition-colors"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
</Card>
)}
{/* ─── Drag & Drop Zone ─── */}
<Card
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-10 cursor-pointer ${
@@ -142,7 +248,11 @@ const UploadZone = ({ onUploadComplete }) => {
<StatusIcon status={item.status} />
<span className="flex-1 truncate text-fg">{item.name}</span>
<span className="text-xs text-fg-muted shrink-0">
{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'}

View File

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

View File

@@ -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 (
<Card className="w-full h-[60vh] flex flex-col p-0">
<div className="p-6 border-b border-bg-warm">
<h2 className="text-xl font-bold">Concept Explainer</h2>
</div>
<div
className="flex-1 overflow-y-auto p-6 prose dark:prose-invert"
ref={containerRef}
>
{content?.sections?.map((section, i) => (
<div key={i} className="mb-6">
<h3>{section.title}</h3>
<div dangerouslySetInnerHTML={{ __html: section.content }} />
</div>
))}
</div>
</Card>
);
}

View File

@@ -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 <div className="text-center p-4">No flashcards available.</div>;
}
const currentCard = cards[currentIndex];
return (
<div className="w-full flex flex-col items-center space-y-6">
<div className="text-sm text-slate-500 font-medium">
Card {currentIndex + 1} of {cards.length}
</div>
<Card
className="w-full max-w-lg min-h-[300px] cursor-pointer relative flex items-center justify-center p-8 hover:shadow-pill transition-all"
onClick={handleFlip}
>
<div className="text-center text-xl">
{isFlipped ? (
<div className="prose dark:prose-invert">
<p>{currentCard.back}</p>
</div>
) : (
<div className="prose dark:prose-invert font-bold text-teal">
<p>{currentCard.front}</p>
</div>
)}
</div>
</Card>
<div className="text-sm text-slate-400 mt-2">
Click the card to flip
</div>
<div className="flex space-x-4">
<Button variant="outline" onClick={handlePrev}>Previous</Button>
<Button variant="outline" onClick={handleNext}>Next</Button>
</div>
</div>
);
}

View File

@@ -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 <ConceptExplainer {...props} />;
case 'scenario_quiz':
return <ScenarioQuiz {...props} />;
case 'flashcard_set':
return <FlashcardSet {...props} />;
case 'reflection_prompt':
return <ReflectionPrompt {...props} />;
default:
return <div>Unknown micro learning type.</div>;
}
};
return (
<div className="w-full max-w-3xl mx-auto">
{renderComponent()}
{completed && (
<div className="mt-4 p-4 bg-green-50 text-green-800 border border-green-200 rounded-md text-center flex flex-col items-center">
<div>
<p className="font-semibold">Micro Learning Completed!</p>
<p className="text-sm mt-1 mb-4">Your progress has been recorded.</p>
</div>
<button
onClick={() => navigate('/test')}
className="px-4 py-2 bg-teal text-white rounded-md font-medium hover:bg-teal-dark transition-colors"
>
Start Test for this Week
</button>
</div>
)}
</div>
);
}

View File

@@ -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 (
<Card className="w-full text-center py-16">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<p className="font-medium text-lg">AI is generating your learning module</p>
<p className="text-fg-muted text-sm mt-2">This may take 1030 seconds.</p>
</Card>
);
}
// Error state
if (error) {
return (
<Card className="w-full border border-red-200 bg-red-50 text-red-900 p-6">
<p className="font-bold mb-1">Generation failed</p>
<p className="text-sm mb-4">{error}</p>
<button
onClick={() => setError(null)}
className="text-sm font-medium text-red-700 hover:text-red-900 underline"
>
Back to selection
</button>
</Card>
);
}
// Render selected micro learning
if (selectedML) {
return (
<div className="space-y-4">
<button
onClick={() => setSelectedML(null)}
className="text-fg-muted hover:text-teal mb-4 text-sm font-medium"
>
Back to selection
</button>
<MicroLearningContainer
microLearning={selectedML}
sessionWeek={sessionWeek}
onCompletedSuccessfully={onTopicCompleted}
/>
</div>
);
}
// Type selection menu — always shows all 4 types
return (
<Card className="w-full">
<div className="mb-6 pb-4 border-b border-bg-warm">
<h2 className="text-xl font-bold">Choose a Learning Format</h2>
<p className="text-sm text-fg-muted mt-1">Select how you want to engage with this topic.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{TYPES.map(({ key, label, description, icon: Icon }) => (
<div
key={key}
className="cursor-pointer border-2 border-bg-warm rounded-[var(--r-md)] p-6 hover:border-teal hover:bg-teal/5 transition-all group"
onClick={() => handleSelection(key)}
>
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-teal/10 flex items-center justify-center flex-shrink-0 group-hover:bg-teal/20 transition-colors">
<Icon size={20} className="text-teal" />
</div>
<h3 className="font-bold text-lg text-teal">{label}</h3>
</div>
<p className="text-sm text-fg-muted">{description}</p>
</div>
))}
</div>
</Card>
);
}

View File

@@ -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 (
<Card className="w-full">
<div className="mb-6 pb-4 border-b border-bg-warm">
<h2 className="text-xl font-bold">Reflection Prompt</h2>
</div>
<div className="space-y-6">
<div className="prose dark:prose-invert">
<p className="text-lg font-medium">{content?.prompt}</p>
</div>
{!submitted ? (
<form onSubmit={handleSubmit} className="space-y-4">
<textarea
className="w-full min-h-[150px] p-4 border rounded-[var(--r-sm)] resize-y focus:ring-2 focus:ring-teal focus:outline-none bg-bg"
placeholder="Write your reflection here..."
value={response}
onChange={(e) => setResponse(e.target.value)}
/>
<Button type="submit" disabled={!response.trim()}>Submit Reflection</Button>
</form>
) : (
<div className="space-y-6">
<div className="p-4 bg-bg-warm rounded-[var(--r-sm)]">
<h4 className="text-sm font-semibold text-fg-muted uppercase tracking-wider mb-2">Your Reflection</h4>
<p className="whitespace-pre-wrap">{response}</p>
</div>
<div className="p-4 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)]">
<h4 className="text-sm font-semibold text-teal uppercase tracking-wider mb-2">Model Answer</h4>
<div className="prose dark:prose-invert">
<p>{content?.model_answer}</p>
</div>
</div>
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import Card from '../ui/Card';
import Button from '../ui/Button';
export default function ScenarioQuiz({ content, onComplete }) {
const [selectedOptionId, setSelectedOptionId] = useState(null);
const [showExplanations, setShowExplanations] = useState(false);
const handleSelect = (idx) => {
if (showExplanations) return;
setSelectedOptionId(idx);
setShowExplanations(true);
onComplete();
};
return (
<Card className="w-full">
<div className="mb-6 pb-4 border-b border-bg-warm">
<h2 className="text-xl font-bold">Scenario Quiz</h2>
</div>
<div className="space-y-6">
<div className="prose dark:prose-invert">
<p className="text-lg font-medium">{content?.scenario}</p>
</div>
<div className="space-y-4">
{content?.options?.map((option, idx) => {
const isSelected = selectedOptionId === idx;
let buttonStyle = "w-full justify-start text-left h-auto p-4 ";
if (showExplanations) {
if (option.isCorrect) buttonStyle += "bg-green-100 dark:bg-green-900 border-green-500 ";
else if (isSelected && !option.isCorrect) buttonStyle += "bg-red-100 dark:bg-red-900 border-red-500 ";
else buttonStyle += "opacity-70 ";
} else {
if (isSelected) buttonStyle += "bg-teal/10 ";
}
return (
<div key={idx} className="space-y-2">
<Button
variant={isSelected ? "primary" : "outline"}
className={buttonStyle}
onClick={() => handleSelect(idx)}
disabled={showExplanations}
>
<span className="flex-1 whitespace-normal">{option.text}</span>
</Button>
{showExplanations && (
<div className={`p-3 rounded-[var(--r-md)] text-sm ${option.isCorrect ? 'bg-green-50 text-green-800' : 'bg-slate-50 text-slate-700'}`}>
<span className="font-semibold">{option.isCorrect ? '✓ ' : '✗ '}</span>
{option.explanation}
</div>
)}
</div>
);
})}
</div>
</div>
</Card>
);
}

View File

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

View File

@@ -0,0 +1,42 @@
import { pb } from '../lib/pb';
import { useApp } from '../store/AppContext';
export function useMicroLearningCompletions() {
const { state } = useApp();
const recordCompletion = async ({ microLearningId, topicId, type, sessionWeek }) => {
try {
const user = state.currentUser;
if (!user) throw new Error("No authenticated user");
const record = await pb.collection('micro_learning_completions').create({
team_member_id: user.id,
micro_learning_id: microLearningId,
topic_id: topicId,
type: type,
session_week: sessionWeek
});
return record;
} catch (err) {
console.error("Error recording completion:", err);
return null;
}
};
const getSessionCompletions = async (sessionWeek) => {
try {
const user = state.currentUser;
if (!user) return [];
const records = await pb.collection('micro_learning_completions').getFullList({
filter: `team_member_id = "${user.id}" && session_week = ${sessionWeek}`
});
return records;
} catch (err) {
console.error("Error fetching session completions:", err);
return [];
}
};
return { recordCompletion, getSessionCompletions };
}

View File

@@ -0,0 +1,20 @@
import { getOrGenerateMicroLearning, regenerateMicroLearning } from '../lib/microLearningService';
export function useMicroLearnings() {
/**
* Get or generate a micro learning for the given topic and type.
* Returns a PocketBase record with .content ready to render.
*/
const getOrGenerate = async (topicId, type) => {
return getOrGenerateMicroLearning(topicId, type);
};
/**
* Force regeneration of a micro learning (deletes cached version first).
*/
const regenerate = async (topicId, type) => {
return regenerateMicroLearning(topicId, type);
};
return { getOrGenerate, regenerate };
}

View File

@@ -135,7 +135,7 @@ describe('quizQuestionsSchema', () => {
).toThrow();
});
it('rejects a missing or unknown difficulty', () => {
it('falls back to medium for a missing or unknown difficulty', () => {
const base = {
id: 'q',
question: 'q',
@@ -144,10 +144,12 @@ describe('quizQuestionsSchema', () => {
correctIndex: 0,
explanation: 'because',
};
expect(() => quizQuestionsSchema.parse({ questions: [base] })).toThrow();
expect(() =>
quizQuestionsSchema.parse({ questions: [{ ...base, difficulty: 'trivial' }] }),
).toThrow();
// Missing difficulty should default to 'medium'
const parsed1 = quizQuestionsSchema.parse({ questions: [base] });
expect(parsed1.questions[0].difficulty).toBe('medium');
// Unknown difficulty should also default to 'medium'
const parsed2 = quizQuestionsSchema.parse({ questions: [{ ...base, difficulty: 'trivial' }] });
expect(parsed2.questions[0].difficulty).toBe('medium');
});
});

View File

@@ -54,8 +54,8 @@ describe('forceGenerateTopicQuestions', () => {
beforeEach(() => {
bankStore.clear();
callLLMMock.mockReset();
debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => { });
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
});
afterEach(() => { debugSpy.mockRestore(); warnSpy.mockRestore(); });

View File

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

View File

@@ -1,250 +1,395 @@
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<themeName, Topic[]>
*/
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);
export function validateSchedule(schedule, topics) {
const errors = []; // Hard errors — schedule is unusable
const warnings = []; // Soft warnings — schedule is usable but imperfect
if (curriculum.length === 0) {
return { completed: 0, total: 52, percentage: 0 };
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}`);
}
// 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) {
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;
// 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)) {
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, warnings };
}
/**
* 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 (${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${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 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 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 validationResult = { valid: false, errors: [], warnings: [] };
for (let attempt = 1; attempt <= 2; attempt++) {
let prompt = userPrompt;
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 {
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) {
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.');
}
continue;
}
schedule = emitted.weeks;
validationResult = validateSchedule(schedule, topics);
if (validationResult.valid) {
break; // Hard errors resolved — warnings are acceptable
}
}
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);
// 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();
// 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;
}
/**
* 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 topicMap = new Map(topics.map(t => [t.id, t]));
const weekTopics = scheduleWeek.topic_ids
.map(id => topicMap.get(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.upsertTopic({
...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 };
}

View File

@@ -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 });
}
}
@@ -38,10 +41,10 @@ export async function upsertTopic(topic) {
await pb.collection('topics').getOne(topic.id);
return await pb.collection('topics').update(topic.id, topic);
} catch {
return await pb.collection('topics').create({
id: topic.id,
return await pb.collection('topics').create({
id: topic.id,
learning_relevance: 'standard',
...topic
...topic
});
}
}
@@ -93,28 +96,14 @@ export async function deleteContent(topicId) {
} catch { /* no record, nothing to do */ }
}
// ── Quiz Banks ───────────────────────────────────────────────────────────────
// ── Quiz Banks (DEPRECATED — collection dropped) ────────────────────────────
function normalizeQuizQuestion(q) {
if (!q || typeof q !== 'object') return q;
return q.difficulty ? q : { ...q, difficulty: 'medium' };
}
export async function getQuizBank(topicId) {
try {
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
return (r.questions || []).map(normalizeQuizQuestion);
} catch { return []; }
}
export function setQuizBank(topicId, questions) {
return pbUpsert('quiz_banks', `topic_id="${topicId}"`, { questions }, { topic_id: topicId, questions });
}
export async function deleteQuestionFromBank(topicId, questionId) {
const questions = await getQuizBank(topicId);
return setQuizBank(topicId, questions.filter(q => q.id !== questionId));
}
/** @deprecated quiz_banks collection has been dropped. */
export async function getQuizBank() { return []; }
/** @deprecated quiz_banks collection has been dropped. */
export async function setQuizBank() { return null; }
/** @deprecated quiz_banks collection has been dropped. */
export async function deleteQuestionFromBank() { return null; }
// ── Sources ──────────────────────────────────────────────────────────────────
@@ -130,6 +119,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);
}
@@ -152,61 +159,26 @@ export async function deleteTeamMember(id) {
return pb.collection('team_members').delete(id);
}
// ── Quiz Results ─────────────────────────────────────────────────────────────
// ── Quiz Results (DEPRECATED — collection dropped) ──────────────────────────
export async function getQuizResult(userId, weekNumber) {
try {
return await pb.collection('quiz_results').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
} catch { return null; }
}
/** @deprecated quiz_results collection has been dropped. */
export async function getQuizResult() { return null; }
/** @deprecated quiz_results collection has been dropped. */
export async function saveQuizResult() { return null; }
export async function saveQuizResult(userId, weekNumber, result) {
return pb.collection('quiz_results').create({
user_id: userId,
week_number: weekNumber,
score: result.score,
total: result.total,
percentage: result.percentage,
time_used: result.timeUsed,
completed_at: result.completedAt,
breakdown: result.breakdown,
points_earned: result.pointsEarned,
});
}
// ── Quiz Cache (DEPRECATED — collection dropped) ────────────────────────────
// ── Quiz Cache ────────────────────────────────────────────────────────────────
/** @deprecated quiz_cache collection has been dropped. */
export async function getCachedQuiz() { return null; }
/** @deprecated quiz_cache collection has been dropped. */
export async function setCachedQuiz() { return null; }
export async function getCachedQuiz(userId, weekNumber) {
try {
return await pb.collection('quiz_cache').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
} catch { return null; }
}
// ── Learn Progress (DEPRECATED — collection dropped) ────────────────────────
export function setCachedQuiz(userId, weekNumber, quiz) {
const data = { questions: quiz.questions, meta: quiz.meta };
return pbUpsert('quiz_cache', `user_id="${userId}" && week_number=${weekNumber}`,
data, { user_id: userId, week_number: weekNumber, ...data });
}
// ── Learn Progress ────────────────────────────────────────────────────────────
export async function getLearnDone(userId, weekNumber) {
try {
const r = await pb.collection('learn_progress').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`
);
return r.done;
} catch { return false; }
}
export function setLearnDone(userId, weekNumber) {
return pbUpsert('learn_progress', `user_id="${userId}" && week_number=${weekNumber}`,
{ done: true }, { user_id: userId, week_number: weekNumber, done: true });
}
/** @deprecated learn_progress collection has been dropped. */
export async function getLearnDone() { return false; }
/** @deprecated learn_progress collection has been dropped. */
export async function setLearnDone() { return null; }
// ── Leaderboard ───────────────────────────────────────────────────────────────
@@ -246,8 +218,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 +276,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 +285,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 +301,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);
@@ -319,14 +342,13 @@ export async function resetForSmokeTest({
const collections = [
'relations',
'content',
'quiz_banks',
'quiz_results',
'quiz_cache',
'curriculum',
'sources',
'topics',
'micro_learnings',
'micro_learning_completions',
];
if (includeProgress) collections.push('learn_progress', 'leaderboard');
if (includeProgress) collections.push('leaderboard');
const results = [];
for (const name of collections) {

View File

@@ -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.
@@ -130,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 }));
@@ -140,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',
@@ -149,6 +158,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,
});
@@ -169,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');

View File

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

View File

@@ -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] },
@@ -332,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', 'AbortError')), timeoutMs);
const timer = setTimeout(() => {
didTimeout = true;
timeoutCtl.abort();
}, timeoutMs);
const fetchSignal = linkSignals(signal, timeoutCtl.signal);
try {
@@ -365,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);
}

View File

@@ -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),
@@ -89,20 +112,32 @@ export const learningAllSchema = z.object({
infographic: infographicBodySchema,
});
const quizDifficultyEnum = z.enum(['easy', 'medium', 'hard']);
const quizDifficultyEnum = z.enum(['easy', 'medium', 'hard']).catch('medium');
const quizQuestionSchema = z.object({
id: z.string().min(1),
id: z.string().min(1).catch(`gen-${Math.random().toString(36).slice(2, 8)}`),
question: z.string().min(1),
topicLabel: z.string().min(1),
topicLabel: z.string().min(1).catch('General'),
options: z.array(z.string().min(1)).length(4),
correctIndex: z.number().int().min(0).max(3),
explanation: z.string().min(1),
correctIndex: z.preprocess(
(v) => (typeof v === 'number' ? Math.round(v) : v),
z.number().int().min(0).max(3),
),
explanation: z.string().min(1).catch('See the correct answer above.'),
difficulty: quizDifficultyEnum,
});
export const quizQuestionsSchema = z.object({
questions: z.array(quizQuestionSchema).min(1),
questions: z.preprocess(
(v) => {
// If the model returned an array directly, wrap it
if (Array.isArray(v)) return v;
// If it returned a single object, wrap in array
if (v && typeof v === 'object' && !Array.isArray(v)) return [v];
return v;
},
z.array(quizQuestionSchema).min(1),
),
});
export const customTopicSchema = z.object({
@@ -185,6 +220,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,

View File

@@ -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',
@@ -289,3 +341,92 @@ export const ARTICLE_PATCH_TOOLS = [
REMOVE_SECTION_TOOL,
REPLACE_TAKEAWAYS_TOOL,
];
// ── Micro Learning generation tools ───────────────────────────────────────────
export const EMIT_CONCEPT_EXPLAINER_TOOL = {
name: 'emit_concept_explainer',
description: 'Return a structured concept explanation with multiple sections. Each section moves from definition → importance → practical application. The final section must include a concrete workplace example.',
input_schema: {
type: 'object',
properties: {
sections: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string', description: 'Section heading.' },
content: { type: 'string', description: 'Section body in HTML. Use <p>, <ul>, <li>, <strong> tags for formatting. At least 3 sentences.' },
},
required: ['title', 'content'],
},
minItems: 3,
description: 'At least 3 sections: What it is, Why it matters, Practical example.',
},
},
required: ['sections'],
},
};
export const EMIT_SCENARIO_QUIZ_TOOL = {
name: 'emit_scenario_quiz',
description: 'Return a realistic workplace scenario with 34 plausible answer options. Exactly one option is correct. Each option must have a detailed explanation teaching why it is right or wrong.',
input_schema: {
type: 'object',
properties: {
scenario: { type: 'string', description: 'A realistic workplace situation (35 sentences) where the employee must decide what to do.' },
options: {
type: 'array',
items: {
type: 'object',
properties: {
text: { type: 'string', description: 'The action the employee could take.' },
isCorrect: { type: 'boolean', description: 'True for exactly one option.' },
explanation: { type: 'string', description: 'Why this option is correct or incorrect (23 sentences). Teach, do not just state.' },
},
required: ['text', 'isCorrect', 'explanation'],
},
minItems: 3,
maxItems: 4,
},
},
required: ['scenario', 'options'],
},
};
export const EMIT_FLASHCARD_SET_TOOL = {
name: 'emit_flashcard_set',
description: 'Return a set of 510 flashcards covering key facts, terms, and relationships from the topic. Mix question types: definitions, applications, and relationships.',
input_schema: {
type: 'object',
properties: {
cards: {
type: 'array',
items: {
type: 'object',
properties: {
front: { type: 'string', description: 'The question or prompt shown on the front of the card.' },
back: { type: 'string', description: 'The answer revealed on the back of the card.' },
},
required: ['front', 'back'],
},
minItems: 5,
maxItems: 10,
},
},
required: ['cards'],
},
};
export const EMIT_REFLECTION_PROMPT_TOOL = {
name: 'emit_reflection_prompt',
description: 'Return an open-ended reflection question that asks the employee to connect the topic to their own professional experience, plus a model answer showing the expected depth and specificity.',
input_schema: {
type: 'object',
properties: {
prompt: { type: 'string', description: 'An open-ended question that cannot be answered with a fact. It must require the employee to think about their own context.' },
model_answer: { type: 'string', description: 'An example of a thoughtful, specific response (35 sentences). This is not a rubric — it illustrates depth.' },
},
required: ['prompt', 'model_answer'],
},
};

View File

@@ -0,0 +1,195 @@
/**
* Micro Learning generation service.
*
* Implements the generate-then-cache strategy:
* 1. Check PocketBase for an existing published record (topic × type)
* 2. If found → return it (cache hit)
* 3. If not → call LLM, store result as published, return it
*
* Content is generated once per (topic, type) pair and shared across all users.
*/
import { pb } from './pb';
import { callLLM, cachedSystem } from './llm';
import {
EMIT_CONCEPT_EXPLAINER_TOOL,
EMIT_SCENARIO_QUIZ_TOOL,
EMIT_FLASHCARD_SET_TOOL,
EMIT_REFLECTION_PROMPT_TOOL,
} from './llmTools';
import * as db from './db';
// ── Configuration per micro learning type ─────────────────────────────────────
const MICRO_LEARNING_TYPES = {
concept_explainer: {
tool: EMIT_CONCEPT_EXPLAINER_TOOL,
tier: 'standard',
maxTokens: 4096,
instructions: `Generate a concept explainer with at least 3 sections.
Section 1: What the concept is — define it clearly.
Section 2: Why it matters — explain its importance in the workplace.
Section 3: Practical example — give a concrete, realistic scenario showing how it works in practice.
Use HTML formatting in the content fields (<p>, <ul>, <li>, <strong>).`,
},
scenario_quiz: {
tool: EMIT_SCENARIO_QUIZ_TOOL,
tier: 'standard',
maxTokens: 4096,
instructions: `Generate a scenario quiz with a realistic workplace situation.
The scenario should be specific and domain-relevant — something the employee might actually encounter.
Provide 34 answer options. Exactly one must be correct.
Each option needs a detailed explanation (23 sentences) that teaches why it is right or wrong.
The incorrect options should represent common mistakes or reasonable misreadings, not obviously wrong answers.`,
},
flashcard_set: {
tool: EMIT_FLASHCARD_SET_TOOL,
tier: 'fast',
maxTokens: 2048,
instructions: `Generate a flashcard set with 510 cards.
Mix three question types:
- Definitions: "What is X?"
- Applications: "How would you apply X in situation Y?"
- Relationships: "How does X relate to Y?"
Keep answers concise — one or two sentences maximum.`,
},
reflection_prompt: {
tool: EMIT_REFLECTION_PROMPT_TOOL,
tier: 'fast',
maxTokens: 1024,
instructions: `Generate a reflection prompt.
The question must be open-ended and cannot be answered with a fact.
It must require the employee to think about their own professional context — their team, their role, their past experience.
The model answer should show depth and specificity (35 sentences). It is not a rubric — it is an example of thoughtful reflection.`,
},
};
const SYSTEM_PROMPT = `You are an expert learning content writer for Respellion, an internal IT company.
You create micro learning content for employees based on knowledge topics from the company knowledge base.
Always write in clear, professional English.
Make the content practical and anchored to the workplace — avoid abstract theory without application.
Emit the content through the provided tool — do not return prose or raw JSON.`;
// ── Core API ──────────────────────────────────────────────────────────────────
/**
* Get an existing micro learning or generate a new one.
* Returns the PocketBase record (with .content parsed).
*/
export async function getOrGenerateMicroLearning(topicId, type) {
const config = MICRO_LEARNING_TYPES[type];
if (!config) throw new Error(`Unknown micro learning type: ${type}`);
// 1. Check cache
const existing = await findExisting(topicId, type);
if (existing) {
console.log(`[MicroLearning] Cache hit: ${topicId} / ${type}`);
return existing;
}
// 2. Load topic metadata
const topic = await loadTopic(topicId);
if (!topic) throw new Error(`Topic not found: ${topicId}`);
// 3. Generate
console.log(`[MicroLearning] Generating: ${topicId} / ${type} (tier: ${config.tier})`);
const content = await generateContent(topic, type, config);
// 4. Store in PocketBase
const record = await pb.collection('micro_learnings').create({
topic_id: topicId,
type: type,
content: content,
status: 'published',
});
console.log(`[MicroLearning] Stored: ${record.id}`);
return record;
}
/**
* Delete an existing micro learning and regenerate it.
* Used when a topic's content has changed and the cached version is stale.
*/
export async function regenerateMicroLearning(topicId, type) {
const config = MICRO_LEARNING_TYPES[type];
if (!config) throw new Error(`Unknown micro learning type: ${type}`);
// Delete existing if present
const existing = await findExisting(topicId, type);
if (existing) {
console.log(`[MicroLearning] Deleting stale record: ${existing.id}`);
await pb.collection('micro_learnings').delete(existing.id);
}
// Generate fresh
return getOrGenerateMicroLearning(topicId, type);
}
/**
* Delete all cached micro learnings for a topic (all types).
*/
export async function deleteAllForTopic(topicId) {
try {
const records = await pb.collection('micro_learnings').getFullList({
filter: `topic_id = "${topicId}"`,
});
for (const record of records) {
await pb.collection('micro_learnings').delete(record.id);
}
console.log(`[MicroLearning] Deleted ${records.length} records for topic ${topicId}`);
return records.length;
} catch (err) {
console.error('[MicroLearning] Error deleting records:', err);
return 0;
}
}
// ── Internal helpers ──────────────────────────────────────────────────────────
async function findExisting(topicId, type) {
try {
const records = await pb.collection('micro_learnings').getFullList({
filter: `topic_id = "${topicId}" && type = "${type}" && status = "published"`,
});
return records.length > 0 ? records[0] : null;
} catch {
return null;
}
}
async function loadTopic(topicId) {
try {
const topics = await db.getTopics();
return topics.find(t => t.id === topicId) || null;
} catch {
return null;
}
}
async function generateContent(topic, type, config) {
const prompt = `Generate a ${type.replace('_', ' ')} micro learning for the following topic:
Label: ${topic.label}
Type: ${topic.type}
Description: ${topic.description}
${config.instructions}`;
const result = await callLLM({
task: `micro_learning.${type}`,
tier: config.tier,
system: cachedSystem(SYSTEM_PROMPT),
user: prompt,
tools: [config.tool],
toolChoice: { type: 'tool', name: config.tool.name },
maxTokens: config.maxTokens,
});
const content = result.toolUses[0]?.input;
if (!content) {
throw new Error(`AI did not return content for ${type}. Please try again.`);
}
return content;
}

View File

@@ -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.
@@ -38,7 +38,7 @@ function dominantCorrectIndex(questions) {
const counts = [0, 0, 0, 0];
for (const q of questions) counts[q.correctIndex] = (counts[q.correctIndex] || 0) + 1;
const max = Math.max(...counts);
return max / questions.length > 0.5 ? { index: counts.indexOf(max), ratio: max / questions.length } : null;
return max / questions.length > 0.8 ? { index: counts.indexOf(max), ratio: max / questions.length } : null;
}
function validateBatchQuality(questions) {
@@ -66,7 +66,7 @@ Topic: ${topic.label}
Type: ${topic.type}
Description: ${topic.description}
Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial. Example: a question whose correct answer is option C uses "correctIndex": 2.`;
Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial. Ensure correct answers are evenly distributed.`;
const result = await callLLM({
task: 'quiz.generate',
@@ -79,44 +79,66 @@ Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and
});
const emitted = result.toolUses[0]?.input;
if (!emitted?.questions?.length) {
throw new Error(`Could not generate questions for ${topic.label}`);
if (!emitted) {
throw new Error(`LLM did not emit tool output for ${topic.label}`);
}
return emitted.questions;
// Handle different shapes the LLM might return:
// 1. { questions: [...] } — expected
// 2. [...] — model returned array directly
// 3. { questions: { ... } } — model wrapped single question in object
let questions;
if (Array.isArray(emitted.questions)) {
questions = emitted.questions;
} else if (Array.isArray(emitted)) {
questions = emitted;
} else if (emitted.questions && typeof emitted.questions === 'object') {
// Single question wrapped as object instead of array
questions = [emitted.questions];
} else {
throw new Error(`Unexpected quiz output shape for ${topic.label}`);
}
if (!questions.length) {
throw new Error(`No questions generated for ${topic.label}`);
}
// Ensure each question has required fields (fill in defaults for missing ones)
return questions.map((q, i) => ({
id: q.id || `gen-${i}-${Math.random().toString(36).slice(2, 8)}`,
question: q.question || '',
topicLabel: q.topicLabel || topic.label,
options: Array.isArray(q.options) ? q.options.slice(0, 4) : [],
correctIndex: typeof q.correctIndex === 'number' ? q.correctIndex : 0,
explanation: q.explanation || 'No explanation provided.',
difficulty: q.difficulty || 'medium',
})).filter(q => q.question && q.options.length === 4);
}
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);
const reviewTopics = sample(others, Math.min(5, others.length));
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
const primaryTopic = weekContent.topics[0];
if (!primaryTopic?.id) {
console.warn('[Test] First curriculum topic has no id — falling back to hash');
} else {
const others = topics.filter(t => t.id !== primaryTopic.id);
const reviewTopics = sample(others, Math.min(5, others.length));
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);
@@ -143,7 +165,14 @@ export async function forceGenerateTopicQuestions(topic, count = 5) {
let candidates = null;
for (let attempt = 0; attempt < 3; attempt++) {
const questions = await callQuizModel(topic, count);
let questions;
try {
questions = await callQuizModel(topic, count);
} catch (err) {
console.warn(`[quiz] LLM failed (attempt ${attempt + 1}):`, err.message);
lastQualityError = err.message;
continue;
}
const qualityError = validateBatchQuality(questions);
if (qualityError) {
@@ -193,8 +222,12 @@ async function getOrGenerateTopicQuestions(topic, count) {
let bank = await db.getQuizBank(topic.id);
if (bank.length < count) {
await forceGenerateTopicQuestions(topic, 5);
bank = await db.getQuizBank(topic.id);
try {
await forceGenerateTopicQuestions(topic, 5);
bank = await db.getQuizBank(topic.id);
} catch (err) {
console.warn(`[quiz] Failed to generate questions for ${topic.label}:`, err.message);
}
}
return sample(bank, Math.min(count, bank.length));
@@ -211,7 +244,7 @@ export async function deleteQuestion(topicId, questionId) {
export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
if (!force) {
const cached = await db.getCachedQuiz(userId, weekNumber);
if (cached) return cached;
if (cached?.questions?.length > 0) return cached;
}
const { primaryTopic, reviewTopics } = await selectTestTopics(userId, weekNumber);
@@ -219,26 +252,31 @@ export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
const questions = [];
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 5);
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 3);
questions.push(...primaryQs);
for (const rt of reviewTopics) {
if (questions.length >= 5) break;
const rtQs = await getOrGenerateTopicQuestions(rt, 1);
questions.push(...rtQs);
}
if (questions.length < 10) {
const needed = 10 - questions.length;
if (questions.length < 5) {
const needed = 5 - questions.length;
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, needed + 5);
const existingIds = new Set(questions.map(q => q.id));
for (const eq of extraQs) {
if (!existingIds.has(eq.id) && questions.length < 10) {
if (!existingIds.has(eq.id) && questions.length < 5) {
questions.push(eq);
}
}
}
const shuffled = shuffle(questions);
const shuffled = shuffle(questions).slice(0, 5);
if (shuffled.length === 0) {
throw new Error('Could not assemble enough questions for this week\'s test. Please try again.');
}
const quiz = {
questions: shuffled,

View File

@@ -178,8 +178,8 @@ const Admin = () => {
{activeTab === 'curriculum' && (
<div className="animate-in fade-in duration-300 max-w-5xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Annual Curriculum</h1>
<p className="text-fg-muted mb-8">Plan and manage the 52-week learning schedule. All employees follow the same weekly topic.</p>
<h1 className="text-3xl text-teal mb-2">Curriculum</h1>
<p className="text-fg-muted mb-8">AI-generated 26-week learning cycle. Review and activate curriculum versions.</p>
<CurriculumManager />
</div>
)}

View File

@@ -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 (
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
@@ -81,12 +94,12 @@ const Dashboard = () => {
<h1 className="text-3xl md:text-5xl mb-2">Welcome, {currentUser?.name}</h1>
<p className="text-fg-muted text-lg">
{curriculumActive
? `Week ${weekNumber} · ${quarterName}`
? `Cycle ${currentCycle} · Week ${currWeek} of 26`
: `Here is your overview for week ${weekNumber}.`}
</p>
</header>
{/* Annual Progress Bar (only when curriculum exists) */}
{/* Cycle Progress Bar (only when curriculum exists) */}
{curriculumActive && yearProgress && (
<Card className="border border-bg-warm">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
@@ -107,21 +120,21 @@ const Dashboard = () => {
</span>
</div>
<div>
<h3 className="font-bold text-lg">Annual Progress</h3>
<h3 className="font-bold text-lg">Cycle Progress</h3>
<p className="text-sm text-fg-muted">{yearProgress.completed} of {yearProgress.total} weeks completed</p>
</div>
</div>
<div className="flex items-center gap-3">
<Tag variant="dark" className="text-xs">Q{currentQuarter}</Tag>
<span className="text-sm text-fg-muted">{52 - weekNumber} weeks remaining</span>
<Tag variant="dark" className="text-xs">Cycle {currentCycle}</Tag>
<span className="text-sm text-fg-muted">{26 - currWeek} weeks remaining</span>
</div>
</div>
{/* Visual week progress bar */}
<div className="mt-4 flex gap-[2px] h-2 rounded-full overflow-hidden">
{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 (
<div
key={w}
@@ -165,7 +178,7 @@ const Dashboard = () => {
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl">Testing</h3>
<p className="text-fg-muted text-sm mt-1">Weekly test 10 questions</p>
<p className="text-fg-muted text-sm mt-1">Weekly test 5 questions</p>
</div>
{testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>}
</div>
@@ -194,9 +207,8 @@ const Dashboard = () => {
) : (
activity.slice(0, 5).map((act, i) => (
<div key={i} className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-bold ${
act.type === 'test' ? 'bg-accent-soft text-purple-700' : 'bg-sage text-teal-900'
}`}>
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-bold ${act.type === 'test' ? 'bg-accent-soft text-purple-700' : 'bg-sage text-teal-900'
}`}>
{act.type === 'test' ? 'T' : 'L'}
</div>
<div>

View File

@@ -1,15 +1,16 @@
import { useState, useEffect } from 'react';
import { BookOpen, CheckCircle, Loader, ArrowRight, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { BookOpen, CheckCircle, ArrowRight, ChevronLeft, Calendar } from 'lucide-react';
import { motion } from 'framer-motion';
import { Link } from 'react-router-dom';
import Card from '../components/ui/Card';
import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag';
import LearningContentViewer from '../components/ui/LearningContentViewer';
import { useApp } from '../store/AppContext';
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
import { getUpcomingWeeks, getQuarterProgress, getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
import { getAssignedTopic } from '../lib/learningService';
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService';
import * as db from '../lib/db';
import MicroLearningSelector from '../components/micro_learning/MicroLearningSelector';
import { useMicroLearningCompletions } from '../hooks/useMicroLearningCompletions';
const Leren = () => {
const { state } = useApp();
@@ -17,126 +18,73 @@ 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);
// Loading & Error
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);
// Feedback
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [feedbackText, setFeedbackText] = useState('');
const [feedbackPrompted, setFeedbackPrompted] = useState(false);
// 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);
const { getSessionCompletions } = useMicroLearningCompletions();
useEffect(() => {
if (state.currentUser) {
const load = async () => {
const [assigned, topics, done] = await Promise.all([
const [assigned, topics] = await Promise.all([
getAssignedTopic(state.currentUser.id, state.weekNumber),
db.getTopics(),
db.getLearnDone(state.currentUser.id, state.weekNumber),
]);
setAssignedTopic(assigned);
setAllTopics(topics);
if (done) setWeeklyDone(true);
// Load curriculum data
let currentWeekContent = null;
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);
currentWeekContent = wc;
setWeekContent(wc);
}
} catch (e) {
console.warn('[Learn] Could not load curriculum data:', e.message);
}
// Check completions for the current week
const completions = await getSessionCompletions(state.weekNumber);
// Define required topics for the week
// We assume assignedTopic is the minimum required if no explicit list in weekContent.
// Actually, let's just check if the assignedTopic is completed.
const requiredTopicIds = currentWeekContent?.topics || [assigned?.id].filter(Boolean);
const completedTopicIds = new Set(completions.map(c => c.topic_id));
const allRequiredCompleted = requiredTopicIds.length > 0 && requiredTopicIds.every(id => completedTopicIds.has(id));
setWeeklyDone(allRequiredCompleted);
};
load();
}
}, [state.currentUser, state.weekNumber]);
}, [state.currentUser, state.weekNumber, view]); // Reload when view changes (e.g. going back to overview)
const handleOpenTopic = async (topic) => {
const handleOpenTopic = (topic) => {
setActiveTopic(topic);
setView('detail');
setSessionDone(false);
setError(null);
setFeedbackText('');
setFeedbackPrompted(false);
const cached = await getCachedContent(topic.id);
if (cached) {
setContent(cached);
} else {
setContent(null);
}
};
const loadContent = async (selectedType = 'article') => {
if (!activeTopic) return;
setIsLoading(true);
setError(null);
try {
const generated = await generateLearningContent(activeTopic, false, selectedType);
setContent(generated);
} catch (e) {
setError(e.message);
} finally {
setIsLoading(false);
}
};
const doComplete = async () => {
const handleTopicCompleted = () => {
setSessionDone(true);
if (!weeklyDone) {
setWeeklyDone(true);
await db.setLearnDone(state.currentUser.id, state.weekNumber);
}
};
const handleComplete = () => {
if (!feedbackPrompted) {
setFeedbackPrompted(true);
setShowFeedbackModal(true);
return;
}
doComplete();
};
const handleSubmitFeedback = async () => {
if (feedbackText.trim()) {
await db.setSetting(
`feedback:${state.currentUser.id}:${activeTopic.id}:${state.weekNumber}`,
feedbackText.trim()
);
}
setShowFeedbackModal(false);
doComplete();
};
const handleSkipFeedback = () => {
setShowFeedbackModal(false);
doComplete();
};
// ── Detail View ──────────────────────────────────────────
@@ -147,16 +95,14 @@ const Leren = () => {
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
<h1 className="text-3xl font-bold mb-4">Topic reviewed!</h1>
<p className="text-fg-muted mb-8">
You have successfully reviewed "<strong>{activeTopic.label}</strong>".
{weeklyDone && ' Your weekly minimum is met.'}
You have successfully completed a micro learning for "<strong>{activeTopic.label}</strong>".
</p>
<div className="flex justify-center gap-4">
<Button variant="outline" onClick={() => setView('overview')}>Learn Another</Button>
<Link to="/test">
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
</Link>
<Button variant="outline" onClick={() => { setView('overview'); setSessionDone(false); }}>
Back to Station
</Button>
</div>
</div>
);
@@ -164,50 +110,6 @@ const Leren = () => {
return (
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
<AnimatePresence>
{showFeedbackModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
>
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 16 }}
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
className="w-full max-w-lg"
>
<Card className="border border-bg-warm shadow-xl">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-teal/10 flex items-center justify-center flex-shrink-0">
<MessageSquare size={20} className="text-teal" />
</div>
<h2 className="text-xl font-bold">How was this content?</h2>
</div>
<p className="text-fg-muted text-sm mb-5">
Your feedback helps improve future learning content. This is optional you can skip if you prefer.
</p>
<textarea
autoFocus
value={feedbackText}
onChange={e => setFeedbackText(e.target.value)}
placeholder="What was clear? What could be improved? Anything missing?"
rows={4}
className="w-full rounded-[var(--r-sm)] border border-bg-warm bg-bg p-3 text-sm resize-none focus:outline-none focus:border-teal transition-colors"
/>
<div className="flex justify-end gap-3 mt-4">
<Button variant="outline" onClick={handleSkipFeedback}>Skip</Button>
<Button onClick={handleSubmitFeedback}>Submit Feedback</Button>
</div>
</Card>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<button onClick={() => setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
<ChevronLeft size={16} /> Back to overview
</button>
@@ -221,62 +123,19 @@ const Leren = () => {
<p className="text-fg-muted mt-2">{activeTopic.description}</p>
</div>
{!content && !isLoading && (
<Card className="border border-bg-warm text-center py-16">
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
<p className="text-fg-muted mb-6">Choose how you want to learn this topic.</p>
<div className="flex flex-wrap justify-center gap-4">
<Button onClick={() => loadContent('article')}>Article</Button>
<Button onClick={() => loadContent('slides')}>Slides</Button>
<Button onClick={() => loadContent('infographic')}>Infographic</Button>
</div>
</Card>
)}
{isLoading && (
<Card className="border border-bg-warm text-center py-16">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<p className="font-medium">AI is generating your learning module...</p>
<p className="text-fg-muted text-sm mt-2">This may take 1030 seconds.</p>
</Card>
)}
{error && (
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
<p className="font-bold mb-1">Generation failed</p>
<p className="text-sm">{error}</p>
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">Try again</Button>
</Card>
)}
{content && <LearningContentViewer content={content} topic={activeTopic} onGenerate={loadContent} isLoading={isLoading} />}
{content && (
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
<Button onClick={handleComplete}>
<CheckCircle size={18} className="mr-2" /> Complete Session
</Button>
</div>
)}
</div>
);
}
// ── Creating Topic Loading ─────────────────────────────────
if (view === 'creating') {
return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<h1 className="text-2xl font-bold text-teal mb-2">Architecting New Topic</h1>
<p className="text-fg-muted">The AI is creating a structure for "{customTopicQuery}"...</p>
<MicroLearningSelector
topicId={activeTopic.id}
sessionWeek={state.weekNumber}
onTopicCompleted={handleTopicCompleted}
/>
</div>
);
}
// ── 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 (
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
@@ -284,21 +143,15 @@ const Leren = () => {
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
<p className="text-fg-muted text-lg">
{hasCurriculum
? `Week ${state.weekNumber} · ${currentQuarterName}`
? `Cycle ${currentCycle} · Week ${currWeek} of 26`
: 'Complete at least 1 topic per week. Explore more from the library!'}
</p>
</div>
{error && (
<div className="mb-6 bg-red-50 text-red-800 p-4 rounded-[var(--r-sm)] border border-red-200">
{error}
</div>
)}
{/* Progress Cards (only shown when curriculum exists) */}
{hasCurriculum && yearProgress && quarterProgress && (
{hasCurriculum && yearProgress && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{/* Year Progress */}
{/* Cycle Progress */}
<Card className="border border-bg-warm text-center p-4">
<div className="relative w-16 h-16 mx-auto mb-2">
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
@@ -315,45 +168,22 @@ const Leren = () => {
{yearProgress.percentage}%
</span>
</div>
<div className="text-xs text-fg-muted">Annual Progress</div>
<div className="text-xs text-fg-muted">Cycle Progress</div>
<div className="text-[10px] text-fg-muted">{yearProgress.completed}/{yearProgress.total} weeks</div>
</Card>
{/* Quarter Progress */}
<Card className="border border-bg-warm text-center p-4">
<div className="relative w-16 h-16 mx-auto mb-2">
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
<circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-bg-warm)" strokeWidth="3" />
<circle
cx="18" cy="18" r="15.5" fill="none"
stroke="#7c3aed" strokeWidth="3"
strokeDasharray={`${quarterProgress.percentage} 100`}
strokeLinecap="round"
className="transition-all duration-700"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
{quarterProgress.percentage}%
</span>
</div>
<div className="text-xs text-fg-muted">Q{currentQuarter} Progress</div>
<div className="text-[10px] text-fg-muted">{quarterProgress.completed}/{quarterProgress.total} weeks</div>
</Card>
{/* Current Week */}
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
<Calendar size={24} className="text-teal mb-1" />
<div className="text-2xl font-bold text-teal">{state.weekNumber}</div>
<div className="text-2xl font-bold text-teal">{currWeek}</div>
<div className="text-xs text-fg-muted">Current Week</div>
</Card>
{/* Status */}
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
<TrendingUp size={24} className={`mb-1 ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`} />
<div className={`text-lg font-bold ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`}>
{weeklyDone ? 'Complete' : 'In Progress'}
</div>
<div className="text-xs text-fg-muted">This Week</div>
{/* Theme */}
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center col-span-2">
<BookOpen size={24} className="text-purple-600 mb-1" />
<div className="text-lg font-bold text-purple-700">{weekContent?.theme || 'General'}</div>
<div className="text-xs text-fg-muted">Current Theme</div>
</Card>
</div>
)}
@@ -376,7 +206,7 @@ const Leren = () => {
{weeklyDone ? 'Completed' : 'Required'}
</Tag>
{hasCurriculum && (
<Tag variant="dark" className="text-[10px]">Week {state.weekNumber}</Tag>
<Tag variant="dark" className="text-[10px]">Theme: {weekContent?.theme || 'General'}</Tag>
)}
</div>
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
@@ -390,35 +220,6 @@ const Leren = () => {
</div>
)}
{/* Upcoming Schedule (only when curriculum exists) */}
{hasCurriculum && upcoming.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<Calendar size={20} className="text-fg-muted" /> Coming Up
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{upcoming.map(week => (
<Card key={week.week_number} className="border border-bg-warm p-4">
<div className="flex items-center justify-between mb-2">
<Tag variant="dark" className="text-[10px] font-mono">Week {week.week_number}</Tag>
{week.is_review_week && <Tag variant="accent" className="text-[10px]">Review</Tag>}
</div>
{week.topic ? (
<>
<h4 className="font-medium text-sm leading-tight">{week.topic.label}</h4>
<p className="text-xs text-fg-muted mt-1">{week.theme}</p>
</>
) : week.is_review_week ? (
<h4 className="font-medium text-sm text-purple-600">{week.theme}</h4>
) : (
<h4 className="text-sm text-fg-muted italic">Unassigned</h4>
)}
</Card>
))}
</div>
</div>
)}
{/* Other Available Topics */}
{otherTopics.length > 0 && (
<div>

View File

@@ -98,6 +98,9 @@ const Testen = () => {
setError(null);
try {
const q = await generateWeeklyQuiz(currentUser.id, weekNumber);
if (!q?.questions?.length) {
throw new Error('No questions could be generated for this week. Please try again.');
}
setQuiz(q);
setCurrentQ(0);
setAnswers({});
@@ -179,7 +182,7 @@ const Testen = () => {
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Weekly Test</h1>
<p className="text-fg-muted text-lg mb-2">Week {weekNumber}</p>
<p className="text-fg-muted mb-8 max-w-md mx-auto">
Test your knowledge with 10 AI-generated questions. You have 5 minutes to complete the test. Good luck!
Test your knowledge with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck!
</p>
{error && (
@@ -193,7 +196,7 @@ const Testen = () => {
<div className="space-y-3">
<div className="flex items-center justify-center gap-6 text-sm text-fg-muted">
<span className="flex items-center gap-1.5"><CheckSquare size={14} /> 10 questions</span>
<span className="flex items-center gap-1.5"><CheckSquare size={14} /> 5 questions</span>
<span className="flex items-center gap-1.5"><Clock size={14} /> 5 minutes</span>
<span className="flex items-center gap-1.5"><Trophy size={14} /> 20 pts max</span>
</div>
@@ -212,7 +215,7 @@ const Testen = () => {
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<p className="font-medium text-lg">AI is generating your test...</p>
<p className="text-sm text-fg-muted mt-2">Preparing 10 questions based on your learning topics.</p>
<p className="text-sm text-fg-muted mt-2">Preparing 5 questions based on your learning topics.</p>
</div>
);
}
@@ -230,9 +233,8 @@ const Testen = () => {
animate={{ scale: 1, opacity: 1 }}
className="text-center mb-10"
>
<div className={`inline-flex items-center justify-center w-28 h-28 rounded-full mb-6 ${
isPerfect ? 'bg-teal text-white' : isGood ? 'bg-teal/10 text-teal' : 'bg-orange-100 text-orange-600'
}`}>
<div className={`inline-flex items-center justify-center w-28 h-28 rounded-full mb-6 ${isPerfect ? 'bg-teal text-white' : isGood ? 'bg-teal/10 text-teal' : 'bg-orange-100 text-orange-600'
}`}>
<span className="text-4xl font-bold">{result.percentage}%</span>
</div>
<h1 className="text-3xl font-bold mb-2">
@@ -278,9 +280,8 @@ const Testen = () => {
{result.breakdown.map((item, i) => (
<Card key={i} className={`border ${item.correct ? 'border-teal/30' : 'border-red-200'}`}>
<div className="flex items-start gap-3 mb-3">
<div className={`flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${
item.correct ? 'bg-teal/10 text-teal' : 'bg-red-50 text-red-500'
}`}>
<div className={`flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${item.correct ? 'bg-teal/10 text-teal' : 'bg-red-50 text-red-500'
}`}>
{i + 1}
</div>
<div className="flex-1">
@@ -296,13 +297,12 @@ const Testen = () => {
return (
<div
key={oi}
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${
isCorrect
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${isCorrect
? 'border-teal bg-teal/5 text-teal font-medium'
: wasSelected
? 'border-red-300 bg-red-50 text-red-700'
: 'border-bg-warm text-fg-muted'
}`}
? 'border-red-300 bg-red-50 text-red-700'
: 'border-bg-warm text-fg-muted'
}`}
>
{isCorrect && <CheckCircle size={14} className="inline mr-1 -mt-0.5" />}
{wasSelected && !isCorrect && <XCircle size={14} className="inline mr-1 -mt-0.5" />}
@@ -323,6 +323,17 @@ const Testen = () => {
// ─── Active Quiz ──────────────────────────────────────────
if (phase === 'quiz' && quiz) {
const q = quiz.questions[currentQ];
// Safety guard — should never happen, but prevents a crash if questions array is empty
if (!q) {
return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
<p className="text-red-500 font-medium">Something went wrong loading this question.</p>
<Button className="mt-4" onClick={() => setPhase('intro')}>Back</Button>
</div>
);
}
const selectedAnswer = answers[q.id];
const isCorrect = selectedAnswer === q.correctIndex;
const progress = ((currentQ + (showFeedback ? 1 : 0)) / quiz.questions.length) * 100;

View File

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

View File

@@ -15,6 +15,9 @@ function getBuildSha() {
// https://vite.dev/config/
export default defineConfig({
build: {
sourcemap: true,
},
define: {
__BUILD_SHA__: JSON.stringify(getBuildSha()),
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),