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:
407
docs/curriculum-spec.md
Normal file
407
docs/curriculum-spec.md
Normal 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 // 1–5
|
||||
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 (15–45 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 // 1–26
|
||||
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 1–5 unchanged, weeks 6–26 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
701
docs/frontend-spec.md
Normal 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 1–N. 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
469
docs/gamification-spec.md
Normal 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
583
docs/generation-spec.md
Normal 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
336
docs/r42-spec.md
Normal 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
|
||||
Reference in New Issue
Block a user