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:
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
|
||||
Reference in New Issue
Block a user