feat: implement curriculum management system including automated generation, enrichment, and versioning workflows
This commit is contained in:
22
AI_AGENT.md
22
AI_AGENT.md
@@ -129,17 +129,17 @@ The platform ships a global chatbot avatar called **R42**, rendered as the Respe
|
|||||||
* **AI token budget.** If you see `[Pipeline] AI returned non-JSON response` in the logs, the response was truncated. Increase the topic cap prompt constraint before raising `max_tokens`.
|
* **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.
|
* **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
|
## 12. 26-Week Versioned Curriculum System
|
||||||
The platform uses a **52-week annual curriculum** so every employee covers all knowledge-base topics in one calendar year.
|
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.
|
* **Service:** `src/lib/curriculumService.js` — curriculum engine, week resolution, AI generation, version lifecycle.
|
||||||
* **DB functions:** `db.getCurriculum(year)`, `db.getCurriculumWeek(year, week)`, `db.setCurriculumWeek(...)`, `db.bulkSetCurriculum(year, weeks[])`.
|
* **DB Collections:** `curriculum_versions` holds the generated JSON schedules. `topics` includes `theme`, `complexity_weight`, and `difficulty` for generation input.
|
||||||
* **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.
|
* **Version Lifecycle:** `draft` -> `active` -> `superseded`. Only one active version exists at a time.
|
||||||
* **Same topic for everyone:** All employees study the same topic each week — this is by design to enable team discussion and shared quizzes.
|
* **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.
|
||||||
* **Quarterly structure:** Weeks 1–13 (Q1), 14–26 (Q2), 27–39 (Q3), 40–52 (Q4). Review/recap weeks at 13, 26, 39, 52.
|
* **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.
|
||||||
* **Fallback:** If no curriculum exists for the current year, `getAssignedTopic()` falls back to the legacy hash-based assignment for backward compatibility.
|
* **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)` and `getQuarterProgress(userId, quarter)` compute completion from the `learn_progress` collection against the curriculum.
|
* **Progress tracking:** `getYearProgress(userId, isoWeekNumber)` computes completion for the *current 26-week cycle*.
|
||||||
* **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.
|
* **Fallback:** If no active curriculum version exists, `getAssignedTopic()` falls back to the legacy hash-based assignment. Do not remove the hash fallback.
|
||||||
* **Do not remove the hash fallback** — it ensures the platform works even without a configured curriculum.
|
* **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.
|
Good luck! You are building a platform that empowers continuous learning. Keep it fast, keep it beautiful, and keep the user engaged.
|
||||||
|
|||||||
334
curriculum-spec-agnostic.md
Normal file
334
curriculum-spec-agnostic.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
# Curriculum specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines the functional behaviour of a perpetual AI-generated
|
||||||
|
learning curriculum. It is implementation-agnostic — no assumptions are made
|
||||||
|
about programming language, framework, database, or AI provider. Any system
|
||||||
|
that satisfies these functional requirements is a valid implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
**Knowledge base (KB)**
|
||||||
|
The source of truth for all learning content. Organised as a two-level
|
||||||
|
hierarchy: Themes (broad subject areas) containing Topics (specific concepts).
|
||||||
|
Topics carry metadata that informs curriculum sequencing.
|
||||||
|
|
||||||
|
**Theme**
|
||||||
|
A broad subject area. One Theme = one weekly session. A Theme contains one
|
||||||
|
or more Topics.
|
||||||
|
|
||||||
|
**Topic**
|
||||||
|
An atomic unit of knowledge within a Theme. Carries:
|
||||||
|
- a complexity weight (1–5 scale, 1 = introductory, 5 = advanced)
|
||||||
|
- a difficulty label (introductory / intermediate / advanced)
|
||||||
|
- relationships to other topics: prerequisite, related, contrast
|
||||||
|
|
||||||
|
**Curriculum**
|
||||||
|
A versioned 26-week schedule mapping one Theme per week. The curriculum is
|
||||||
|
generated by AI from the KB, not manually authored. Admins review and
|
||||||
|
finetune it before it is applied.
|
||||||
|
|
||||||
|
**Curriculum version**
|
||||||
|
Each generation produces a new version. Only one version is active at a time.
|
||||||
|
Previous versions are retained for audit purposes.
|
||||||
|
|
||||||
|
**Cycle**
|
||||||
|
One full pass through the 26-week curriculum. After completing week 26,
|
||||||
|
an employee begins cycle 2. Cycles continue indefinitely.
|
||||||
|
|
||||||
|
**Employee curriculum state**
|
||||||
|
Tracks each employee's position: current cycle, current week, start date,
|
||||||
|
and which curriculum version they are on. Employees have independent start
|
||||||
|
dates — there are no cohorts.
|
||||||
|
|
||||||
|
**Completed week**
|
||||||
|
A week where the employee has recorded at least one session completion.
|
||||||
|
Completed weeks are immutable — no regeneration or version change may alter
|
||||||
|
a completed week's record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Functional requirements
|
||||||
|
|
||||||
|
### 1. Curriculum generation
|
||||||
|
|
||||||
|
The system must be able to generate a 26-week curriculum from the current
|
||||||
|
published KB.
|
||||||
|
|
||||||
|
**Trigger conditions:**
|
||||||
|
- Manually by an admin
|
||||||
|
- Automatically when new Topics are published to the KB
|
||||||
|
|
||||||
|
**Inputs to the generator:**
|
||||||
|
- All published Themes, each with their ordered Topic list
|
||||||
|
- Per Topic: title, complexity weight, difficulty, prerequisite relationships
|
||||||
|
- If regenerating: reason for regeneration
|
||||||
|
|
||||||
|
**Generation process:**
|
||||||
|
|
||||||
|
Step 1 — Pre-process topic order within each Theme
|
||||||
|
Before any AI involvement, resolve the order of Topics within each Theme
|
||||||
|
using their prerequisite relationships. Build a directed graph of
|
||||||
|
prerequisite edges and apply a topological sort. The result is an ordered
|
||||||
|
topic list per Theme where prerequisites always precede dependent Topics.
|
||||||
|
If a cycle is detected in the graph (which should not occur in a well-formed
|
||||||
|
KB), fall back to ordering by complexity weight ascending.
|
||||||
|
|
||||||
|
This pre-processing offloads prerequisite reasoning from the AI — the AI
|
||||||
|
receives already-ordered topic lists and focuses only on Theme sequencing
|
||||||
|
across weeks.
|
||||||
|
|
||||||
|
Step 2 — AI sequences Themes across 26 weeks
|
||||||
|
The AI receives the pre-processed KB snapshot and produces a 26-week
|
||||||
|
schedule. The AI must follow these sequencing rules:
|
||||||
|
|
||||||
|
- Every Theme must appear at least once across the 26 weeks
|
||||||
|
- Themes with more Topics may span multiple weeks or recur within the 26 weeks
|
||||||
|
- Foundational Themes precede dependent ones
|
||||||
|
- Complexity increases progressively: introductory Themes in the first half,
|
||||||
|
advanced Themes in the second half
|
||||||
|
- If the KB contains more Topics than 26 weeks can cover in depth, prioritise
|
||||||
|
breadth in cycle 1 — every Theme covered, key Topics per Theme
|
||||||
|
- Each week slot has an estimated session duration (15–45 minutes)
|
||||||
|
- Each week slot includes a one-sentence rationale explaining its position
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
A draft 26-week schedule with one Theme and an ordered Topic subset per week,
|
||||||
|
estimated duration per week, and a rationale per week.
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
All Theme and Topic references in the generated schedule must resolve to
|
||||||
|
existing published KB entries. Any reference that does not resolve must
|
||||||
|
cause the generation to be retried or rejected — never persisted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Admin review and confirmation
|
||||||
|
|
||||||
|
A generated curriculum is always a draft first. It is not applied to any
|
||||||
|
employee until an admin explicitly confirms it.
|
||||||
|
|
||||||
|
**Admin capabilities before confirmation:**
|
||||||
|
- Preview the full proposed 26-week schedule
|
||||||
|
- View the rationale for each week's Theme assignment
|
||||||
|
- Reorder weeks (swap Theme assignments between weeks)
|
||||||
|
- Add or remove Topics from a week's Topic subset
|
||||||
|
- Annotate any week with free-text notes
|
||||||
|
- Compare the proposed schedule against the currently active version
|
||||||
|
|
||||||
|
**Confirmation:**
|
||||||
|
When admin confirms, the new version becomes active and is applied to all
|
||||||
|
employees whose current week falls within the regenerated range (see
|
||||||
|
section 4 — versioning).
|
||||||
|
|
||||||
|
**Rejection:**
|
||||||
|
If admin rejects the draft, it is discarded and no changes are applied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Perpetual cycling
|
||||||
|
|
||||||
|
The curriculum runs indefinitely. After an employee completes week 26, cycle 2
|
||||||
|
begins automatically on the latest active curriculum version.
|
||||||
|
|
||||||
|
**Cycle 2 and beyond are not identical to cycle 1.**
|
||||||
|
The generator must vary subsequent cycles to reinforce rather than repeat.
|
||||||
|
When generating a cycle 2+ schedule for an employee, the generator receives
|
||||||
|
additional input:
|
||||||
|
|
||||||
|
- Which micro learning types the employee has used across their history
|
||||||
|
- Which micro learning types the employee has not yet used
|
||||||
|
- Which Topics the employee engaged with minimally (completed only the
|
||||||
|
minimum required interaction)
|
||||||
|
|
||||||
|
The generator uses this input to:
|
||||||
|
- Vary the Theme sequence from the previous cycle
|
||||||
|
- Surface underused micro learning types as recommended formats
|
||||||
|
- Schedule low-engagement Topics earlier in the new cycle to give them
|
||||||
|
more visibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Versioning and version transitions
|
||||||
|
|
||||||
|
**One active version at all times.**
|
||||||
|
Previous versions are archived, not deleted.
|
||||||
|
|
||||||
|
**Completed weeks are frozen.**
|
||||||
|
When a new curriculum version is applied, only future unstarted weeks are
|
||||||
|
updated. Any week the employee has already started or completed retains its
|
||||||
|
original content. The boundary is the employee's current week at the moment
|
||||||
|
the new version is applied.
|
||||||
|
|
||||||
|
**Version transition logic:**
|
||||||
|
When a new version is confirmed:
|
||||||
|
- For each active employee: identify their current week N
|
||||||
|
- Weeks 1 through N-1 are already completed — untouched
|
||||||
|
- Week N onward is replaced with the new version's schedule
|
||||||
|
- The employee continues from week N on the new version seamlessly
|
||||||
|
|
||||||
|
**Regeneration triggers:**
|
||||||
|
Two events should prompt a regeneration:
|
||||||
|
1. New Topics are published to the KB (new content available for scheduling)
|
||||||
|
2. Admin manually requests regeneration
|
||||||
|
|
||||||
|
In both cases, the new version must be previewed and confirmed by an admin
|
||||||
|
before being applied. Regeneration is never automatic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Employee curriculum state
|
||||||
|
|
||||||
|
Each employee has an independent curriculum state:
|
||||||
|
|
||||||
|
| Attribute | Description |
|
||||||
|
|---|---|
|
||||||
|
| Current cycle | Which pass through the 26-week schedule (starts at 1) |
|
||||||
|
| Current week | Which week within the cycle (1–26) |
|
||||||
|
| Start date | When this employee began their curriculum (rolling) |
|
||||||
|
| Active version | Which curriculum version the employee's future weeks draw from |
|
||||||
|
|
||||||
|
**State transitions:**
|
||||||
|
- Week completion: current week increments by 1
|
||||||
|
- Week 26 completion: cycle increments by 1, current week resets to 1,
|
||||||
|
active version updates to latest, cycle variant generated
|
||||||
|
- Version applied: active version updates for weeks not yet started
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Coverage guarantee
|
||||||
|
|
||||||
|
The curriculum must cover the entire published KB within one cycle.
|
||||||
|
Every published Theme must appear at least once in the 26-week schedule.
|
||||||
|
Coverage is verified after generation and before the draft is made available
|
||||||
|
for admin review. A schedule that fails coverage verification is rejected
|
||||||
|
and regenerated.
|
||||||
|
|
||||||
|
Coverage statistics must be surfaced to the admin during preview:
|
||||||
|
- Total Themes in KB vs Themes scheduled
|
||||||
|
- Total Topics in KB vs Topics scheduled across all weeks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Estimated session duration
|
||||||
|
|
||||||
|
Each week slot carries an estimated duration in minutes (15–45 minute range).
|
||||||
|
This is an AI estimate based on Topic count and complexity weights for that
|
||||||
|
week. It is informational — it guides employees on time commitment but does
|
||||||
|
not enforce a time limit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data model (logical)
|
||||||
|
|
||||||
|
These are logical entities. Implementation may map them to any storage system.
|
||||||
|
|
||||||
|
**CurriculumVersion**
|
||||||
|
```
|
||||||
|
id
|
||||||
|
version_number integer, increments on each generation
|
||||||
|
status draft | active | superseded
|
||||||
|
generated_at datetime
|
||||||
|
generation_reason string
|
||||||
|
confirmed_by user reference
|
||||||
|
confirmed_at datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
**CurriculumWeek**
|
||||||
|
```
|
||||||
|
id
|
||||||
|
curriculum_version → CurriculumVersion
|
||||||
|
week_number 1–26
|
||||||
|
theme → Theme
|
||||||
|
topics ordered list of → Topic
|
||||||
|
estimated_duration minutes (15–45)
|
||||||
|
week_rationale string (AI-generated, one sentence)
|
||||||
|
admin_notes string (free text, admin-authored)
|
||||||
|
```
|
||||||
|
|
||||||
|
**EmployeeCurriculumState**
|
||||||
|
```
|
||||||
|
id
|
||||||
|
employee → User
|
||||||
|
current_cycle integer (starts at 1)
|
||||||
|
current_week integer (1–26)
|
||||||
|
start_date datetime
|
||||||
|
active_version → CurriculumVersion
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI prompt requirements
|
||||||
|
|
||||||
|
The following requirements apply to any AI model used for generation,
|
||||||
|
regardless of provider.
|
||||||
|
|
||||||
|
**Input the AI must receive:**
|
||||||
|
- Full KB snapshot with Themes and pre-ordered Topic lists
|
||||||
|
- Per Topic: complexity weight, difficulty, relationship types
|
||||||
|
- Cycle number (1 for first cycle, 2+ for subsequent)
|
||||||
|
- For cycle 2+: employee history (types used, types not used, low-engagement topics)
|
||||||
|
|
||||||
|
**Output the AI must produce:**
|
||||||
|
- Exactly 26 week slots
|
||||||
|
- Each slot: theme reference, ordered topic references, estimated duration, rationale
|
||||||
|
- Structured format suitable for machine parsing (JSON or equivalent)
|
||||||
|
- No explanatory prose outside the structured output
|
||||||
|
|
||||||
|
**Constraints on AI behaviour:**
|
||||||
|
- Temperature should be set to deterministic or near-deterministic (0 or equivalent)
|
||||||
|
- Output must be validated before persistence — never persist unvalidated AI output
|
||||||
|
- On validation failure: retry once with a stricter prompt, then surface an error
|
||||||
|
to the operator rather than persisting a partial or malformed schedule
|
||||||
|
- The AI must not invent Theme or Topic references — all references must exist
|
||||||
|
in the provided KB snapshot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin capabilities summary
|
||||||
|
|
||||||
|
| Capability | When available |
|
||||||
|
|---|---|
|
||||||
|
| Trigger manual regeneration | Any time |
|
||||||
|
| Preview proposed schedule | After generation, before confirmation |
|
||||||
|
| View week rationale | During preview |
|
||||||
|
| Reorder weeks | During preview |
|
||||||
|
| Edit topic subset per week | During preview |
|
||||||
|
| Add admin notes per week | During preview and after confirmation |
|
||||||
|
| Confirm version | During preview |
|
||||||
|
| Reject draft | During preview |
|
||||||
|
| View coverage statistics | During preview |
|
||||||
|
| View active schedule | Any time |
|
||||||
|
| View version history | Any time |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behaviours that must never occur
|
||||||
|
|
||||||
|
- A completed week's content changes after the employee has completed it
|
||||||
|
- A curriculum version is applied without admin confirmation
|
||||||
|
- AI output is persisted without validation against the KB
|
||||||
|
- An employee's cycle increments without week 26 being completed
|
||||||
|
- Two versions are active simultaneously
|
||||||
|
- A Theme is absent from a 26-week schedule when it exists in the published KB
|
||||||
|
- The admin is not shown a preview before a new version is applied
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. Generate a curriculum from a KB with 5 or more Themes → exactly 26 weeks produced
|
||||||
|
2. All published Themes appear at least once in the schedule
|
||||||
|
3. Topic order within each week respects prerequisite relationships
|
||||||
|
4. Coverage statistics correctly reflect KB vs schedule coverage
|
||||||
|
5. A draft version is available for preview before any employee is affected
|
||||||
|
6. Admin can reorder two weeks and confirm → reordered schedule applied
|
||||||
|
7. New Topics published to KB → regeneration triggered → admin sees preview →
|
||||||
|
confirms → employees at week N see weeks 1 to N unchanged, weeks N+1 onward updated
|
||||||
|
8. Employee completes week 26 → cycle 2 begins → sequence differs from cycle 1
|
||||||
|
9. Cycle 2 schedule surfaces Topics the employee engaged with minimally
|
||||||
|
10. Two employees with different start dates and current weeks both receive
|
||||||
|
correct version transitions independently when a new version is applied
|
||||||
202
pb_migrations/1780600000_curriculum_v2.js
Normal file
202
pb_migrations/1780600000_curriculum_v2.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -1,114 +1,184 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, Loader, AlertTriangle } from 'lucide-react';
|
import { Calendar, Wand2, CheckCircle2, Loader, AlertTriangle, Play, Sparkles, X, Clock, Target } from 'lucide-react';
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Tag from '../ui/Tag';
|
import Tag from '../ui/Tag';
|
||||||
import * as db from '../../lib/db';
|
import * as db from '../../lib/db';
|
||||||
import {
|
import {
|
||||||
autoGenerateCurriculum,
|
getCurriculumWeek,
|
||||||
getCurriculumYear,
|
generateCurriculumDraft,
|
||||||
getQuarterForWeek,
|
confirmVersion,
|
||||||
getQuarterName,
|
rejectVersion,
|
||||||
getFullCurriculum,
|
enrichTopicsForCurriculum,
|
||||||
|
getActiveVersion,
|
||||||
|
getDraftVersion
|
||||||
} from '../../lib/curriculumService';
|
} 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 CurriculumManager = () => {
|
||||||
const [year, setYear] = useState(getCurriculumYear());
|
const [activeVersion, setActiveVersion] = useState(null);
|
||||||
const [curriculum, setCurriculum] = useState([]);
|
const [draftVersion, setDraftVersion] = useState(null);
|
||||||
const [topics, setTopics] = useState([]);
|
const [topics, setTopics] = useState([]);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [expandedQuarters, setExpandedQuarters] = useState({ 1: true, 2: true, 3: true, 4: true });
|
const [isEnriching, setIsEnriching] = useState(false);
|
||||||
const [editingWeek, setEditingWeek] = useState(null);
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [saveStatus, setSaveStatus] = useState(null);
|
|
||||||
|
|
||||||
const currentWeek = useMemo(() => {
|
const [generationReason, setGenerationReason] = useState('');
|
||||||
|
|
||||||
|
const currentIsoWeek = (() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
||||||
}, []);
|
})();
|
||||||
|
|
||||||
|
const currentWeek = getCurriculumWeek(currentIsoWeek);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const [currData, topicData] = await Promise.all([
|
try {
|
||||||
getFullCurriculum(year),
|
const [active, draft, allTopics] = await Promise.all([
|
||||||
|
getActiveVersion(),
|
||||||
|
getDraftVersion(),
|
||||||
db.getTopics(),
|
db.getTopics(),
|
||||||
]);
|
]);
|
||||||
setCurriculum(currData);
|
setActiveVersion(active);
|
||||||
setTopics(topicData.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'));
|
setDraftVersion(draft);
|
||||||
|
setTopics(allTopics);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load curriculum state:', e);
|
||||||
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, [year]);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const handleAutoGenerate = async () => {
|
const showStatus = (msg) => {
|
||||||
if (curriculum.length > 0 && !confirm('This will replace the existing curriculum for ' + year + '. Continue?')) return;
|
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);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
await autoGenerateCurriculum(year);
|
await generateCurriculumDraft(generationReason || 'Admin request');
|
||||||
|
setGenerationReason('');
|
||||||
|
showStatus('Draft generated successfully!');
|
||||||
await load();
|
await load();
|
||||||
setSaveStatus('Curriculum generated!');
|
|
||||||
setTimeout(() => setSaveStatus(null), 3000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to generate curriculum:', e);
|
showStatus(`Generation failed: ${e.message}`);
|
||||||
setSaveStatus('Generation failed: ' + e.message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWeekTopicChange = async (weekNumber, topicId) => {
|
const handleConfirmDraft = async () => {
|
||||||
const topic = topics.find(t => t.id === topicId);
|
if (!draftVersion) return;
|
||||||
await db.setCurriculumWeek(year, weekNumber, {
|
if (!confirm('This will replace the currently active curriculum for all employees. Proceed?')) return;
|
||||||
topic_id: topicId,
|
|
||||||
theme: topic?.type || 'General',
|
try {
|
||||||
quarter: getQuarterForWeek(weekNumber),
|
// Typically we'd pass the actual admin user ID here, hardcoded to 'admin' for MVP
|
||||||
is_review_week: false,
|
await confirmVersion(draftVersion.id, 'admin');
|
||||||
sort_order: weekNumber,
|
showStatus('Curriculum activated!');
|
||||||
});
|
|
||||||
setEditingWeek(null);
|
|
||||||
await load();
|
await load();
|
||||||
setSaveStatus('Week ' + weekNumber + ' updated');
|
} catch (e) {
|
||||||
setTimeout(() => setSaveStatus(null), 2000);
|
showStatus(`Confirmation failed: ${e.message}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleReview = async (weekNumber, currentEntry) => {
|
const handleRejectDraft = async () => {
|
||||||
await db.setCurriculumWeek(year, weekNumber, {
|
if (!draftVersion) return;
|
||||||
topic_id: currentEntry?.topic_id || '',
|
if (!confirm('Are you sure you want to discard this draft?')) return;
|
||||||
theme: !currentEntry?.is_review_week ? `Q${getQuarterForWeek(weekNumber)} Review` : currentEntry?.theme || '',
|
|
||||||
quarter: getQuarterForWeek(weekNumber),
|
try {
|
||||||
is_review_week: !currentEntry?.is_review_week,
|
await rejectVersion(draftVersion.id);
|
||||||
sort_order: weekNumber,
|
showStatus('Draft discarded.');
|
||||||
});
|
|
||||||
await load();
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(`Rejection failed: ${e.message}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleQuarter = (q) => {
|
// --- Rendering Helpers ---
|
||||||
setExpandedQuarters(prev => ({ ...prev, [q]: !prev[q] }));
|
|
||||||
|
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 renderScheduleList = (schedule, isActiveVersion = false) => {
|
||||||
const quarters = [1, 2, 3, 4].map(q => ({
|
return (
|
||||||
quarter: q,
|
<div className="divide-y divide-bg-warm mt-6 border border-bg-warm rounded-[var(--r-lg)] overflow-hidden">
|
||||||
name: getQuarterName(q * 13 - 12),
|
{schedule.map((week) => {
|
||||||
weeks: curriculum.filter(w => w.quarter === q),
|
const isCurrent = isActiveVersion && week.week_number === currentWeek;
|
||||||
colors: QUARTER_COLORS[q],
|
|
||||||
startWeek: (q - 1) * 13 + 1,
|
|
||||||
endWeek: q * 13,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Stats
|
return (
|
||||||
const assignedCount = curriculum.filter(w => w.topic_id).length;
|
<div key={week.week_number} className={`p-4 ${isCurrent ? 'bg-teal/5 border-l-4 border-l-teal' : 'bg-bg'}`}>
|
||||||
const reviewCount = curriculum.filter(w => w.is_review_week).length;
|
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||||
const unassignedCount = curriculum.length > 0 ? 52 - assignedCount - reviewCount : 52;
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Main Render ---
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -118,201 +188,128 @@ const CurriculumManager = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||||
|
const unenrichedCount = learningTopics.filter(t => !t.theme).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 animate-in fade-in duration-300 pb-20">
|
||||||
{/* Header with year selector and stats */}
|
|
||||||
|
{/* Header & Global Status */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<Calendar size={20} className="text-teal" />
|
<h1 className="text-2xl font-bold text-teal flex items-center gap-2">
|
||||||
<select
|
<Calendar size={24} /> 26-Week Curriculum
|
||||||
value={year}
|
</h1>
|
||||||
onChange={e => setYear(Number(e.target.value))}
|
<p className="text-sm text-fg-muted">Manage the AI-generated perpetual learning cycle.</p>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{statusMessage && (
|
||||||
{saveStatus && <span className="text-teal text-sm font-medium">{saveStatus}</span>}
|
<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">
|
||||||
<Button
|
{statusMessage}
|
||||||
onClick={handleAutoGenerate}
|
</div>
|
||||||
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</>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats bar */}
|
{renderCoverageStats(draftVersion.coverage_stats)}
|
||||||
|
{renderScheduleList(draftVersion.schedule, false)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State / Generation Tools */}
|
||||||
|
{!draftVersion && (
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="mb-6">
|
||||||
<div className="text-center">
|
<h2 className="text-lg font-bold mb-2">Curriculum Generator</h2>
|
||||||
<div className="text-2xl font-bold text-teal">{curriculum.length}</div>
|
<p className="text-sm text-fg-muted">Generate a new 26-week draft based on the current knowledge base.</p>
|
||||||
<div className="text-xs text-fg-muted">Total Weeks</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold" style={{ color: '#22c55e' }}>{assignedCount}</div>
|
{learningTopics.length === 0 ? (
|
||||||
<div className="text-xs text-fg-muted">Topics Assigned</div>
|
<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>
|
</div>
|
||||||
<div className="text-center">
|
) : unenrichedCount > 0 ? (
|
||||||
<div className="text-2xl font-bold" style={{ color: '#7c3aed' }}>{reviewCount}</div>
|
<div className="space-y-4">
|
||||||
<div className="text-xs text-fg-muted">Review Weeks</div>
|
<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>
|
</div>
|
||||||
<div className="text-center">
|
<Button onClick={handleEnrichTopics} disabled={isEnriching}>
|
||||||
<div className="text-2xl font-bold" style={{ color: unassignedCount > 0 ? '#ef4444' : '#22c55e' }}>{unassignedCount}</div>
|
{isEnriching ? <Loader size={16} className="animate-spin mr-2" /> : <Sparkles size={16} className="mr-2" />}
|
||||||
<div className="text-xs text-fg-muted">Unassigned</div>
|
Enrich {unenrichedCount} Topics
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
{curriculum.length > 0 && (
|
<div className="flex flex-col md:flex-row gap-4 items-end">
|
||||||
<div className="mt-4 h-2 rounded-full bg-bg-warm overflow-hidden flex">
|
<div className="flex-1">
|
||||||
{[1, 2, 3, 4].map(q => {
|
<label className="block text-sm font-medium mb-1 text-fg-muted">Reason for generation (optional)</label>
|
||||||
const qWeeks = curriculum.filter(w => w.quarter === q).length;
|
<input
|
||||||
return (
|
type="text"
|
||||||
<div
|
value={generationReason}
|
||||||
key={q}
|
onChange={e => setGenerationReason(e.target.value)}
|
||||||
style={{ width: `${(qWeeks / 52) * 100}%`, backgroundColor: QUARTER_COLORS[q].accent }}
|
placeholder="e.g., 'Include new privacy topics' or 'Q3 refresh'"
|
||||||
className="h-full transition-all"
|
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-2 bg-bg focus:outline-none focus:border-teal"
|
||||||
title={`Q${q}: ${qWeeks} weeks`}
|
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={handleGenerate} disabled={isGenerating}>
|
||||||
|
{isGenerating ? (
|
||||||
|
<><Loader size={16} className="animate-spin mr-2" /> Architecting...</>
|
||||||
|
) : (
|
||||||
|
<><Wand2 size={16} className="mr-2" /> Generate Draft</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Button>
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quarter sections */}
|
{/* Active Version View */}
|
||||||
{curriculum.length > 0 && quarters.map(({ quarter, name, weeks, colors, startWeek, endWeek }) => (
|
{!draftVersion && activeVersion && (
|
||||||
<div key={quarter} className="space-y-0">
|
<Card className="border border-bg-warm">
|
||||||
<button
|
<div className="flex items-center justify-between border-b border-bg-warm pb-4 mb-4">
|
||||||
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>
|
|
||||||
</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>
|
<div>
|
||||||
<span className="font-medium">{entry.topic.label}</span>
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
<span className="text-xs text-fg-muted ml-2">{entry.theme}</span>
|
<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>
|
</div>
|
||||||
) : entry?.topic_id ? (
|
<Tag variant="success">Active</Tag>
|
||||||
<span className="text-fg-muted italic">Topic: {entry.topic_id} (not found)</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-fg-muted">Unassigned</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{renderCoverageStats(activeVersion.coverage_stats)}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
{renderScheduleList(activeVersion.schedule, true)}
|
||||||
{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>
|
</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>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,250 +1,352 @@
|
|||||||
import * as db from './db';
|
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.
|
* Get the current curriculum week (1-26) based on an ISO week number.
|
||||||
* Each quarter has a name and a list of thematic blocks.
|
|
||||||
*/
|
*/
|
||||||
const DEFAULT_QUARTERS = [
|
export function getCurriculumWeek(isoWeekNumber) {
|
||||||
{
|
return ((isoWeekNumber - 1) % 26) + 1;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
export function getCurriculumCycle(isoWeekNumber) {
|
||||||
if (weekNumber <= 13) return 1;
|
return Math.floor((isoWeekNumber - 1) / 26) + 1;
|
||||||
if (weekNumber <= 26) return 2;
|
|
||||||
if (weekNumber <= 39) return 3;
|
|
||||||
return 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
export function buildThemeTopicMap(topics) {
|
||||||
const q = getQuarterForWeek(weekNumber);
|
const map = new Map();
|
||||||
return DEFAULT_QUARTERS[q - 1]?.name || `Quarter ${q}`;
|
for (const topic of topics) {
|
||||||
}
|
if (topic.type === 'fact' || topic.learning_relevance === 'exclude') continue;
|
||||||
|
const theme = topic.theme || 'General';
|
||||||
/**
|
if (!map.has(theme)) {
|
||||||
* Get the assigned topic for a given week from the curriculum.
|
map.set(theme, []);
|
||||||
* Returns { topic, curriculumEntry } or { topic: null } if no curriculum exists.
|
}
|
||||||
*/
|
map.get(theme).push(topic);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the topic from the topics collection (ensure it is not excluded)
|
// Sort within each theme by complexity_weight ascending
|
||||||
const topics = await db.getTopics();
|
for (const [theme, themeTopics] of map.entries()) {
|
||||||
const topic = topics.find(t => t.id === entry.topic_id && t.learning_relevance !== 'exclude') || null;
|
themeTopics.sort((a, b) => (a.complexity_weight || 3) - (b.complexity_weight || 3));
|
||||||
|
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return map;
|
||||||
completed,
|
|
||||||
total: quarterWeeks.length,
|
|
||||||
percentage: quarterWeeks.length > 0
|
|
||||||
? Math.round((completed / quarterWeeks.length) * 100)
|
|
||||||
: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get overall annual progress for a user.
|
* Validates a 26-week schedule against the provided topics.
|
||||||
* Returns { completed, total, percentage }.
|
* Checks for exactly 26 weeks, duration range, theme existence, and topic existence.
|
||||||
|
* Returns { valid: boolean, errors: string[] }
|
||||||
*/
|
*/
|
||||||
export async function getYearProgress(userId, year) {
|
export function validateSchedule(schedule, topics) {
|
||||||
const currYear = year ?? getCurriculumYear();
|
const errors = [];
|
||||||
const curriculum = await db.getCurriculum(currYear);
|
if (!Array.isArray(schedule) || schedule.length !== 26) {
|
||||||
|
errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`);
|
||||||
if (curriculum.length === 0) {
|
|
||||||
return { completed: 0, total: 52, percentage: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let completed = 0;
|
const validThemes = new Set(topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude').map(t => t.theme || 'General'));
|
||||||
for (const week of curriculum) {
|
const validTopicIds = new Set(topics.map(t => t.id));
|
||||||
const done = await db.getLearnDone(userId, week.week_number);
|
|
||||||
if (done) completed++;
|
const scheduledThemes = new Set();
|
||||||
|
|
||||||
|
for (let i = 0; i < (schedule || []).length; i++) {
|
||||||
|
const week = schedule[i];
|
||||||
|
if (week.week_number !== i + 1) {
|
||||||
|
errors.push(`Week ${i + 1} has incorrect week_number: ${week.week_number}`);
|
||||||
}
|
}
|
||||||
|
if (week.estimated_duration < 15 || week.estimated_duration > 45) {
|
||||||
|
errors.push(`Week ${week.week_number} has out-of-range duration: ${week.estimated_duration}`);
|
||||||
|
}
|
||||||
|
if (!validThemes.has(week.theme)) {
|
||||||
|
errors.push(`Week ${week.week_number} references unknown theme: ${week.theme}`);
|
||||||
|
}
|
||||||
|
scheduledThemes.add(week.theme);
|
||||||
|
|
||||||
return {
|
if (!week.topic_ids || week.topic_ids.length === 0) {
|
||||||
completed,
|
errors.push(`Week ${week.week_number} has no topic_ids.`);
|
||||||
total: curriculum.length,
|
|
||||||
percentage: Math.round((completed / curriculum.length) * 100),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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++;
|
|
||||||
} else {
|
} else {
|
||||||
// No topics at all
|
for (const tId of week.topic_ids) {
|
||||||
weeks.push({
|
if (!validTopicIds.has(tId)) {
|
||||||
week_number: w,
|
errors.push(`Week ${week.week_number} references unknown topic_id: ${tId}`);
|
||||||
topic_id: '',
|
}
|
||||||
theme: 'Unassigned',
|
}
|
||||||
quarter,
|
|
||||||
is_review_week: false,
|
|
||||||
sort_order: w,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.bulkSetCurriculum(currYear, weeks);
|
// Check coverage
|
||||||
return weeks;
|
for (const t of validThemes) {
|
||||||
|
if (!scheduledThemes.has(t)) {
|
||||||
|
errors.push(`Theme '${t}' is missing from the schedule.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a curriculum exists for the given year.
|
* Computes coverage stats for a schedule.
|
||||||
*/
|
*/
|
||||||
export async function hasCurriculum(year) {
|
export function computeCoverageStats(schedule, topics) {
|
||||||
const currYear = year ?? getCurriculumYear();
|
const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||||
const entries = await db.getCurriculum(currYear);
|
const kbThemes = new Set(learningTopics.map(t => t.theme || 'General'));
|
||||||
return entries.length > 0;
|
|
||||||
|
const scheduledThemes = new Set();
|
||||||
|
const scheduledTopics = new Set();
|
||||||
|
|
||||||
|
for (const w of schedule || []) {
|
||||||
|
scheduledThemes.add(w.theme);
|
||||||
|
(w.topic_ids || []).forEach(t => scheduledTopics.add(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
themes_kb: kbThemes.size,
|
||||||
|
themes_scheduled: scheduledThemes.size,
|
||||||
|
topics_kb: learningTopics.length,
|
||||||
|
topics_scheduled: scheduledTopics.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generate a 26-week curriculum draft.
|
||||||
|
*/
|
||||||
|
export async function generateCurriculumDraft(reason) {
|
||||||
|
const topics = await db.getTopics();
|
||||||
|
const themeMap = buildThemeTopicMap(topics);
|
||||||
|
|
||||||
|
if (themeMap.size === 0) {
|
||||||
|
throw new Error('No valid topics or themes found to generate a curriculum.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the prompt context
|
||||||
|
let contextParts = [];
|
||||||
|
for (const [theme, themeTopics] of themeMap.entries()) {
|
||||||
|
const avgWeight = themeTopics.reduce((sum, t) => sum + (t.complexity_weight || 3), 0) / themeTopics.length;
|
||||||
|
let listStr = themeTopics.map((t, idx) => ` ${idx + 1}. ${t.id} (weight: ${t.complexity_weight || 3}, ${t.difficulty || 'intermediate'})`).join('\n');
|
||||||
|
contextParts.push(`Theme "${theme}" (${themeTopics.length} topics, avg complexity ${avgWeight.toFixed(1)}):\n${listStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPrompt = `KB Snapshot:\n${contextParts.join('\n\n')}\n\nGeneration reason: "${reason || 'Initial curriculum generation'}"`;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a curriculum architect for Respellion's internal learning platform.
|
||||||
|
You receive a knowledge base snapshot organized by themes, each containing an ordered list of topics. Produce a 26-week learning schedule.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Exactly 26 week slots, numbered 1-26
|
||||||
|
- Every theme must appear at least once
|
||||||
|
- Themes with more topics may span multiple weeks
|
||||||
|
- Introductory themes in the first half, advanced in the second half
|
||||||
|
- Complexity should increase progressively across the 26 weeks
|
||||||
|
- Each week: one theme, 1+ topic IDs (from that theme only), duration 15-45 min
|
||||||
|
- Include a one-sentence rationale per week explaining its position
|
||||||
|
- Do NOT invent theme or topic references — use only the provided values
|
||||||
|
- Emit via emit_curriculum_schedule tool — no prose`;
|
||||||
|
|
||||||
|
// Try generation
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await callLLM({
|
||||||
|
task: 'curriculum.generate',
|
||||||
|
tier: 'standard',
|
||||||
|
system: cachedSystem(SYSTEM_PROMPT),
|
||||||
|
user: userPrompt,
|
||||||
|
tools: [EMIT_CURRICULUM_SCHEDULE_TOOL],
|
||||||
|
toolChoice: { type: 'tool', name: EMIT_CURRICULUM_SCHEDULE_TOOL.name },
|
||||||
|
maxTokens: 8192,
|
||||||
|
temperature: 0,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`AI generation failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitted = result.toolUses[0]?.input;
|
||||||
|
if (!emitted || !emitted.weeks) {
|
||||||
|
throw new Error('The AI did not emit a valid curriculum schedule.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = emitted.weeks;
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const validation = validateSchedule(schedule, topics);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(`Generated schedule failed validation:\n- ${validation.errors.join('\n- ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = computeCoverageStats(schedule, topics);
|
||||||
|
|
||||||
|
// Reject any existing draft to enforce single-draft rule
|
||||||
|
const existingDraft = await db.getDraftCurriculumVersion();
|
||||||
|
if (existingDraft) {
|
||||||
|
await db.updateCurriculumVersion(existingDraft.id, { status: 'superseded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVersionNum = await db.getNextVersionNumber();
|
||||||
|
|
||||||
|
return db.createCurriculumVersion({
|
||||||
|
version_number: nextVersionNum,
|
||||||
|
status: 'draft',
|
||||||
|
generation_reason: reason || '',
|
||||||
|
schedule: schedule,
|
||||||
|
coverage_stats: stats,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a draft curriculum version, making it active.
|
||||||
|
*/
|
||||||
|
export async function confirmVersion(versionId, adminUserId) {
|
||||||
|
const version = await db.getCurriculumVersion(versionId);
|
||||||
|
if (!version || version.status !== 'draft') {
|
||||||
|
throw new Error('Invalid version or not a draft.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentActive = await db.getActiveCurriculumVersion();
|
||||||
|
if (currentActive) {
|
||||||
|
await db.updateCurriculumVersion(currentActive.id, { status: 'superseded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.updateCurriculumVersion(versionId, {
|
||||||
|
status: 'active',
|
||||||
|
confirmed_by: adminUserId,
|
||||||
|
confirmed_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a draft curriculum version.
|
||||||
|
*/
|
||||||
|
export async function rejectVersion(versionId) {
|
||||||
|
const version = await db.getCurriculumVersion(versionId);
|
||||||
|
if (!version || version.status !== 'draft') {
|
||||||
|
throw new Error('Invalid version or not a draft.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.updateCurriculumVersion(versionId, { status: 'superseded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveVersion() {
|
||||||
|
return db.getActiveCurriculumVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDraftVersion() {
|
||||||
|
return db.getDraftCurriculumVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersionHistory() {
|
||||||
|
return db.getCurriculumVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the assigned topics and metadata for a given ISO week number.
|
||||||
|
*/
|
||||||
|
export async function getCurrentWeekContent(isoWeekNumber) {
|
||||||
|
const activeVersion = await db.getActiveCurriculumVersion();
|
||||||
|
if (!activeVersion || !activeVersion.schedule) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekNumber = getCurriculumWeek(isoWeekNumber);
|
||||||
|
const cycle = getCurriculumCycle(isoWeekNumber);
|
||||||
|
|
||||||
|
const scheduleWeek = activeVersion.schedule.find(w => w.week_number === weekNumber);
|
||||||
|
if (!scheduleWeek) return null;
|
||||||
|
|
||||||
|
const topics = await db.getTopics();
|
||||||
|
const weekTopics = scheduleWeek.topic_ids
|
||||||
|
.map(id => topics.find(t => t.id === id))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cycle,
|
||||||
|
weekNumber,
|
||||||
|
theme: scheduleWeek.theme,
|
||||||
|
topics: weekTopics,
|
||||||
|
estimatedDuration: scheduleWeek.estimated_duration,
|
||||||
|
rationale: scheduleWeek.week_rationale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track progress for the current cycle based on completed weeks.
|
||||||
|
*/
|
||||||
|
export async function getYearProgress(userId, isoWeekNumber) {
|
||||||
|
const activeVersion = await db.getActiveCurriculumVersion();
|
||||||
|
if (!activeVersion) {
|
||||||
|
return { completed: 0, total: 26, percentage: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCycle = getCurriculumCycle(isoWeekNumber);
|
||||||
|
const cycleStartWeek = (currentCycle - 1) * 26 + 1;
|
||||||
|
const cycleEndWeek = currentCycle * 26;
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
for (let w = cycleStartWeek; w <= cycleEndWeek; w++) {
|
||||||
|
const done = await db.getLearnDone(userId, w);
|
||||||
|
if (done) completed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed,
|
||||||
|
total: 26,
|
||||||
|
percentage: Math.round((completed / 26) * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off AI backfill for theme, complexity_weight, difficulty.
|
||||||
|
*/
|
||||||
|
export async function enrichTopicsForCurriculum() {
|
||||||
|
const allTopics = await db.getTopics();
|
||||||
|
const unenriched = allTopics.filter(t => !t.theme && t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||||
|
|
||||||
|
if (unenriched.length === 0) {
|
||||||
|
return { enriched: 0, skipped: allTopics.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH_SIZE = 20; // enrich in batches to avoid token limits
|
||||||
|
let totalEnriched = 0;
|
||||||
|
|
||||||
|
const SYSTEM = `You are an AI knowledge categorizer. Your task is to enrich a batch of topics with a theme (subject domain), complexity_weight (1-5), and difficulty (introductory, intermediate, advanced). Return the enriched data via emit_topic_enrichment tool.`;
|
||||||
|
|
||||||
|
for (let i = 0; i < unenriched.length; i += BATCH_SIZE) {
|
||||||
|
const batch = unenriched.slice(i, i + BATCH_SIZE);
|
||||||
|
const batchJson = JSON.stringify(batch.map(t => ({ id: t.id, label: t.label, description: t.description })));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await callLLM({
|
||||||
|
task: 'topic.enrich',
|
||||||
|
tier: 'standard',
|
||||||
|
system: cachedSystem(SYSTEM),
|
||||||
|
user: `Enrich these topics:\n${batchJson}`,
|
||||||
|
tools: [EMIT_TOPIC_ENRICHMENT_TOOL],
|
||||||
|
toolChoice: { type: 'tool', name: EMIT_TOPIC_ENRICHMENT_TOOL.name },
|
||||||
|
maxTokens: 4096,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichedBatch = result.toolUses[0]?.input?.topics;
|
||||||
|
if (enrichedBatch && Array.isArray(enrichedBatch)) {
|
||||||
|
for (const update of enrichedBatch) {
|
||||||
|
const original = allTopics.find(t => t.id === update.id);
|
||||||
|
if (original) {
|
||||||
|
await db.saveTopics([{
|
||||||
|
...original,
|
||||||
|
theme: update.theme,
|
||||||
|
complexity_weight: update.complexity_weight,
|
||||||
|
difficulty: update.difficulty
|
||||||
|
}]);
|
||||||
|
totalEnriched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Batch enrichment failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enriched: totalEnriched, skipped: allTopics.length - totalEnriched };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,8 +246,55 @@ export function setSetting(key, value) {
|
|||||||
return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(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) {
|
export async function getCurriculum(year) {
|
||||||
try {
|
try {
|
||||||
return await pb.collection('curriculum').getFullList({
|
return await pb.collection('curriculum').getFullList({
|
||||||
@@ -257,6 +304,7 @@ export async function getCurriculum(year) {
|
|||||||
} catch { return []; }
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use curriculum_versions (v2) instead. */
|
||||||
export async function getCurriculumWeek(year, weekNumber) {
|
export async function getCurriculumWeek(year, weekNumber) {
|
||||||
try {
|
try {
|
||||||
return await pb.collection('curriculum').getFirstListItem(
|
return await pb.collection('curriculum').getFirstListItem(
|
||||||
@@ -265,11 +313,13 @@ export async function getCurriculumWeek(year, weekNumber) {
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use curriculum_versions (v2) instead. */
|
||||||
export function setCurriculumWeek(year, weekNumber, data) {
|
export function setCurriculumWeek(year, weekNumber, data) {
|
||||||
return pbUpsert('curriculum', `year=${year} && week_number=${weekNumber}`,
|
return pbUpsert('curriculum', `year=${year} && week_number=${weekNumber}`,
|
||||||
data, { year, week_number: weekNumber, ...data });
|
data, { year, week_number: weekNumber, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use curriculum_versions (v2) instead. */
|
||||||
export async function deleteCurriculumWeek(year, weekNumber) {
|
export async function deleteCurriculumWeek(year, weekNumber) {
|
||||||
try {
|
try {
|
||||||
const r = await pb.collection('curriculum').getFirstListItem(
|
const r = await pb.collection('curriculum').getFirstListItem(
|
||||||
@@ -279,6 +329,7 @@ export async function deleteCurriculumWeek(year, weekNumber) {
|
|||||||
} catch { /* nothing to delete */ }
|
} catch { /* nothing to delete */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use curriculum_versions (v2) instead. */
|
||||||
export async function bulkSetCurriculum(year, weeks) {
|
export async function bulkSetCurriculum(year, weeks) {
|
||||||
// Delete all existing entries for this year first
|
// Delete all existing entries for this year first
|
||||||
const existing = await getCurriculum(year);
|
const existing = await getCurriculum(year);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
ARTICLE_PATCH_TOOLS,
|
ARTICLE_PATCH_TOOLS,
|
||||||
} from './llmTools';
|
} from './llmTools';
|
||||||
import { applyAndValidate } from './articlePatches';
|
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.
|
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.
|
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.
|
* Get the assigned primary topic for a given week.
|
||||||
* Curriculum-first: checks the curriculum collection for the current year.
|
* Curriculum v2: checks the active curriculum version for the given ISO week.
|
||||||
* Falls back to hash-based assignment if no curriculum is configured.
|
* Falls back to hash-based assignment if no curriculum is configured.
|
||||||
*/
|
*/
|
||||||
export async function getAssignedTopic(userId, weekNumber) {
|
export async function getAssignedTopic(userId, isoWeekNumber) {
|
||||||
try {
|
try {
|
||||||
const { topic } = await getCurriculumTopic(weekNumber);
|
const weekContent = await getCurrentWeekContent(isoWeekNumber);
|
||||||
if (topic && topic.learning_relevance !== 'exclude') return topic;
|
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
|
||||||
|
// For single-topic compatibility, return the first topic
|
||||||
|
return weekContent.topics[0];
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
|
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback hash-based assignment
|
||||||
const allTopics = await db.getTopics();
|
const allTopics = await db.getTopics();
|
||||||
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||||
if (!topics || topics.length === 0) return null;
|
if (!topics || topics.length === 0) return null;
|
||||||
|
|
||||||
const str = `${userId}:${weekNumber}`;
|
const str = `${userId}:${isoWeekNumber}`;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||||
@@ -58,6 +62,24 @@ export async function getAssignedTopic(userId, weekNumber) {
|
|||||||
return topics[index];
|
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) {
|
export async function getCachedContent(topicId) {
|
||||||
return db.getContent(topicId);
|
return db.getContent(topicId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,22 @@ const SIMULATION_INFOGRAPHIC = {
|
|||||||
|
|
||||||
const SIMULATION_TOOL_STUBS = {
|
const SIMULATION_TOOL_STUBS = {
|
||||||
emit_knowledge_graph: SIMULATION_EXTRACTION_GRAPH,
|
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_article: { article: SIMULATION_ARTICLE },
|
||||||
emit_learning_slides: { slides: [SIMULATION_SLIDE] },
|
emit_learning_slides: { slides: [SIMULATION_SLIDE] },
|
||||||
|
|||||||
@@ -30,6 +30,29 @@ export const extractionResultSchema = z.object({
|
|||||||
relations: z.array(extractionRelationSchema),
|
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({
|
const articleSectionSchema = z.object({
|
||||||
heading: z.string().min(1),
|
heading: z.string().min(1),
|
||||||
@@ -185,6 +208,8 @@ export const replaceTakeawaysPatchSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const toolSchemaRegistry = {
|
export const toolSchemaRegistry = {
|
||||||
emit_knowledge_graph: extractionResultSchema,
|
emit_knowledge_graph: extractionResultSchema,
|
||||||
|
emit_curriculum_schedule: curriculumScheduleSchema,
|
||||||
|
emit_topic_enrichment: topicEnrichmentSchema,
|
||||||
emit_learning_article: learningArticleSchema,
|
emit_learning_article: learningArticleSchema,
|
||||||
emit_learning_slides: learningSlidesSchema,
|
emit_learning_slides: learningSlidesSchema,
|
||||||
emit_learning_infographic: learningInfographicSchema,
|
emit_learning_infographic: learningInfographicSchema,
|
||||||
|
|||||||
@@ -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 = {
|
const articleSectionSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as db from './db';
|
import * as db from './db';
|
||||||
import { callLLM, cachedSystem } from './llm';
|
import { callLLM, cachedSystem } from './llm';
|
||||||
import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools';
|
import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools';
|
||||||
import { getCurriculumTopic, getQuarterForWeek } from './curriculumService';
|
import { getCurrentWeekContent } from './curriculumService';
|
||||||
import { shuffle, sample } from './random';
|
import { shuffle, sample } from './random';
|
||||||
|
|
||||||
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
||||||
@@ -85,38 +85,26 @@ Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and
|
|||||||
return emitted.questions;
|
return emitted.questions;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectTestTopics(userId, weekNumber) {
|
async function selectTestTopics(userId, isoWeekNumber) {
|
||||||
const allTopics = await db.getTopics();
|
const allTopics = await db.getTopics();
|
||||||
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||||
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
|
const weekContent = await getCurrentWeekContent(isoWeekNumber);
|
||||||
|
|
||||||
if (curriculumEntry?.is_review_week) {
|
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
|
||||||
const quarter = getQuarterForWeek(weekNumber);
|
const primaryTopic = weekContent.topics[0]; // Use first topic as primary for now
|
||||||
const curriculum = await db.getCurriculum(new Date().getFullYear());
|
const others = topics.filter(t => t.id !== primaryTopic.id);
|
||||||
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));
|
const reviewTopics = sample(others, Math.min(5, others.length));
|
||||||
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
|
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
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;
|
let hash = 0;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||||
|
|||||||
@@ -178,8 +178,8 @@ const Admin = () => {
|
|||||||
|
|
||||||
{activeTab === 'curriculum' && (
|
{activeTab === 'curriculum' && (
|
||||||
<div className="animate-in fade-in duration-300 max-w-5xl mx-auto">
|
<div className="animate-in fade-in duration-300 max-w-5xl mx-auto">
|
||||||
<h1 className="text-3xl text-teal mb-2">Annual Curriculum</h1>
|
<h1 className="text-3xl text-teal mb-2">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>
|
<p className="text-fg-muted mb-8">AI-generated 26-week learning cycle. Review and activate curriculum versions.</p>
|
||||||
<CurriculumManager />
|
<CurriculumManager />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Button from '../components/ui/Button';
|
|||||||
import Tag from '../components/ui/Tag';
|
import Tag from '../components/ui/Tag';
|
||||||
import * as db from '../lib/db';
|
import * as db from '../lib/db';
|
||||||
import { getAssignedTopic } from '../lib/learningService';
|
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 Dashboard = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
@@ -22,6 +22,7 @@ const Dashboard = () => {
|
|||||||
activity: [],
|
activity: [],
|
||||||
yearProgress: null,
|
yearProgress: null,
|
||||||
hasCurriculum: false,
|
hasCurriculum: false,
|
||||||
|
theme: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,23 +58,35 @@ const Dashboard = () => {
|
|||||||
let yearProgress = null;
|
let yearProgress = null;
|
||||||
let curriculumExists = false;
|
let curriculumExists = false;
|
||||||
try {
|
try {
|
||||||
curriculumExists = await checkHasCurriculum();
|
const activeVersion = await getActiveVersion();
|
||||||
|
curriculumExists = !!activeVersion;
|
||||||
if (curriculumExists) {
|
if (curriculumExists) {
|
||||||
yearProgress = await getYearProgress(currentUser.id);
|
yearProgress = await getYearProgress(currentUser.id, weekNumber);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Dashboard] Could not load curriculum data:', e.message);
|
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();
|
load();
|
||||||
}, [currentUser, weekNumber]);
|
}, [currentUser, weekNumber]);
|
||||||
|
|
||||||
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive } = dashData;
|
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive, theme } = dashData;
|
||||||
const currentQuarter = getQuarterForWeek(weekNumber);
|
const currentCycle = getCurriculumCycle(weekNumber);
|
||||||
const quarterName = getQuarterName(weekNumber);
|
const currWeek = getCurriculumWeek(weekNumber);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<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>
|
<h1 className="text-3xl md:text-5xl mb-2">Welcome, {currentUser?.name}</h1>
|
||||||
<p className="text-fg-muted text-lg">
|
<p className="text-fg-muted text-lg">
|
||||||
{curriculumActive
|
{curriculumActive
|
||||||
? `Week ${weekNumber} · ${quarterName}`
|
? `Cycle ${currentCycle} · Week ${currWeek} of 26`
|
||||||
: `Here is your overview for week ${weekNumber}.`}
|
: `Here is your overview for week ${weekNumber}.`}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Annual Progress Bar (only when curriculum exists) */}
|
{/* Cycle Progress Bar (only when curriculum exists) */}
|
||||||
{curriculumActive && yearProgress && (
|
{curriculumActive && yearProgress && (
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
@@ -107,21 +120,21 @@ const Dashboard = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-sm text-fg-muted">{yearProgress.completed} of {yearProgress.total} weeks completed</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Tag variant="dark" className="text-xs">Q{currentQuarter}</Tag>
|
<Tag variant="dark" className="text-xs">Cycle {currentCycle}</Tag>
|
||||||
<span className="text-sm text-fg-muted">{52 - weekNumber} weeks remaining</span>
|
<span className="text-sm text-fg-muted">{26 - currWeek} weeks remaining</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Visual week progress bar */}
|
{/* Visual week progress bar */}
|
||||||
<div className="mt-4 flex gap-[2px] h-2 rounded-full overflow-hidden">
|
<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 w = i + 1;
|
||||||
const isCurrent = w === weekNumber;
|
const isCurrent = w === currWeek;
|
||||||
const isPast = w < weekNumber;
|
const isPast = w < currWeek;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={w}
|
key={w}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Tag from '../components/ui/Tag';
|
|||||||
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
|
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
|
||||||
import { getUpcomingWeeks, getQuarterProgress, getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
|
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService';
|
||||||
import * as db from '../lib/db';
|
import * as db from '../lib/db';
|
||||||
|
|
||||||
const Leren = () => {
|
const Leren = () => {
|
||||||
@@ -17,7 +17,7 @@ const Leren = () => {
|
|||||||
const [allTopics, setAllTopics] = useState([]);
|
const [allTopics, setAllTopics] = useState([]);
|
||||||
|
|
||||||
// View state
|
// View state
|
||||||
const [view, setView] = useState('overview'); // overview, detail, creating
|
const [view, setView] = useState('overview'); // overview, detail
|
||||||
const [activeTopic, setActiveTopic] = useState(null);
|
const [activeTopic, setActiveTopic] = useState(null);
|
||||||
const [content, setContent] = useState(null);
|
const [content, setContent] = useState(null);
|
||||||
|
|
||||||
@@ -25,9 +25,6 @@ const Leren = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Custom Topic
|
|
||||||
const [customTopicQuery] = useState('');
|
|
||||||
|
|
||||||
// Weekly status
|
// Weekly status
|
||||||
const [weeklyDone, setWeeklyDone] = useState(false);
|
const [weeklyDone, setWeeklyDone] = useState(false);
|
||||||
const [sessionDone, setSessionDone] = useState(false);
|
const [sessionDone, setSessionDone] = useState(false);
|
||||||
@@ -39,9 +36,8 @@ const Leren = () => {
|
|||||||
|
|
||||||
// Curriculum state
|
// Curriculum state
|
||||||
const [hasCurriculum, setHasCurriculum] = useState(false);
|
const [hasCurriculum, setHasCurriculum] = useState(false);
|
||||||
const [upcoming, setUpcoming] = useState([]);
|
|
||||||
const [quarterProgress, setQuarterProgress] = useState(null);
|
|
||||||
const [yearProgress, setYearProgress] = useState(null);
|
const [yearProgress, setYearProgress] = useState(null);
|
||||||
|
const [weekContent, setWeekContent] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.currentUser) {
|
if (state.currentUser) {
|
||||||
@@ -57,17 +53,15 @@ const Leren = () => {
|
|||||||
|
|
||||||
// Load curriculum data
|
// Load curriculum data
|
||||||
try {
|
try {
|
||||||
const currExists = await checkHasCurriculum();
|
const activeVersion = await getActiveVersion();
|
||||||
setHasCurriculum(currExists);
|
setHasCurriculum(!!activeVersion);
|
||||||
if (currExists) {
|
if (activeVersion) {
|
||||||
const [upcomingData, qProgress, yProgress] = await Promise.all([
|
const [yProgress, wc] = await Promise.all([
|
||||||
getUpcomingWeeks(state.weekNumber, 4),
|
getYearProgress(state.currentUser.id, state.weekNumber),
|
||||||
getQuarterProgress(state.currentUser.id, getQuarterForWeek(state.weekNumber)),
|
getCurrentWeekContent(state.weekNumber),
|
||||||
getYearProgress(state.currentUser.id),
|
|
||||||
]);
|
]);
|
||||||
setUpcoming(upcomingData);
|
|
||||||
setQuarterProgress(qProgress);
|
|
||||||
setYearProgress(yProgress);
|
setYearProgress(yProgress);
|
||||||
|
setWeekContent(wc);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Learn] Could not load curriculum data:', e.message);
|
console.warn('[Learn] Could not load curriculum data:', e.message);
|
||||||
@@ -262,21 +256,10 @@ const Leren = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Overview ──────────────────────────────────────────────
|
// ── Overview ──────────────────────────────────────────────
|
||||||
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||||
const currentQuarter = getQuarterForWeek(state.weekNumber);
|
const currentCycle = getCurriculumCycle(state.weekNumber);
|
||||||
const currentQuarterName = getQuarterName(state.weekNumber);
|
const currWeek = getCurriculumWeek(state.weekNumber);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
|
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
|
||||||
@@ -284,7 +267,7 @@ const Leren = () => {
|
|||||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
|
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
|
||||||
<p className="text-fg-muted text-lg">
|
<p className="text-fg-muted text-lg">
|
||||||
{hasCurriculum
|
{hasCurriculum
|
||||||
? `Week ${state.weekNumber} · ${currentQuarterName}`
|
? `Cycle ${currentCycle} · Week ${currWeek} of 26`
|
||||||
: 'Complete at least 1 topic per week. Explore more from the library!'}
|
: 'Complete at least 1 topic per week. Explore more from the library!'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,9 +279,9 @@ const Leren = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress Cards (only shown when curriculum exists) */}
|
{/* 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">
|
<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">
|
<Card className="border border-bg-warm text-center p-4">
|
||||||
<div className="relative w-16 h-16 mx-auto mb-2">
|
<div className="relative w-16 h-16 mx-auto mb-2">
|
||||||
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
||||||
@@ -315,45 +298,22 @@ const Leren = () => {
|
|||||||
{yearProgress.percentage}%
|
{yearProgress.percentage}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<div className="text-[10px] text-fg-muted">{yearProgress.completed}/{yearProgress.total} weeks</div>
|
||||||
</Card>
|
</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 */}
|
{/* Current Week */}
|
||||||
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
|
<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" />
|
<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>
|
<div className="text-xs text-fg-muted">Current Week</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Theme */}
|
||||||
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
|
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center col-span-2">
|
||||||
<TrendingUp size={24} className={`mb-1 ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`} />
|
<BookOpen size={24} className="text-purple-600 mb-1" />
|
||||||
<div className={`text-lg font-bold ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`}>
|
<div className="text-lg font-bold text-purple-700">{weekContent?.theme || 'General'}</div>
|
||||||
{weeklyDone ? 'Complete' : 'In Progress'}
|
<div className="text-xs text-fg-muted">Current Theme</div>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-fg-muted">This Week</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -376,7 +336,7 @@ const Leren = () => {
|
|||||||
{weeklyDone ? 'Completed' : 'Required'}
|
{weeklyDone ? 'Completed' : 'Required'}
|
||||||
</Tag>
|
</Tag>
|
||||||
{hasCurriculum && (
|
{hasCurriculum && (
|
||||||
<Tag variant="dark" className="text-[10px]">Week {state.weekNumber}</Tag>
|
<Tag variant="dark" className="text-[10px]">Theme: {weekContent?.theme || 'General'}</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
|
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
|
||||||
@@ -390,35 +350,6 @@ const Leren = () => {
|
|||||||
</div>
|
</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 */}
|
{/* Other Available Topics */}
|
||||||
{otherTopics.length > 0 && (
|
{otherTopics.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ function appReducer(state, action) {
|
|||||||
return { ...state, currentUser: action.payload };
|
return { ...state, currentUser: action.payload };
|
||||||
case 'LOGOUT':
|
case 'LOGOUT':
|
||||||
return { ...state, currentUser: null };
|
return { ...state, currentUser: null };
|
||||||
case 'ADVANCE_WEEK':
|
|
||||||
return { ...state, weekNumber: state.weekNumber + 1 };
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user