Files
learning-platform/docs/generation-spec.md
RaymondVerhoef 472685f0d7 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.
2026-05-23 18:13:08 +02:00

12 KiB

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:

{
  "themeId": "string"
}

Response (202 Accepted):

{
  "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:

{
  "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:

{
  "status": "published" | "rejected"
}

Response (200 OK):

{
  "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

{
  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:

{
  "paragraphs": ["2 to 3 paragraphs explaining the concept in plain language"],
  "example": "one concrete real-world example"
}

Zod schema:

z.object({
  paragraphs: z.array(z.string()).min(2).max(3),
  example: z.string().min(20)
})

scenario_quiz

Type label: Scenario Quiz

Schema description:

{
  "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:

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:

{
  "items": [
    { "misconception": "common wrong belief", "correction": "accurate explanation" }
  ]
}

Rules: 3 to 5 items.

Zod schema:

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:

{
  "steps": [
    { "number": 1, "instruction": "what to do" }
  ]
}

Rules: 3 to 8 steps.

Zod schema:

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:

{
  "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:

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:

{
  "prompt": "open-ended question for the employee to reflect on",
  "model_answer": "a thoughtful example answer the employee can compare against"
}

Zod schema:

z.object({
  prompt: z.string().min(20),
  model_answer: z.string().min(50)
})

flashcard_set

Type label: Flashcard Set

Schema description:

{
  "cards": [
    { "question": "question text", "answer": "answer text" }
  ]
}

Rules: 5 to 10 cards.

Zod schema:

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:

{
  "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:

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:

{
  "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:

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:

{
  "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:

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:

{
  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

{
  "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