Add specifications for gamification, generation, and R42 chat services

- Introduced gamification service spec detailing responsibilities, API surface, XP calculation, levels, streaks, badges, milestone cards, and heatmap data.
- Added generation service spec outlining the process for generating micro learning content, including API endpoints, AI call configuration, prompt strategies, and error handling.
- Created R42 chat service spec covering chatbot interactions, retrieval pipeline, prompt construction, response generation, and stateless design principles.
This commit is contained in:
RaymondVerhoef
2026-05-23 18:13:08 +02:00
parent dda20612e9
commit 472685f0d7
62 changed files with 11552 additions and 21 deletions

407
docs/curriculum-spec.md Normal file
View File

@@ -0,0 +1,407 @@
# Curriculum service spec
## Responsibility
Generates a versioned 26-week learning schedule from the published knowledge
base. Manages perpetual cycling, version transitions, and employee curriculum
state. Handles regeneration when the KB changes.
---
## Service location
```
app/services/curriculum/
├── src/
│ ├── index.ts entry point, Fastify server
│ ├── routes/
│ │ ├── curriculum.ts POST /generate, GET /current, GET /preview
│ │ └── employee.ts GET /state/:userId, POST /advance/:userId
│ ├── generator/
│ │ ├── build.ts KB graph → 26-week schedule (AI call)
│ │ ├── sequence.ts prerequisite + complexity ordering
│ │ └── cycle.ts cycle 2+ variation logic
│ ├── versioning/
│ │ ├── apply.ts apply new version to active employees
│ │ └── freeze.ts protect completed weeks
│ └── lib/
│ ├── pocketbase.ts
│ └── anthropic.ts
├── package.json
├── tsconfig.json
└── .env.example
```
---
## API surface
### POST /generate
Triggers curriculum generation from current published KB.
Called by admin app after confirming regeneration.
Request:
```json
{
"triggeredBy": "string",
"reason": "new_topics" | "manual"
}
```
Response (202 Accepted):
```json
{
"jobId": "string",
"status": "queued"
}
```
---
### GET /preview
Returns proposed new curriculum before admin confirms.
Called by admin app to show preview before regeneration is applied.
Response:
```json
{
"version": 3,
"weeks": [
{
"weekNumber": 1,
"theme": { "id": "string", "title": "string" },
"topics": [
{ "id": "string", "title": "string", "complexityWeight": 2 }
],
"estimatedDurationMinutes": 25
}
],
"coverageStats": {
"themesTotal": 8,
"themesCovered": 8,
"topicsTotal": 42,
"topicsCovered": 42
}
}
```
---
### GET /current
Returns the currently active curriculum version with all week slots.
---
### GET /state/:userId
Returns an employee's current curriculum state.
Response:
```json
{
"userId": "string",
"currentCycle": 1,
"currentWeek": 7,
"startDate": "2026-01-15T00:00:00Z",
"activeVersionId": "string",
"nextSessionTheme": { "id": "string", "title": "string" },
"nextSessionTopics": []
}
```
---
### POST /advance/:userId
Called by progress service when an employee completes a week.
Increments currentWeek, handles cycle transition at week 26.
Request:
```json
{
"completedWeek": 7
}
```
---
## Curriculum generation
### Input
All published Themes and Topics retrieved from PocketBase:
```typescript
type KBSnapshot = {
themes: {
id: string
title: string
description: string
topics: {
id: string
title: string
complexityWeight: number // 15
difficulty: string
prerequisiteTopics: string[] // topic IDs
relatedTopics: string[]
contrastTopics: string[]
}[]
}[]
}
```
---
### Pre-processing: sequence topics within themes
Before the AI call, the service resolves topic ordering within each Theme
using a topological sort on prerequisite relationships.
```
For each Theme:
Build directed graph: prerequisite_topics edges
Topological sort → ordered topic list
If cycle detected (should not occur but handle): log warning, fall back to
complexity_weight ascending order
```
This pre-processing means the AI does not need to reason about prerequisites —
it receives already-ordered topic lists and focuses on Theme sequencing.
---
### AI call: Theme sequencing across 26 weeks
System prompt:
```
You are a curriculum designer. Your task is to distribute a set of learning
Themes across 26 weekly sessions to create an effective learning journey.
Output ONLY valid JSON matching the schema provided. No preamble, no
explanation, no markdown fences.
Rules:
- Every Theme must appear at least once across 26 weeks
- Themes with more Topics (higher topic count) may span multiple weeks or
appear in multiple cycles within the 26 weeks
- Sequence Themes so foundational concepts precede dependent ones
- Distribute complexity progressively: introductory Themes early, advanced
Themes in the second half
- If total Topics across all Themes exceeds what 26 weeks can cover in depth,
prioritise breadth in cycle 1 — every Theme covered, key Topics per Theme
- Assign an estimated duration in minutes per week (1545 minutes per session)
- Return exactly 26 week slots
```
User prompt:
```
Knowledge base snapshot:
{KBSnapshot as JSON}
Generate a 26-week curriculum schedule.
```
Output schema:
```typescript
type CurriculumDraft = {
weeks: {
weekNumber: number // 126
themeId: string
topicIds: string[] // ordered subset of theme's topics
estimatedDurationMinutes: number
rationale: string // one sentence — shown to admin in preview
}[]
}
```
AI call configuration:
```typescript
{
model: 'claude-sonnet-4-20250514',
max_tokens: 4000,
temperature: 0
}
```
Validation: Zod schema on output. Check all themeIds and topicIds exist in
the KB snapshot before writing. Reject and retry once on validation failure.
---
### Write to PocketBase
```
Create curriculum_versions record {
version: latest + 1,
status: 'draft',
generated_at: now,
generation_notes: reason
}
For each week in CurriculumDraft:
Create curriculum_weeks record {
curriculum_version: versionId,
week_number: weekNumber,
theme: themeId,
topics: topicIds,
topic_order: [0, 1, 2, ...],
estimated_duration_minutes: value,
admin_notes: ''
}
Set curriculum_versions.status → 'draft'
Notify admin: preview available at GET /preview
```
Draft version is not applied until admin confirms via POST /generate confirm.
---
## Versioning and regeneration
### Applying a new version
When admin confirms, `apply.ts` runs:
```
Get all employees from employee_curriculum_state
For each employee:
frozenWeek = employee.current_week
Update employee_curriculum_state:
active_version = new version ID
Note: completed weeks are protected by current_week value
The frontend only renders weeks >= current_week from active_version
Weeks < current_week are rendered from session_completions history
(immutable records — not from curriculum_weeks)
Set old curriculum_versions.status → 'superseded'
Set new curriculum_versions.status → 'active'
```
Completed weeks are never stored against a curriculum version — they live
in session_completions. The version only determines future week content.
---
## Perpetual cycling
### Week 26 completion → cycle transition
When progress service calls POST /advance/:userId with completedWeek: 26:
```
employee.currentCycle += 1
employee.currentWeek = 1
employee.startDate = now
employee.activeVersion = current active version
Generate cycle variant (see below)
```
### Cycle variant generation
Cycle 2+ is not identical to cycle 1. The AI call receives additional context:
Additional fields in user prompt for cycle 2+:
```json
{
"cycleNumber": 2,
"employeeHistory": {
"typesUsed": ["concept_explainer", "scenario_quiz", "how_to"],
"typesNotUsed": ["case_study", "myth_vs_evidence", "comparison_card"],
"lowEngagementTopics": ["topic-id-1", "topic-id-2"]
}
}
```
Additional rules added to system prompt for cycle 2+:
```
- Vary the Theme sequence from the previous cycle
- Topics identified as low engagement should appear earlier in this cycle
- The rationale field should note what is different from cycle 1
```
Low engagement is determined by: topics where the employee completed only
one micro learning type (minimum engagement). Retrieved from session_completions
by progress service and passed to curriculum service on cycle transition.
---
## Admin curriculum editor
The curriculum editor in the admin app (built in frontend phase) calls:
- GET /preview to display the proposed schedule
- PATCH /weeks/:weekId to update theme or topic assignment
- POST /confirm to apply the version
The PATCH route allows admin to:
- Reassign a Theme to a different week (swap two weeks)
- Add or remove Topics from a week's topic list
- Edit admin_notes per week
Changes made via PATCH update the draft curriculum_weeks records before
the version is confirmed and applied.
---
## Environment variables
```
ANTHROPIC_API_KEY=
POCKETBASE_URL=
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
CURRICULUM_PORT=3003
```
---
## Dependencies
```json
{
"dependencies": {
"fastify": "^4",
"@anthropic-ai/sdk": "^0.24",
"pocketbase": "^0.21",
"zod": "^3",
"uuid": "^9"
}
}
```
---
## TypeScript strict mode requirements
- No `any` types
- KBSnapshot typed explicitly — validated against PocketBase response
- CurriculumDraft validated through Zod before any PocketBase writes
- Topological sort implemented with explicit typed graph structure
---
## What this service does NOT do
- Does not generate micro learnings → generation service
- Does not record completions → progress service
- Does not serve KB content → frontend reads PocketBase directly
- Does not handle auth → PocketBase + frontend
---
## Testing checkpoints
1. Generate curriculum from a KB with 5+ themes → confirm 26 weeks produced
2. Confirm all themes appear at least once
3. Confirm topic order within a week respects prerequisites
4. Add a new theme to KB → trigger regeneration → confirm employee at week 5
sees weeks 15 unchanged, weeks 626 updated
5. Advance employee through week 26 → confirm cycle 2 starts with varied sequence
6. Admin edits week 3 theme → confirm patch updates draft before confirmation

701
docs/frontend-spec.md Normal file
View File

@@ -0,0 +1,701 @@
# Frontend spec
## Responsibility
Single Next.js 14 codebase serving two distinct role-based experiences:
- `/admin/*` — content administration (document upload, KB review, curriculum)
- `/app/*` — employee learning experience (sessions, library, R42, gamification)
Mobile-first. Designed for 375px width, scales up. Installable as a PWA.
---
## Location
```
app/frontend/
├── src/
│ ├── app/ Next.js app router
│ │ ├── layout.tsx root layout — global stylesheet import
│ │ ├── page.tsx redirect → role-based landing
│ │ ├── admin/
│ │ │ ├── layout.tsx admin shell (sidebar nav)
│ │ │ ├── page.tsx admin dashboard
│ │ │ ├── documents/
│ │ │ │ └── page.tsx document upload + ingestion status
│ │ │ ├── knowledge/
│ │ │ │ ├── page.tsx theme batch review list
│ │ │ │ └── [themeId]/page.tsx theme detail + topic edit
│ │ │ └── curriculum/
│ │ │ └── page.tsx curriculum editor + regeneration
│ │ ├── app/
│ │ │ ├── layout.tsx employee shell (bottom nav + R42)
│ │ │ ├── page.tsx redirect → /app/session
│ │ │ ├── session/
│ │ │ │ └── page.tsx current week session
│ │ │ ├── library/
│ │ │ │ ├── page.tsx knowledge library browse
│ │ │ │ └── [topicId]/page.tsx topic detail
│ │ │ └── profile/
│ │ │ └── page.tsx gamification profile + heatmap + badges
│ │ ├── auth/
│ │ │ └── page.tsx login (PocketBase auth)
│ │ └── api/ Next.js API routes (thin proxies only)
│ ├── components/
│ │ ├── admin/ admin-specific components
│ │ ├── employee/ employee-specific components
│ │ ├── micro-learnings/ one component per micro learning type
│ │ ├── r42/ R42 chatbot components
│ │ ├── gamification/ heatmap, badges, leaderboard
│ │ └── ui/ shared primitives
│ ├── lib/
│ │ ├── pocketbase.ts PocketBase client (browser)
│ │ ├── services.ts typed API calls to backend services
│ │ ├── auth.ts auth helpers + role guards
│ │ └── hooks/ custom React hooks
│ └── types/
│ └── index.ts shared TypeScript types
├── public/
│ ├── manifest.json PWA manifest
│ ├── sw.js service worker (generated)
│ └── icons/ PWA icons (192, 512)
├── next.config.js
├── tailwind.config.ts
├── tsconfig.json
└── .env.example
```
---
## Stylesheet integration
`/stylesheet.css` lives at the repo root — not inside `app/frontend/`.
Import it as the first global stylesheet in `src/app/layout.tsx`:
```tsx
import '../../../stylesheet.css' // path from app/frontend/src/app/
import './globals.css' // Tailwind directives second
```
Rules:
- stylesheet.css is the authoritative visual style — never override it
- Where Tailwind utility classes conflict with stylesheet.css rules,
stylesheet.css wins
- Tailwind is used for layout, spacing, and elements not covered by the
stylesheet — match the visual language (spacing scale, colour, type) of
the existing stylesheet when doing so
- Inspect stylesheet.css before implementing any component — use its CSS
custom properties (if any) rather than hardcoding values
---
## PWA configuration
### next.config.js
Use `next-pwa` package to generate service worker and manifest wiring:
```javascript
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development'
})
module.exports = withPWA({
reactStrictMode: true,
})
```
### public/manifest.json
```json
{
"name": "Learning Platform",
"short_name": "Learn",
"description": "Employee knowledge and learning",
"start_url": "/app",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"orientation": "portrait",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
]
}
```
Note: set `theme_color` and `background_color` to match stylesheet.css
primary background after inspecting the file.
### Service worker caching strategy
- Static assets: cache-first
- PocketBase API calls: network-first, fall back to cache
- Backend service calls: network-only (no caching for dynamic content)
---
## Auth
PocketBase handles auth. Two roles: `admin` and `employee`.
### Login flow
```
/auth page → email + password form
PocketBase authWithPassword()
Store token in PocketBase SDK (persists in localStorage)
Read user.role from auth record
role === 'admin' → redirect to /admin
role === 'employee' → redirect to /app
```
### Route guards
Implement as Next.js middleware (`middleware.ts` at app root):
```typescript
// Admin routes: require role === 'admin'
// Employee routes: require role === 'employee'
// Unauthenticated: redirect to /auth
// Wrong role: redirect to correct landing
```
### PocketBase client (browser)
```typescript
// lib/pocketbase.ts
import PocketBase from 'pocketbase'
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL)
```
Use `pb.authStore` for auth state. Use `pb.collection().getFullList()` etc.
for direct PocketBase reads. The frontend reads KB content (topics, micro
learnings) directly from PocketBase — it does not proxy through backend services.
### Service calls
Backend services (ingestion, generation, curriculum, chat, progress) are called
via typed fetch wrappers in `lib/services.ts`:
```typescript
// Example
export async function postComplete(payload: CompletePayload) {
const res = await fetch(`${PROGRESS_URL}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (!res.ok) throw new Error(`Complete failed: ${res.status}`)
return res.json() as Promise<CompleteResponse>
}
```
All service response types imported from `types/index.ts`.
---
## Admin app
### Shell layout (`admin/layout.tsx`)
Sidebar navigation on desktop, top navigation on mobile.
Nav items:
- Documents
- Knowledge base
- Curriculum
- (link back to employee app)
### Documents page (`admin/documents/page.tsx`)
**Upload section**
- Drag-and-drop file input: accepts .pdf, .md, .txt
- On upload: POST file to PocketBase storage →
then POST to ingestion service `/ingest` with document metadata
- Show upload confirmation with filename
**Job status list**
- Poll GET /status/:jobId every 3 seconds while status is not done/failed
- Show per-job progress:
- Status badge: queued / extracting / chunking / structuring / embedding / done / failed
- Progress bar derived from chunksEmbedded / chunksTotal
- On done: "N themes, N topics ready for review" → link to knowledge base
- On failed: error reason in red, no retry (admin re-uploads)
- Stop polling when status === 'done' or 'failed'
**Document history**
- List of all source_documents from PocketBase
- Columns: filename, format, status, ingested_at, chunk_count
---
### Knowledge base page (`admin/knowledge/page.tsx`)
Lists all Themes with status indicator.
**Theme card**
```
[Theme title] [status badge: draft / published]
N topics · from: filename.pdf
[Approve batch] [Edit] [Reject]
```
Approve batch:
- Calls PocketBase to set theme.status → 'published', all child topics → 'published'
- Triggers generation service: POST /generate-all with themeId
- Shows toast: "Generation queued for N topics"
Reject:
- Sets theme.status → 'rejected'
- Removes from list
Edit → navigates to `/admin/knowledge/[themeId]`
**Theme detail page (`admin/knowledge/[themeId]/page.tsx`)**
Displays all Topics in the Theme as editable cards.
Topic card fields (all editable inline):
- title (text input)
- body (textarea — rich enough for paragraphs, no full rich text editor needed)
- difficulty (select: introductory / intermediate / advanced)
- key_terms (tag input — comma-separated)
- related_topics (multi-select from published topics)
- prerequisite_topics (multi-select)
Save button per card — calls PocketBase PATCH on the topic record.
Below topic list: [Approve batch] button — approves all topics in the theme.
**Micro learning generation status**
After batch approval, show generation status per topic:
```
Concept explainer ✓ published
Scenario quiz ⏳ generating
Comparison card ✓ published
...
```
Poll micro_learnings collection filtered by topic until all 10 are published.
---
### Curriculum page (`admin/curriculum/page.tsx`)
**Current curriculum view**
26 weeks displayed as a list. Each week shows:
```
Week 7
[Theme: Holacratic roles]
Topics: Role definitions · Circle structure · Lead link responsibilities
Estimated: 25 min
[Edit week] [Admin notes]
```
**Regeneration banner**
When a pending regeneration is queued:
```
⚠ 8 new topics added. A new curriculum version is ready to preview.
[Preview changes] [Confirm regeneration] [Dismiss]
```
Preview: shows proposed schedule with diff highlighting — weeks that changed
are highlighted, weeks that stay the same are dimmed.
Confirm: calls POST /generate confirm on curriculum service →
applies new version to all active employees.
**Drag-to-reorder**
Each week row is draggable. Reordering calls PATCH /weeks/:weekId on the
curriculum service to swap theme assignments.
**Admin notes**
Inline text input per week — saved to curriculum_weeks.admin_notes.
---
## Employee app
### Shell layout (`app/layout.tsx`)
Bottom navigation bar (mobile-first):
```
[Session] [Library] [Profile]
```
R42 floating button: fixed position, bottom-right, above the nav bar.
Z-index above all content.
### Session page (`app/session/page.tsx`)
**Week header**
```
Week 7 of 26 · Cycle 1
[Theme title: Holacratic roles]
[Progress bar: N of 26 weeks complete]
```
**Topic list**
Each topic in the week's theme rendered as a card:
```
[Topic title]
[difficulty badge] [estimated: 10 min]
Choose how to learn this topic:
[Concept explainer] [Scenario quiz] [How-to] ...
(only published types shown as buttons)
[Completed types: ✓ Concept explainer]
```
Selecting a type opens the micro learning inline (no navigation — expands in
place on mobile). Employee reads/completes it, then taps [Mark complete].
On mark complete:
- POST to progress service `/complete`
- Response displays: commits earned + any new badges as a toast notification
- Topic card updates to show type as completed (✓)
- All types in topic completable in one session
**Week complete state**
When all topics in the week have at least one completed type:
```
🚀 Week 7 complete
You earned N commits
[Continue to Week 8]
```
Continue button calls POST /advance/:userId on curriculum service.
---
### Micro learning components
One component per type in `components/micro-learnings/`.
Each receives the `content` JSON field from the micro_learnings record.
| Component | Key interactions |
|---|---|
| ConceptExplainer | Render paragraphs + example — read only |
| ScenarioQuiz | Select option → reveal explanation — stateful |
| Misconceptions | Accordion: tap misconception to reveal correction |
| HowTo | Numbered steps — tap step to check it off |
| ComparisonCard | Two-column table — swipeable on mobile |
| ReflectionPrompt | Open text area → reveal model answer on submit |
| FlashcardSet | Flip card interaction — swipe through deck |
| CaseStudy | Scenario text + open questions — read only |
| GlossaryAnchor | Term card with definition + examples |
| MythVsEvidence | Myth card → tap to reveal evidence |
All components are self-contained. They receive content JSON and emit an
`onComplete` callback. They do not call any services directly.
```typescript
type MicroLearningProps = {
content: unknown // typed per component
onComplete: () => void
}
```
---
### Knowledge library (`app/library/page.tsx`)
**Browse view**
All published topics, grouped by Theme.
Search input: filters by title and key_terms in real time (client-side).
Filter chips: by difficulty (introductory / intermediate / advanced).
Each topic shown as a card:
```
[Topic title]
[Theme] · [difficulty badge]
[key terms as chips]
```
Tap → navigate to topic detail.
**Topic detail (`app/library/[topicId]/page.tsx`)**
```
[Topic title]
[Theme] · [difficulty]
[Topic body — rendered as paragraphs]
Key terms: [chip] [chip] [chip]
Related topics: [card] [card]
Prerequisite for: [card] [card]
How to learn this topic:
[micro learning type buttons — same as session view]
```
Completing a micro learning from the library records the completion via
progress service. Week_number is set to the employee's current week.
---
### Profile page (`app/profile/page.tsx`)
**Header**
```
[Display name]
[Level badge: Junior] [N commits]
[Current streak: 5 weeks] [Longest: 8 weeks]
```
**Heatmap**
GitHub-style contribution graph.
26 columns (weeks) × rows implied by completions per week.
Cell colour: 0 completions = lightest, 5+ completions = darkest.
Tap a cell → tooltip: "Week N · N completions".
Scrollable horizontally on mobile if needed.
Implementation: render as SVG or CSS grid — no charting library required.
```typescript
// Data from GET /profile/:userId → heatmap[]
// Colour scale: 4 levels based on completions count
// 0: var(--heatmap-0)
// 1: var(--heatmap-1)
// 2-3: var(--heatmap-2)
// 4+: var(--heatmap-3)
// Use CSS custom properties — values derived from stylesheet.css palette
```
**Badges**
Grid of earned badges. Unearned badges shown as locked (greyed out).
Tap badge → tooltip with award condition.
```
🥉 First commit ✓
🥈 Five sessions ✓
🥇 On a streak 🔒 (13 week streak needed)
⭐ Shipped 🔒
---
🏷 Governance nerd ✓
🏷 Deep reader 🔒 (3/5 case studies)
```
**Leaderboard tab**
Toggle between "My profile" and "Leaderboard".
Leaderboard: table of all employees from GET /leaderboard.
Columns: Name · Commits · Streak · Types used · Badges · Level.
Not ranked 1N. No sorting by the user — display order is commits descending.
Current employee row is highlighted.
**Activity feed tab**
Third tab: "Feed".
Milestone cards from GET /feed.
Most recent first.
```
🚀 Alex shipped the full curriculum
26 weeks · 847 commits · 3 badges
Longest streak: 18 weeks
[timestamp]
```
---
## R42 chatbot components (`components/r42/`)
### R42Button
Fixed position, bottom-right, above bottom nav bar.
Circle button with R42 label or icon.
Tap → opens R42Drawer.
```tsx
// Position: fixed, bottom: calc(nav-height + 16px), right: 16px
// Z-index: above all content, below modals
```
### R42Drawer
Slides up from bottom on mobile (sheet pattern).
On desktop: expands to a side panel.
```
[R42 header bar] [close ×]
─────────────────────────────────────────────
[Response area — scrollable]
Based on: [Holacratic roles ×] [Circle structure ×]
─────────────────────────────────────────────
[Type a question...] [Send →]
```
**State machine:**
```
idle → loading (query sent) → streaming → done
↘ out_of_scope
```
**Streaming implementation:**
```typescript
// POST /chat with fetch, read SSE stream
const response = await fetch(`${CHAT_URL}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, userId })
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const lines = decoder.decode(value).split('\n')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const event = JSON.parse(line.slice(6))
if (event.type === 'chunk') appendText(event.text)
if (event.type === 'citations') setCitations(event.topics)
if (event.type === 'out_of_scope') setOutOfScope(event.text)
if (event.type === 'done') setDone()
}
}
```
**Citations**
Rendered as tappable pills below the response.
Tap → closes R42Drawer, navigates to `/app/library/[topicId]`.
**Out of scope response**
Render as a muted message (not an error state):
"This doesn't appear to be covered in the knowledge base.
You can browse the full library in the Knowledge section."
**Stateless by design**
Conversation cleared on drawer close. No history persisted.
Input cleared on send.
---
## Mobile-first layout rules
All layout decisions start at 375px and scale up.
- Bottom navigation: fixed, height 56px, icons + labels
- R42 button: 48px circle, positioned above nav bar
- Session topic cards: full width, stack vertically
- Micro learning components: full width, no horizontal scroll except
ComparisonCard (swipeable)
- Heatmap: horizontal scroll container on narrow screens
- Leaderboard table: horizontally scrollable on mobile, sticky name column
- Drawer/sheet pattern for R42 on mobile, side panel on desktop (breakpoint: 768px)
- Tap targets: minimum 44×44px on all interactive elements
- No hover-only interactions — all hover states have tap equivalents
---
## Environment variables
```
NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
NEXT_PUBLIC_INGESTION_URL=http://localhost:3001
NEXT_PUBLIC_GENERATION_URL=http://localhost:3002
NEXT_PUBLIC_CURRICULUM_URL=http://localhost:3003
NEXT_PUBLIC_CHAT_URL=http://localhost:3004
NEXT_PUBLIC_PROGRESS_URL=http://localhost:3005
```
---
## Dependencies
```json
{
"dependencies": {
"next": "14",
"react": "^18",
"react-dom": "^18",
"pocketbase": "^0.21",
"next-pwa": "^5",
"zod": "^3"
},
"devDependencies": {
"typescript": "^5",
"tailwindcss": "^3",
"autoprefixer": "^10",
"postcss": "^8",
"@types/react": "^18",
"@types/node": "^20"
}
}
```
No component library. No charting library. No drag-and-drop library —
implement curriculum drag-to-reorder with native HTML5 drag API.
The heatmap is SVG or CSS grid — no D3.
---
## TypeScript strict mode requirements
- No `any` types
- All PocketBase collection responses typed against data-model.md schemas
- All service API responses typed against response types from each service spec
- Micro learning content JSON typed per type using discriminated union:
```typescript
type MicroLearningContent =
| { type: 'concept_explainer'; paragraphs: string[]; example: string }
| { type: 'scenario_quiz'; scenario: string; options: QuizOption[] }
| { type: 'misconceptions'; items: MisconceptionItem[] }
// ... all 10 types
```
- SSE event types as discriminated union
- No implicit any on event handlers
---
## What the frontend does NOT do
- Does not run AI calls directly — all AI goes through backend services
- Does not write to Qdrant — embedding is the ingestion service's responsibility
- Does not implement auth logic — delegates entirely to PocketBase SDK
- Does not implement curriculum generation — calls curriculum service
---
## Testing checkpoints
### Admin app
1. Upload a PDF → ingestion job created → status polls and updates → done state shows link
2. Theme batch appears after ingestion → approve → generation queued
3. Edit a topic title and body → save → changes persisted in PocketBase
4. Curriculum renders 26 weeks → drag week 3 and week 5 → order persists
5. Regeneration banner appears → preview shows → confirm applies new version
### Employee app
6. Login as employee → redirected to /app/session → correct week shown
7. Select micro learning type → content renders → mark complete → commits toast shown
8. Complete all topics in week → week complete state shown → advance to next week
9. Library browse → search filters results → topic detail renders body + related topics
10. Profile page → heatmap renders for current cycle → badges show locked/unlocked state
11. Leaderboard tab → all employees shown → current employee row highlighted
12. R42 button visible on every screen → opens drawer → question answered with citations
13. R42 citation tap → navigates to correct topic in library
14. Out-of-scope question → muted message shown, no citations
15. All screens render correctly at 375px width — no horizontal overflow except
intentional scroll containers

469
docs/gamification-spec.md Normal file
View File

@@ -0,0 +1,469 @@
# Gamification and progress service spec
## Responsibility
Records session completions, calculates XP (commits), manages levels and
streaks, evaluates badge conditions, generates milestone cards, and serves
leaderboard data. All gamification data is public to all employees.
---
## Service location
```
app/services/progress/
├── src/
│ ├── index.ts entry point, Fastify server
│ ├── routes/
│ │ ├── completions.ts POST /complete
│ │ ├── profile.ts GET /profile/:userId
│ │ ├── leaderboard.ts GET /leaderboard
│ │ └── feed.ts GET /feed
│ ├── engine/
│ │ ├── xp.ts commit calculation per type
│ │ ├── level.ts level thresholds + promotion
│ │ ├── streak.ts streak evaluation
│ │ ├── badges.ts badge condition evaluation
│ │ └── milestone.ts milestone card generation
│ └── lib/
│ └── pocketbase.ts
├── package.json
├── tsconfig.json
└── .env.example
```
---
## API surface
### POST /complete
Called by the frontend when an employee completes a micro learning.
Request:
```json
{
"userId": "string",
"topicId": "string",
"microLearningId": "string",
"microLearningType": "string",
"weekNumber": 7,
"cycle": 1
}
```
Response:
```json
{
"commitsEarned": 15,
"totalCommits": 342,
"levelBefore": "junior",
"levelAfter": "junior",
"streakWeeks": 5,
"newBadges": [
{ "key": "deep_reader", "label": "Deep reader", "tier": "content" }
],
"milestoneCard": null
}
```
`newBadges` is empty array if no badges earned this completion.
`milestoneCard` is populated only at weeks 13 and 26.
---
### GET /profile/:userId
Returns full gamification profile for an employee.
Response:
```json
{
"userId": "string",
"displayName": "string",
"totalCommits": 342,
"level": "junior",
"currentStreakWeeks": 5,
"longestStreakWeeks": 8,
"typesUsed": ["concept_explainer", "scenario_quiz", "how_to"],
"typesNotUsed": ["case_study", "myth_vs_evidence"],
"badges": [
{ "key": "bronze_1", "label": "First commit", "tier": "bronze", "earnedAt": "..." }
],
"heatmap": [
{ "week": 1, "completions": 3 },
{ "week": 2, "completions": 0 }
]
}
```
Heatmap returns 26 entries per cycle. `completions` = number of micro
learning types completed that week.
---
### GET /leaderboard
Returns all employee gamification profiles for leaderboard rendering.
Not paginated at 150 employees.
Response:
```json
{
"employees": [
{
"userId": "string",
"displayName": "string",
"totalCommits": 847,
"currentStreakWeeks": 18,
"typesUsedCount": 9,
"badgeCount": 5,
"level": "senior"
}
]
}
```
Sorted by totalCommits descending. Frontend renders as multi-dimension
table — not a ranked list.
---
### GET /feed
Returns recent milestone cards for the public activity feed.
Most recent first.
Response:
```json
{
"milestones": [
{
"userId": "string",
"displayName": "string",
"cycle": 1,
"week": 26,
"totalCommits": 847,
"streakWeeks": 18,
"badges": ["on_streak", "shipped"],
"createdAt": "string"
}
]
}
```
---
## XP (commits) calculation
Each micro learning type earns a different number of commits based on
cognitive effort required.
```typescript
const COMMITS_PER_TYPE: Record<MicroLearningType, number> = {
concept_explainer: 10,
glossary_anchor: 10,
misconceptions: 15,
how_to: 15,
flashcard_set: 15,
comparison_card: 20,
reflection_prompt: 20,
scenario_quiz: 25,
myth_vs_evidence: 25,
case_study: 30,
}
```
First completion of a type the employee has never used before: +5 bonus
commits (rewards type diversity).
Duplicate completion (same topic + same type in same cycle): 0 commits.
Check session_completions before awarding — idempotent.
---
## Levels
Thresholds based on cumulative commits across all cycles.
```typescript
const LEVEL_THRESHOLDS = {
intern: 0,
junior: 100,
medior: 300,
senior: 600,
staff: 1000,
principal: 1500,
}
```
Level evaluated after every commit update. Level can only increase —
never decreases between cycles.
---
## Streak
Counts consecutive weeks with at least one completion.
Evaluation logic on every POST /complete:
```typescript
const lastActiveWeek = profile.last_active_week
const currentWeek = completionWeekNumber
if (currentWeek === lastActiveWeek) {
// Same week — streak unchanged
} else if (currentWeek === lastActiveWeek + 1) {
// Consecutive — increment
streak += 1
} else {
// Gap — reset to 1
streak = 1
}
profile.last_active_week = currentWeek
profile.longest_streak_weeks = Math.max(streak, profile.longest_streak_weeks)
```
Week number resets to 1 on cycle start. Streak does not reset on cycle
transition — a streak spanning the cycle boundary is maintained.
---
## Badges
### Badge seed data
Seeded into PocketBase badges collection at startup.
```typescript
const BADGE_DEFINITIONS = [
{ key: 'first_commit', tier: 'bronze',
label: 'First commit',
description: 'Complete any session' },
{ key: 'five_sessions', tier: 'silver',
label: 'Five sessions',
description: 'Complete 5 sessions using 5 different micro learning types' },
{ key: 'on_streak', tier: 'gold',
label: 'On a streak',
description: 'Complete 13 sessions without skipping a week' },
{ key: 'shipped', tier: 'legendary',
label: 'Shipped',
description: 'Complete all 26 sessions using all 10 micro learning types' },
{ key: 'governance_nerd', tier: 'content',
label: 'Governance nerd',
description: 'Complete all topics in the holacratic structure theme' },
{ key: 'process_architect', tier: 'content',
label: 'Process architect',
description: 'Complete all topics in the internal processes theme' },
{ key: 'deep_reader', tier: 'content',
label: 'Deep reader',
description: 'Use the case study micro learning type 5 or more times' },
{ key: 'handbook_expert', tier: 'content',
label: 'Handbook expert',
description: 'Complete all topics in the employee handbook theme' },
{ key: 'type_collector', tier: 'content',
label: 'Type collector',
description: 'Use all 10 micro learning types at least once' },
]
```
Content badges are theme-specific. Theme association resolved at runtime
by matching badge key to theme title pattern — not hardcoded to theme IDs.
```typescript
const THEME_BADGE_PATTERNS: Record<string, string> = {
'governance_nerd': 'holacrat',
'process_architect': 'process',
'handbook_expert': 'handbook',
}
```
Case-insensitive substring match on theme title.
---
### Badge evaluation
Run after every POST /complete. Check all conditions, award unearned badges.
```typescript
async function evaluateBadges(userId: string, profile: GamificationProfile):
Promise<BadgeDefinition[]> {
const earnedKeys = await getEarnedBadgeKeys(userId)
const newBadges: string[] = []
if (!earnedKeys.includes('first_commit')) {
const count = await countCompletions(userId)
if (count >= 1) newBadges.push('first_commit')
}
if (!earnedKeys.includes('five_sessions')) {
const sessions = await countUniqueSessions(userId)
if (sessions >= 5 && profile.types_used.length >= 5)
newBadges.push('five_sessions')
}
if (!earnedKeys.includes('on_streak')) {
if (profile.longest_streak_weeks >= 13) newBadges.push('on_streak')
}
if (!earnedKeys.includes('shipped')) {
const cycleComplete = await isCycleComplete(userId, profile.current_cycle)
if (cycleComplete && profile.types_used.length === 10)
newBadges.push('shipped')
}
if (!earnedKeys.includes('deep_reader')) {
const count = await countTypeCompletions(userId, 'case_study')
if (count >= 5) newBadges.push('deep_reader')
}
if (!earnedKeys.includes('type_collector')) {
if (profile.types_used.length === 10) newBadges.push('type_collector')
}
for (const [badgeKey, pattern] of Object.entries(THEME_BADGE_PATTERNS)) {
if (!earnedKeys.includes(badgeKey)) {
const complete = await isThemeComplete(userId, pattern)
if (complete) newBadges.push(badgeKey)
}
}
for (const key of newBadges) {
await awardBadge(userId, key, profile.current_cycle)
}
return newBadges.map(k => BADGE_DEFINITIONS.find(b => b.key === k)!)
}
```
---
## Milestone cards
Generated at weeks 13 and 26 of each cycle.
```typescript
async function generateMilestoneCard(
userId: string,
weekNumber: number,
cycle: number,
profile: GamificationProfile
): Promise<MilestoneCard | null> {
if (weekNumber !== 13 && weekNumber !== 26) return null
const exists = await milestoneExists(userId, cycle, weekNumber)
if (exists) return null
const badges = await getEarnedBadges(userId)
return await pocketbase.collection('milestone_cards').create({
user: userId,
cycle,
week: weekNumber,
total_commits: profile.total_commits,
streak_weeks: profile.current_streak_weeks,
badge_keys: badges.map(b => b.key),
created_at: new Date().toISOString()
})
}
```
---
## Heatmap data
Built from session_completions filtered by userId + cycle.
Returns 26 entries — one per week.
```typescript
async function buildHeatmap(userId: string, cycle: number):
Promise<HeatmapEntry[]> {
const completions = await pocketbase
.collection('session_completions')
.getFullList({ filter: `user="${userId}" && cycle=${cycle}` })
const byWeek = groupBy(completions, c => c.week_number)
return Array.from({ length: 26 }, (_, i) => ({
week: i + 1,
completions: byWeek[i + 1]?.length ?? 0
}))
}
```
---
## Environment variables
```
POCKETBASE_URL=
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
PROGRESS_PORT=3005
```
---
## Dependencies
```json
{
"dependencies": {
"fastify": "^4",
"pocketbase": "^0.21",
"zod": "^3"
}
}
```
No AI calls. No Qdrant. No OpenAI. Pure business logic over PocketBase.
---
## TypeScript strict mode requirements
- No `any` types
- MicroLearningType as explicit string union
- Badge keys as explicit string union matching BADGE_DEFINITIONS
- COMMITS_PER_TYPE keyed by MicroLearningType — compile-time exhaustiveness
- HeatmapEntry, MilestoneCard, BadgeDefinition typed explicitly
---
## What this service does NOT do
- Does not generate content
- Does not handle curriculum scheduling → curriculum service
- Does not serve KB or micro learning data → frontend reads PocketBase
- Does not handle auth → PocketBase + frontend
---
## Testing checkpoints
1. POST /complete for new user → first_commit badge awarded, commits added
2. POST /complete same topic + type twice → 0 commits second call (idempotent)
3. Complete 5 sessions with 5 types → five_sessions badge awarded
4. Simulate 13 consecutive weekly completions → on_streak badge awarded
5. Skip a week → streak resets to 1
6. Complete all topics in a theme → content badge awarded
7. Use all 10 types → type_collector badge awarded
8. Complete week 13 → milestone_card created and returned in response
9. GET /leaderboard → all employees returned with correct fields
10. GET /feed → milestone cards ordered most recent first
11. Cycle transition: week 26 complete → streak spans boundary, level preserved,
heatmap resets for new cycle

583
docs/generation-spec.md Normal file
View File

@@ -0,0 +1,583 @@
# Generation service spec
## Responsibility
Accepts a Theme ID from the admin app (on batch approval) and generates all 10
micro learning types for every published Topic in that Theme. One Claude Sonnet 4
call per type per topic. All outputs validated through Zod schemas before write.
This service runs entirely server-side. The admin app calls it via REST. All AI
calls go through the Anthropic API. No generation logic lives in the frontend.
---
## Service location
```
app/services/generation/
├── src/
│ ├── index.ts entry point, Fastify server
│ ├── routes/
│ │ ├── generate.ts POST /generate, GET /status/:jobId
│ │ └── publish.ts PATCH /micro-learnings/:id
│ ├── pipeline/
│ │ └── generate.ts per-type generation logic
│ ├── jobs/
│ │ └── queue.ts async job queue (in-memory)
│ ├── lib/
│ │ ├── pocketbase.ts PocketBase client
│ │ └── anthropic.ts Anthropic client
│ └── types.ts shared TypeScript types + Zod schemas
├── package.json
├── tsconfig.json
├── .env.example
└── .gitignore
```
---
## API surface
### POST /generate
Triggered by admin app when a Theme batch is approved.
Request:
```json
{
"themeId": "string"
}
```
Response (202 Accepted):
```json
{
"jobId": "string",
"status": "queued",
"topicsFound": 5,
"totalItems": 50
}
```
Processing is async. The admin app polls job status.
Behaviour:
- Fetches all published Topics for the given themeId
- Creates one micro_learnings record per topic per type with status `queued`
- Generates each item sequentially; updates status to `generated` on success
- On failure: sets individual item status to `failed`, continues remaining items
- Job completes when all items are either `generated` or `failed`
---
### GET /status/:jobId
Returns current job progress.
Response:
```json
{
"jobId": "string",
"status": "queued" | "running" | "done" | "failed",
"progress": {
"topicsTotal": 5,
"topicsProcessed": 3,
"itemsTotal": 50,
"itemsGenerated": 28,
"itemsFailed": 2
},
"error": "string | null"
}
```
---
### PATCH /micro-learnings/:id
Admin publishes or rejects an individual micro learning.
Request:
```json
{
"status": "published" | "rejected"
}
```
Response (200 OK):
```json
{
"id": "string",
"status": "published" | "rejected",
"published_at": "datetime | null"
}
```
Rules:
- Only `generated` records can be published or rejected
- `published_at` set on publish, left null on reject
- Returns 400 if record is not in `generated` status
- Returns 404 if record not found
---
## Generation pipeline
### Input
For each Topic in the approved Theme:
```
topic.title: string
topic.body: string
topic.key_terms: string[]
topic.difficulty: 'introductory' | 'intermediate' | 'advanced'
```
### Output
10 micro_learnings records per topic, one per type.
---
## AI call configuration
```typescript
{
model: 'claude-sonnet-4-20250514',
max_tokens: 2000,
temperature: 0 // deterministic structured output
}
```
One call per type per topic. Do not batch multiple types into one call — isolated
calls are easier to retry and validate independently.
---
## Prompt strategy
### System prompt (all types)
```
You are a learning content designer. Your task is to generate structured learning
content for a specific topic in an employee learning platform.
Output ONLY valid JSON matching the schema provided. No preamble, no explanation,
no markdown fences.
The content should be accurate, practical, and appropriate for the stated
difficulty level. Tone: professional but accessible.
```
### User prompt template (all types)
```
Topic: {topic.title}
Difficulty: {topic.difficulty}
Body:
{topic.body}
Key terms: {topic.key_terms.join(', ')}
Generate a {type_label} for this topic.
Output schema:
{JSON.stringify(schemaDescription)}
```
---
## Type-specific prompts and schemas
### concept_explainer
Type label: `Concept Explainer`
Schema description:
```json
{
"paragraphs": ["2 to 3 paragraphs explaining the concept in plain language"],
"example": "one concrete real-world example"
}
```
Zod schema:
```typescript
z.object({
paragraphs: z.array(z.string()).min(2).max(3),
example: z.string().min(20)
})
```
---
### scenario_quiz
Type label: `Scenario Quiz`
Schema description:
```json
{
"scenario": "a realistic workplace scenario",
"options": [
{ "label": "A", "text": "answer text", "correct": false, "explanation": "why" },
{ "label": "B", "text": "answer text", "correct": true, "explanation": "why" },
{ "label": "C", "text": "answer text", "correct": false, "explanation": "why" },
{ "label": "D", "text": "answer text", "correct": false, "explanation": "why" }
]
}
```
Rules: exactly 4 options, exactly 1 correct.
Zod schema:
```typescript
z.object({
scenario: z.string().min(30),
options: z.array(z.object({
label: z.enum(['A', 'B', 'C', 'D']),
text: z.string().min(5),
correct: z.boolean(),
explanation: z.string().min(10)
})).length(4).refine(
opts => opts.filter(o => o.correct).length === 1,
{ message: 'exactly one correct option required' }
)
})
```
---
### misconceptions
Type label: `Misconceptions`
Schema description:
```json
{
"items": [
{ "misconception": "common wrong belief", "correction": "accurate explanation" }
]
}
```
Rules: 3 to 5 items.
Zod schema:
```typescript
z.object({
items: z.array(z.object({
misconception: z.string().min(10),
correction: z.string().min(10)
})).min(3).max(5)
})
```
---
### how_to
Type label: `How-To Guide`
Schema description:
```json
{
"steps": [
{ "number": 1, "instruction": "what to do" }
]
}
```
Rules: 3 to 8 steps.
Zod schema:
```typescript
z.object({
steps: z.array(z.object({
number: z.number().int().positive(),
instruction: z.string().min(10)
})).min(3).max(8)
})
```
---
### comparison_card
Type label: `Comparison Card`
Schema description:
```json
{
"subject_a": "first concept or approach",
"subject_b": "second concept or approach",
"dimensions": [
{ "label": "dimension name", "a": "how A differs", "b": "how B differs" }
]
}
```
Rules: 3 to 6 dimensions.
Zod schema:
```typescript
z.object({
subject_a: z.string().min(2),
subject_b: z.string().min(2),
dimensions: z.array(z.object({
label: z.string().min(2),
a: z.string().min(5),
b: z.string().min(5)
})).min(3).max(6)
})
```
---
### reflection_prompt
Type label: `Reflection Prompt`
Schema description:
```json
{
"prompt": "open-ended question for the employee to reflect on",
"model_answer": "a thoughtful example answer the employee can compare against"
}
```
Zod schema:
```typescript
z.object({
prompt: z.string().min(20),
model_answer: z.string().min(50)
})
```
---
### flashcard_set
Type label: `Flashcard Set`
Schema description:
```json
{
"cards": [
{ "question": "question text", "answer": "answer text" }
]
}
```
Rules: 5 to 10 cards.
Zod schema:
```typescript
z.object({
cards: z.array(z.object({
question: z.string().min(5),
answer: z.string().min(5)
})).min(5).max(10)
})
```
---
### case_study
Type label: `Case Study`
Schema description:
```json
{
"scenario": "a detailed real-world scenario (150+ words)",
"questions": ["discussion or reflection question 1", "discussion or reflection question 2"]
}
```
Rules: 2 to 4 questions.
Zod schema:
```typescript
z.object({
scenario: z.string().min(150),
questions: z.array(z.string().min(10)).min(2).max(4)
})
```
---
### glossary_anchor
Type label: `Glossary Anchor`
Schema description:
```json
{
"term": "the key term",
"definition": "precise definition",
"correct_use": "example sentence showing correct use",
"misuse": "common incorrect usage to avoid"
}
```
Prompt addition: use the first key term from `topic.key_terms` as the anchor term.
Zod schema:
```typescript
z.object({
term: z.string().min(2),
definition: z.string().min(20),
correct_use: z.string().min(20),
misuse: z.string().min(20)
})
```
---
### myth_vs_evidence
Type label: `Myth vs Evidence`
Schema description:
```json
{
"myth": "a commonly held misconception about this topic",
"evidence": "the evidence-based counterpoint",
"sources": ["source or reference if applicable — leave empty array if none"]
}
```
Zod schema:
```typescript
z.object({
myth: z.string().min(20),
evidence: z.string().min(30),
sources: z.array(z.string())
})
```
---
## Error handling
**Per item:**
- JSON parse failure → retry once with stricter prompt ("respond with valid JSON only, no other text")
- Second failure → set micro_learning status to `failed`, log raw response, continue to next item
- Zod validation failure → same as parse failure: retry once, then `failed`
- Anthropic API error (rate limit / timeout) → exponential backoff, 3 retries, then `failed`
**Per job:**
- If all items for a topic fail → log, continue to next topic
- Job status becomes `done` when all items processed, regardless of individual failures
- Job status becomes `failed` only if the initial topic fetch fails (PocketBase error before generation starts)
---
## PocketBase write
For each generated item:
```typescript
{
topic: topicId,
type: type, // one of the 10 type enum values
content: validatedContent, // JSON, validated by Zod
status: 'generated',
generation_model: 'claude-sonnet-4-20250514',
generated_at: new Date().toISOString()
}
```
Create record with status `queued` before generation starts.
Update to `generated` (with content) or `failed` after attempt.
---
## Job lifecycle
```
POST /generate received
Fetch published Topics for Theme
Create micro_learning records: status = queued
Job created → status: running
For each topic:
For each of 10 types:
Claude call → validate → write content → status = generated
All items processed
Job status: done
```
On topic fetch failure:
```
status: failed
error: { reason: 'topic_fetch_failed', detail: ... }
```
---
## Environment variables required
```
ANTHROPIC_API_KEY=
POCKETBASE_URL=
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
GENERATION_PORT=3002
```
---
## Dependencies
```json
{
"dependencies": {
"fastify": "^4",
"@anthropic-ai/sdk": "^0.24",
"pocketbase": "^0.21",
"uuid": "^9",
"zod": "^3"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"tsx": "^4"
}
}
```
---
## TypeScript strict mode requirements
- No `any` types
- All Claude response parsing through Zod schema validation before PocketBase write
- All PocketBase writes typed against micro_learnings schema from data-model.md
- Content type is `unknown` after JSON.parse — always validate through Zod before use
---
## What this service does NOT do
- Does not extract or chunk source documents → ingestion service
- Does not build or schedule the curriculum → curriculum service
- Does not handle admin auth → PocketBase + admin app
- Does not embed content into Qdrant → ingestion service handles all embeddings
- Does not serve R42 queries → chat service
---
## Testing checkpoints
1. Call POST /generate with a themeId that has 2 published topics → verify 20 micro_learning records created
2. All 10 types generated for each topic → verify content JSON parses correctly
3. All Zod schemas pass for each of the 10 types
4. PATCH /micro-learnings/:id with `published` → verify status + published_at updated
5. PATCH /micro-learnings/:id with `rejected` → verify status updated, published_at null
6. Force a JSON parse error (mock) → verify retry logic fires once, then sets status to `failed`
7. GET /status/:jobId during processing → verify progress counters increment correctly

336
docs/r42-spec.md Normal file
View File

@@ -0,0 +1,336 @@
# R42 chat service spec
## Responsibility
Handles all R42 chatbot interactions. Receives employee queries, retrieves
relevant KB chunks from Qdrant, generates grounded responses using Claude
Haiku 4.5, and streams the result to the frontend. Stateless — no chat
history is persisted.
---
## Service location
```
app/services/chat/
├── src/
│ ├── index.ts entry point, Fastify server
│ ├── routes/
│ │ └── chat.ts POST /chat (streaming)
│ ├── retrieval/
│ │ ├── embed.ts query → embedding
│ │ ├── search.ts Qdrant nearest-neighbour search
│ │ └── merge.ts merge + rank results from both collections
│ ├── prompt/
│ │ └── build.ts assemble system + user prompt with context
│ └── lib/
│ ├── qdrant.ts
│ ├── pocketbase.ts
│ ├── anthropic.ts
│ └── openai.ts
├── package.json
├── tsconfig.json
└── .env.example
```
---
## API surface
### POST /chat
Single route. Streams response back to client using server-sent events (SSE).
Request:
```json
{
"query": "string",
"userId": "string"
}
```
Response: SSE stream
```
Content-Type: text/event-stream
data: {"type": "chunk", "text": "Holacratic roles "}
data: {"type": "chunk", "text": "are defined as..."}
data: {"type": "citations", "topics": [{"id": "abc", "title": "Holacratic roles"}]}
data: {"type": "done"}
```
Error response (non-streaming, returned before stream starts):
```json
{
"error": "query_too_short" | "user_not_found" | "retrieval_failed",
"message": "string"
}
```
---
## Retrieval pipeline
### Step 1 — Embed query
Embed the employee query using OpenAI text-embedding-3-small (1536 dimensions).
Same model used during ingestion — vectors are comparable.
```typescript
const queryVector = await embedText(query) // float[1536]
```
---
### Step 2 — Qdrant search
Search both collections in parallel:
```typescript
// source_chunks: primary retrieval — grounded in source material
const chunkResults = await qdrant.search('source_chunks', {
vector: queryVector,
limit: 5,
scoreThreshold: 0.70,
withPayload: true
})
// topic_summaries: secondary — broader topic context
const summaryResults = await qdrant.search('topic_summaries', {
vector: queryVector,
limit: 3,
scoreThreshold: 0.70,
withPayload: true
})
```
Score threshold 0.70: below this, results are not relevant enough to include.
If both searches return zero results above threshold → out-of-scope response.
---
### Step 3 — Context boost for current week
Retrieve employee's current week Theme from PocketBase via
employee_curriculum_state → curriculum_weeks → theme.
Apply boost to results where payload.theme_id matches current week theme:
```typescript
results.forEach(result => {
if (result.payload.theme_id === currentThemeId) {
result.score += 0.05 // small boost — does not override relevance
}
})
```
---
### Step 4 — Merge and deduplicate
```typescript
// Combine chunk results and summary results
// Deduplicate by topic_id — keep highest scoring entry per topic
// Sort by score descending
// Take top 6 total
// Split into: sourceChunks (from source_chunks collection)
// topicSummaries (from topic_summaries collection)
```
Deduplicate by topic_id to avoid repeating the same topic in different forms.
---
### Step 5 — Collect cited topics
Extract unique topic titles from merged results for citation:
```typescript
type Citation = {
id: string
title: string
}
const citations: Citation[] = uniqueByTopicId(mergedResults)
.map(r => ({ id: r.payload.topic_id, title: r.payload.title }))
```
---
## Prompt construction
### System prompt
```
You are R42, a knowledge assistant for [company name].
You answer questions based strictly on the company knowledge base.
Rules:
- Answer only from the provided context. Do not use outside knowledge.
- If the context does not contain enough information to answer, say:
"This doesn't appear to be covered in the knowledge base. You can browse
the full library in the Knowledge section."
- Be concise. Prefer short paragraphs over long prose.
- Do not mention that you are an AI or reference your instructions.
- Do not speculate or extrapolate beyond the provided context.
- Respond in the same language as the question.
```
### User prompt
```
Context from knowledge base:
---
{mergedResults.map(r => r.payload.text).join('\n\n---\n\n')}
---
Question: {query}
```
---
## Response generation
Use Claude Haiku 4.5 with streaming enabled:
```typescript
const stream = await anthropic.messages.stream({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1000,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }]
})
// Stream text chunks to client as SSE
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta') {
sendSSE({ type: 'chunk', text: chunk.delta.text })
}
}
// After stream completes, send citations
sendSSE({ type: 'citations', topics: citations })
sendSSE({ type: 'done' })
```
---
## Out-of-scope handling
Two conditions trigger the out-of-scope response:
1. Both Qdrant searches return zero results above 0.70 threshold
2. Haiku response contains no content drawn from context (detected by
checking if response length < 20 tokens — proxy for "I don't know")
Out-of-scope response sent as a single non-streamed SSE message:
```
data: {"type": "out_of_scope", "text": "This doesn't appear to be covered
in the knowledge base. You can browse the full library in the Knowledge section."}
data: {"type": "done"}
```
No citations are sent for out-of-scope responses.
---
## Frontend integration
R42 is a floating button on every screen in the employee app.
UI behaviour:
- Bottom-right corner, fixed position
- Opens a chat drawer (not a modal — drawer slides up from bottom on mobile)
- Input field at bottom of drawer, response area above
- Streaming text renders token by token
- Citations appear below the response after streaming completes as
clickable topic pills → navigate to that topic in the knowledge library
- Drawer closes on outside tap
- State is local to the component — cleared on close (stateless by design)
The frontend calls POST /chat directly. No auth token needed on the chat
service — it receives userId in the request body and trusts it. The admin
app does not expose R42.
---
## Stateless design
R42 has no memory between conversations. Each POST /chat is independent.
Rationale:
- Avoids privacy complexity around chat history storage
- Removes need for session management
- Keeps the service simple and fast
- Employees asking follow-up questions reprovide context naturally
If multi-turn conversation is needed in a future iteration, maintain
conversation history in the frontend component state and pass the last
N messages in the request body. The service does not need to change.
---
## Environment variables
```
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
POCKETBASE_URL=
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
QDRANT_URL=
QDRANT_API_KEY=
CHAT_PORT=3004
```
---
## Dependencies
```json
{
"dependencies": {
"fastify": "^4",
"@fastify/sse": "^2",
"@anthropic-ai/sdk": "^0.24",
"openai": "^4",
"@qdrant/js-client-rest": "^1.9",
"pocketbase": "^0.21",
"zod": "^3"
}
}
```
---
## TypeScript strict mode requirements
- No `any` types
- Qdrant search results typed explicitly including payload fields
- SSE event types defined as a discriminated union
- Citation type explicit — not inferred from payload
---
## What this service does NOT do
- Does not persist chat history
- Does not generate or serve micro learning content
- Does not handle admin queries — admin app has no R42 access
- Does not handle auth — trusts userId from request body
---
## Testing checkpoints
1. POST /chat with a query matching a published topic → confirm relevant
chunks retrieved (score > 0.70) and response references topic content
2. POST /chat with an out-of-scope query → confirm out-of-scope response
returned, no citations sent
3. Confirm citations array contains correct topic titles matching retrieved chunks
4. Confirm SSE stream delivers chunks progressively (not batched)
5. Confirm current-week boost: same query returns higher-ranked result for
current week theme topic vs equally relevant topic from different theme
6. POST /chat with userId whose current week has no matching topic →
confirm boost does not break retrieval, general results returned