Add comprehensive documentation for employee learning platform

- Created handover document outlining design decisions and application functionality.
- Developed implementation plan detailing phased approach for service development.
- Specified ingestion service responsibilities, API surface, and processing pipeline.
This commit is contained in:
RaymondVerhoef
2026-05-23 15:38:09 +02:00
parent 881148357e
commit dda20612e9
32 changed files with 3519 additions and 573 deletions

279
docs/architecture.md Normal file
View File

@@ -0,0 +1,279 @@
# Architecture: employee learning platform
## Overview
A mobile-first progressive web application that provides employees with a structured knowledge library, a 26-week perpetual learning curriculum, and an AI-powered assistant (R42). The knowledge base is the single source of truth for all content, micro learnings, curriculum scheduling, and chat retrieval.
---
## System domains
### Admin app
Browser-based interface for content administrators.
Responsibilities:
- Upload source documents (PDF, MD, TXT)
- Review and approve AI-generated Theme batches
- Edit and finetune AI-generated curriculum
- Confirm curriculum regeneration after KB updates
- Monitor ingestion and generation job status
### Employee app
Mobile-first PWA accessible on all devices.
Responsibilities:
- Weekly session delivery (Theme + Topics + micro learning type selection)
- Knowledge library (browse all published Topics)
- Gamification profile (heatmap, badges, streak, leaderboard)
- R42 chatbot (available on every screen)
### Backend services
Six discrete services, each with a single responsibility.
| Service | Responsibility |
|---|---|
| Ingestion service | Document upload → chunk → extract KB structure |
| Generation service | Topics → 10 micro learning types (structured JSON) |
| Curriculum service | KB graph → 26-week schedule, versioning, regeneration |
| Embedding service | Chunks + topic summaries → Qdrant |
| Chat service (R42) | Query → vector retrieval → grounded response |
| Progress service | Completions → XP → badges → streak |
---
## Deployment topology
```
repo/
├── .github/workflows/ ← pipeline (frozen)
├── docker-compose.yml ← infrastructure (frozen)
├── Dockerfile ← updated once to point at /app
├── ansible/ ← provisioning (frozen)
├── legacy/ ← original prototype (read-only reference)
└── app/
├── frontend/ ← Next.js PWA (admin + employee)
└── services/
├── ingestion/
├── generation/
├── curriculum/
├── embedding/
├── chat/
└── progress/
```
---
## Tech stack
| Layer | Technology | Rationale |
|---|---|---|
| Frontend | Next.js 14, TypeScript, Tailwind CSS | PWA support, single codebase for admin + employee |
| Backend state | PocketBase | Auth, file storage, admin UI, SQLite — no infra overhead |
| Vector store | Qdrant (Docker) | RAG retrieval, runs as single container |
| AI generation | Claude Sonnet 4 via Anthropic API | Structured JSON output, long-form drafting, graph reasoning |
| AI chat (R42) | Claude Haiku 4.5 via Anthropic API | Low latency, cost-effective, grounded by retrieval layer |
| Embeddings | OpenAI text-embedding-3-small | Cost-effective, high quality at this scale |
| Auth | PocketBase built-in | Role-based: admin / employee |
---
## AI model responsibilities
| Task | Model |
|---|---|
| Document → KB structure extraction | Claude Sonnet 4 |
| Topic body drafting | Claude Sonnet 4 |
| Micro learning generation (all 10 types) | Claude Sonnet 4 |
| Curriculum generation + versioning | Claude Sonnet 4 |
| R42 chat responses | Claude Haiku 4.5 |
| Embeddings | text-embedding-3-small |
---
## Document ingestion pipeline
```
Admin uploads file (PDF / MD / TXT)
Format detection → text extraction
MD: split on headings → preserve hierarchy
PDF: pdfplumber → page + paragraph detection
TXT: sliding window chunking with overlap
Chunk cleaning (strip headers/footers/artefacts)
Claude Sonnet 4 reads chunks → extracts:
- candidate Themes
- candidate Topics per Theme
- Topic→Topic relationships (related, prerequisite, contrast)
- key terms for glossary
Draft KB written to PocketBase (status: draft)
Embedding service: embed source chunks → write to Qdrant
Admin reviews Theme batch → approves / edits / rejects
On approval: Topics published, micro learning generation queued
Curriculum regeneration notification queued for admin
```
Note: embeddings are generated from **source chunks**, not only from AI-generated topic summaries. R42 retrieves from grounded source material.
MD source files are the preferred format for admins — heading structure maps directly to Theme → Topic hierarchy and improves extraction quality.
---
## Curriculum lifecycle
### Generation
Input: all published Themes, Topics, relationship graph, complexity weights
Process: cluster by Theme → sequence pedagogically (prerequisites first, complexity gradient) → distribute across 26 weeks → ensure full KB coverage
Output: versioned 26-week draft schedule
### Perpetual cycling
The curriculum runs continuously. After week 26, the employee begins cycle 2 on the latest curriculum version.
Second and subsequent cycles are not identical to cycle 1:
- Theme sequence is varied
- Recommended micro learning types surface types the employee has not yet used
- Topics with low engagement in prior cycles receive increased coverage
### Versioning rules
| Event | Action |
|---|---|
| New source doc published to KB | Regenerate curriculum from week N+1 for all active employees |
| Topic body edited | Micro learnings regenerated; curriculum unaffected |
| Theme batch approved | Regeneration queued; admin confirms before it applies |
Completed weeks are immutable. Regeneration only affects future unstarted weeks.
### Admin regeneration flow
Admin receives notification: "N new topics added. Regenerate curriculum? This will update unstarted weeks for all active employees."
Admin can preview the proposed new schedule before confirming.
---
## Weekly session flow (employee)
```
Week N opens
Employee sees assigned Theme + Topics for the week
Per Topic: employee selects micro learning type
(all published types for that topic are available)
Employee completes one or more types per topic
Completion recorded → XP awarded → badges evaluated
Progress visible on public leaderboard and activity feed
```
Sessions support multiple micro learning types per topic in a single session.
---
## Micro learning types
All 10 types are generated by Claude Sonnet 4 as structured JSON, stored in PocketBase, and rendered by the frontend. One or more types may be published per topic.
| # | Type | Format |
|---|---|---|
| 1 | Concept explainer | 23 paragraphs + example |
| 2 | Scenario quiz | situation + 34 MCQ options + explained answers |
| 3 | Common misconceptions | 35 false beliefs + corrections |
| 4 | Step-by-step how-to | numbered procedure |
| 5 | Comparison card | side-by-side on 46 dimensions |
| 6 | Reflection prompt | open question + model answer |
| 7 | Spaced repetition flashcards | 510 Q&A pairs |
| 8 | Case study mini-analysis | 150200 word scenario + guiding questions |
| 9 | Glossary anchor | term + definition + correct use + misuse |
| 10 | Myth vs. evidence | false claim + evidence-based rebuttal |
---
## R42 — chat service design
R42 is a functional KB-grounded assistant available on every screen in the employee app.
Behaviour:
- Stateless per session (no memory between conversations)
- Retrieves relevant chunks from Qdrant using the employee's query
- Knows the employee's current curriculum week → retrieval is context-weighted
- Cites source topic in every response ("based on the **Holacratic roles** topic")
- Explicitly refuses to answer outside KB scope rather than hallucinating
- Scope: internal KB only
Implementation:
- Employee query → embed → Qdrant nearest-neighbour retrieval → top-K chunks
- Chunks + employee context injected into Haiku 4.5 prompt
- Response streamed to frontend
UI: floating button bottom-right, unobtrusive on mobile.
---
## Gamification system
Inspired by the visual language of GitHub, Stack Overflow, and Duolingo. Mechanics use developer-native terminology.
### XP unit: commits
Every completed topic earns commits. Quantity varies by micro learning type complexity.
### Levels
`Intern → Junior → Medior → Senior → Staff → Principal`
Based on cumulative commits across all cycles.
### Streak
Counted in consecutive weeks, not days. Resets if a week is skipped entirely.
### Activity heatmap
GitHub-style contribution graph spanning the full 26-week cycle. Cell darkness = number of types completed that week.
### Badges
| Tier | Condition |
|---|---|
| Bronze | Complete any session |
| Silver | 5 sessions completed, 5 different types used |
| Gold | 13 sessions without skipping a week |
| Legendary | All 26 sessions, all 10 types used at least once |
Named content badges (examples):
- `governance nerd` — all holacratic structure topics completed
- `process architect` — all internal process topics completed
- `deep reader` — case study type used 5+ times
### Milestone cards (public)
At weeks 13 and 26, a public card is posted to the shared activity feed:
```
🚀 [Name] shipped the full curriculum
26 weeks · [N] commits · [badges]
Longest streak: [N] weeks
```
Language: shipping vocabulary, not school vocabulary.
### Leaderboard
Not ranked 1N by score. Displays multiple dimensions:
| Employee | Commits | Streak | Types used | Badges |
|---|---|---|---|---|
Multiple paths to visibility. No single metric determines standing.
---
## Security and privacy
- Auth: PocketBase role-based (admin / employee)
- Gamification data (commits, badges, streak) is public to all employees
- Session completion data (which topic, which type, when) is public
- Source documents are admin-only
- No PII beyond display name stored in gamification context
- R42 is stateless — no chat history persisted

397
docs/data-model.md Normal file
View File

@@ -0,0 +1,397 @@
# Data model: employee learning platform
## Overview
Two storage systems:
- **PocketBase** — all structured relational data (SQLite under the hood)
- **Qdrant** — all vector embeddings for RAG retrieval
---
## PocketBase collections
### `source_documents`
Uploaded source files. Parent of all generated KB content.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| filename | string | original filename |
| file | file | PocketBase file storage |
| format | select | `pdf` `md` `txt` |
| status | select | `processing` `processed` `failed` |
| ingested_at | datetime | |
| chunk_count | number | total chunks extracted |
| created_by | relation → `users` | admin who uploaded |
---
### `themes`
Top-level content groupings. One Theme = one weekly session.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| title | string | |
| description | text | AI drafted, admin editable |
| status | select | `draft` `published` |
| source_documents | relation[] → `source_documents` | which docs contributed |
| approved_by | relation → `users` | admin who approved batch |
| approved_at | datetime | |
| created_at | datetime | |
| updated_at | datetime | |
---
### `topics`
Atomic knowledge units. Always belong to a Theme.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| theme | relation → `themes` | parent theme |
| title | string | |
| body | text (rich) | AI drafted, admin editable |
| difficulty | select | `introductory` `intermediate` `advanced` |
| complexity_weight | number | 15, used by curriculum generator |
| status | select | `draft` `published` |
| related_topics | relation[] → `topics` | lateral relationships |
| prerequisite_topics | relation[] → `topics` | must-complete-first |
| contrast_topics | relation[] → `topics` | deliberate opposites |
| key_terms | json | string[] — feeds glossary |
| qdrant_chunk_ids | json | string[] — references to embedded chunks |
| created_at | datetime | |
| updated_at | datetime | |
Relationship types (related / prerequisite / contrast) are stored via the three explicit relation fields rather than a generic relationship table. This keeps queries simple at this scale.
---
### `micro_learnings`
Generated content artifacts. One record per topic per type.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| topic | relation → `topics` | |
| type | select | see type enum below |
| content | json | structured output — schema varies per type |
| status | select | `queued` `generated` `published` `rejected` |
| generation_model | string | model version used |
| generated_at | datetime | |
| published_at | datetime | |
| updated_at | datetime | |
**Type enum:**
`concept_explainer` `scenario_quiz` `misconceptions` `how_to` `comparison_card` `reflection_prompt` `flashcard_set` `case_study` `glossary_anchor` `myth_vs_evidence`
**Content JSON schemas per type:**
```json
// concept_explainer
{
"paragraphs": ["string", "string"],
"example": "string"
}
// scenario_quiz
{
"scenario": "string",
"options": [
{ "label": "A", "text": "string", "correct": true, "explanation": "string" }
]
}
// misconceptions
{
"items": [
{ "misconception": "string", "correction": "string" }
]
}
// how_to
{
"steps": [
{ "number": 1, "instruction": "string" }
]
}
// comparison_card
{
"subject_a": "string",
"subject_b": "string",
"dimensions": [
{ "label": "string", "a": "string", "b": "string" }
]
}
// reflection_prompt
{
"prompt": "string",
"model_answer": "string"
}
// flashcard_set
{
"cards": [
{ "question": "string", "answer": "string" }
]
}
// case_study
{
"scenario": "string",
"questions": ["string"]
}
// glossary_anchor
{
"term": "string",
"definition": "string",
"correct_use": "string",
"misuse": "string"
}
// myth_vs_evidence
{
"myth": "string",
"evidence": "string",
"sources": ["string"]
}
```
---
### `curriculum_versions`
Versioned 26-week schedule. New version created on each regeneration.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| version | number | increments on each regeneration |
| status | select | `draft` `active` `superseded` |
| generated_at | datetime | |
| approved_by | relation → `users` | admin who confirmed regeneration |
| approved_at | datetime | |
| generation_notes | text | why this version was created |
Only one version has status `active` at any time.
---
### `curriculum_weeks`
Individual week slots. Child of a curriculum version.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| curriculum_version | relation → `curriculum_versions` | |
| week_number | number | 126 |
| theme | relation → `themes` | |
| topics | relation[] → `topics` | ordered subset of theme topics |
| topic_order | json | number[] — explicit ordering |
| estimated_duration_minutes | number | AI estimate |
| admin_notes | text | freeform admin annotation |
---
### `employee_curriculum_state`
Tracks each employee's position in the curriculum. One record per employee.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| user | relation → `users` | |
| current_cycle | number | starts at 1, increments on loop |
| current_week | number | 126 |
| start_date | datetime | rolling start |
| active_version | relation → `curriculum_versions` | version employee is on |
| updated_at | datetime | |
When curriculum regenerates: `active_version` updates for all employees whose `current_week` is less than the first regenerated week.
---
### `session_completions`
Immutable completion records. One record per employee per topic per type.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| user | relation → `users` | |
| topic | relation → `topics` | |
| micro_learning | relation → `micro_learnings` | specific type completed |
| week_number | number | curriculum week at time of completion |
| cycle | number | which cycle |
| completed_at | datetime | |
Records are never updated or deleted. This is the canonical history.
---
### `gamification_profiles`
One record per employee. Updated by progress service on each completion.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| user | relation → `users` | |
| total_commits | number | cumulative XP |
| current_level | select | `intern` `junior` `medior` `senior` `staff` `principal` |
| current_streak_weeks | number | consecutive weeks with ≥1 completion |
| longest_streak_weeks | number | all-time high |
| types_used | json | string[] — which of 10 types used at least once |
| last_active_week | number | used to detect streak breaks |
| updated_at | datetime | |
---
### `badges`
Badge definitions. Seeded at startup, not user-generated.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| key | string | unique slug e.g. `governance_nerd` |
| tier | select | `bronze` `silver` `gold` `legendary` `content` |
| label | string | display name |
| description | string | award condition description |
| icon | string | emoji or icon key |
---
### `employee_badges`
Junction: which employees have earned which badges.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| user | relation → `users` | |
| badge | relation → `badges` | |
| earned_at | datetime | |
| cycle | number | which cycle it was earned in |
---
### `milestone_cards`
Public milestone events at weeks 13 and 26.
| Field | Type | Notes |
|---|---|---|
| id | string | PocketBase auto |
| user | relation → `users` | |
| cycle | number | |
| week | number | 13 or 26 |
| total_commits | number | snapshot at time of milestone |
| streak_weeks | number | snapshot |
| badge_keys | json | string[] — badges held at milestone |
| created_at | datetime | public feed ordered by this |
---
## PocketBase users collection (extended)
Standard PocketBase `users` collection with additional fields:
| Field | Type | Notes |
|---|---|---|
| role | select | `admin` `employee` |
| display_name | string | used in gamification feed |
| avatar | file | optional |
---
## Qdrant collections
### `source_chunks`
Embeddings of raw source document chunks. Primary retrieval target for R42.
| Field | Type | Notes |
|---|---|---|
| id | string | UUID |
| vector | float[] | 1536 dimensions (text-embedding-3-small) |
| source_document_id | string | reference to PocketBase |
| chunk_index | number | position within document |
| text | string | raw chunk text |
| theme_id | string | assigned theme (post-extraction) |
| topic_id | string | assigned topic (post-extraction, nullable) |
| format | string | pdf / md / txt |
### `topic_summaries`
Embeddings of AI-generated topic body text. Secondary retrieval target.
| Field | Type | Notes |
|---|---|---|
| id | string | UUID |
| vector | float[] | 1536 dimensions |
| topic_id | string | reference to PocketBase |
| theme_id | string | |
| title | string | for display in R42 citation |
| text | string | full topic body |
---
## Retrieval strategy for R42
R42 queries both Qdrant collections and merges results:
```
Employee query
Embed query → text-embedding-3-small
Qdrant search: source_chunks (top 5) + topic_summaries (top 3)
Filter: boost chunks from employee's current week theme
Merge + deduplicate by topic_id
Top-K context injected into Haiku 4.5 prompt
Response includes: answer + cited topic title(s)
```
Source chunks are weighted higher than topic summaries to keep R42 grounded in original source material rather than AI-generated abstractions.
---
## Indexes and query patterns
Critical query patterns the data model must support efficiently:
| Query | Collection | Index |
|---|---|---|
| All published topics for a theme | topics | theme + status |
| All micro learnings for a topic | micro_learnings | topic + status |
| Employee's current week | employee_curriculum_state | user |
| Weeks for a curriculum version | curriculum_weeks | curriculum_version + week_number |
| Employee completion history | session_completions | user + cycle |
| Public leaderboard | gamification_profiles | total_commits + streak |
| Milestone feed | milestone_cards | created_at DESC |
| Badges earned by employee | employee_badges | user |
PocketBase creates indexes on relation fields by default. Composite indexes on `status` fields should be added manually where query frequency warrants it.
---
## Data flow summary
```
source_documents
└── (ingestion service)
└── qdrant: source_chunks
└── themes (draft)
└── topics (draft)
└── (approval)
└── topics (published)
└── qdrant: topic_summaries
└── micro_learnings (queued → published)
└── (curriculum service)
└── curriculum_versions
└── curriculum_weeks
└── (employee progress)
└── session_completions
└── gamification_profiles
└── employee_badges
└── milestone_cards
```

231
docs/handover.md Normal file
View File

@@ -0,0 +1,231 @@
# Handover: employee learning platform
## Purpose of this document
This document captures every design decision made before implementation started.
It is the authoritative source for rationale. When a spec file is ambiguous,
resolve it against this document. Do not ask the human — the answers are here.
---
## What this application does
Employees of a tech company use this platform to build and maintain knowledge of
the employee handbook, holacratic structures, and internal processes.
Core mechanics:
- Admins upload source documents → AI extracts a structured knowledge base
- The KB is organised into Themes (broad) and Topics (specific)
- An AI generates 10 types of micro learning content per Topic
- Employees follow a 26-week curriculum of weekly sessions
- Each session covers one Theme (multiple related Topics)
- Employees choose which micro learning type to use per Topic
- After 26 weeks the curriculum restarts, varied to reinforce rather than repeat
- A chatbot called R42 answers KB-grounded questions on every screen
- A gamification system using developer-native language motivates completion
---
## All confirmed design decisions
### Knowledge base
**Decision: KB is extracted from source documents, not manually authored**
Admins upload raw source material. Claude Sonnet 4 extracts Themes, Topics, and
relationships. Admins review and approve in batches (one Theme at a time, not
one Topic at a time). Topic bodies are AI-drafted and admin-editable after approval.
**Decision: two-level hierarchy — Theme → Topic**
A Theme is a broad subject area. A Topic is one specific concept within a Theme.
One weekly session = one Theme. Multiple Topics within that Theme per session.
**Decision: three relationship types between Topics**
- related: Topics that complement each other
- prerequisite: Topic A should be understood before Topic B
- contrast: Topics representing opposing approaches
These relationships are stored as explicit PocketBase relation fields, not a
generic junction table.
**Decision: source material format priority**
Accepted formats: PDF, MD, TXT only. MD is the highest quality input —
heading structure maps directly to Theme → Topic hierarchy. Admins should be
recommended to provide MD where possible.
**Decision: embeddings from source chunks, not topic summaries only**
R42 retrieves from original source material chunks as primary source, with
topic summaries as secondary. This keeps R42 grounded and reduces hallucination.
---
### Micro learnings
**Decision: 10 types, all generated by AI as structured JSON**
Types:
1. concept_explainer
2. scenario_quiz
3. misconceptions
4. how_to
5. comparison_card
6. reflection_prompt
7. flashcard_set
8. case_study
9. glossary_anchor
10. myth_vs_evidence
Each type has a defined JSON schema in data-model.md. Generation uses
Claude Sonnet 4. Output is validated against Zod schemas before storage.
**Decision: employees choose type per topic per session**
Employees are not locked to one type globally. Each session, per Topic, the
employee selects from all published types for that topic. Multiple types can
be completed in one session.
**Decision: pre-generate, don't generate on demand**
All 10 types are generated when a Topic is approved, not when an employee
requests them. This controls quality and cost. R42 is the only on-demand
AI interaction.
---
### Curriculum
**Decision: AI generates curriculum, admin edits**
Claude Sonnet 4 reads the full KB graph (Themes, Topics, relationships,
complexity weights) and produces a 26-week schedule. Admin reviews, reorders,
and finetunes. Admin does not build from scratch.
**Decision: one Theme per week session**
A session covers all Topics under one Theme. Topics within the session are
ordered by the curriculum generator based on prerequisites and complexity.
**Decision: perpetual curriculum with versioning**
The curriculum runs indefinitely. After week 26, cycle 2 begins on the latest
curriculum version. Cycle 2+ varies sequence, surfaces unused micro learning
types, and increases coverage of low-engagement topics.
**Decision: completed weeks are immutable**
Regeneration only affects future unstarted weeks. An employee's completion
history is never altered regardless of curriculum version changes.
**Decision: regeneration requires admin confirmation**
When new Topics are approved, the system queues a regeneration but does not
apply it until the admin explicitly confirms. Admin sees a preview of the
proposed new schedule before confirming.
**Decision: rolling starts**
Each employee has their own start date. There are no cohorts or shared
start dates.
---
### Gamification
**Decision: developer-native visual language**
Inspired by GitHub (heatmap), Stack Overflow (badges, reputation), and
Duolingo (streak, XP, levels). Language uses developer vocabulary throughout.
**Decision: XP unit is called commits**
Every completed Topic earns commits. Quantity varies by micro learning type.
**Decision: levels use developer rank names**
Intern → Junior → Medior → Senior → Staff → Principal
Based on cumulative commits across all cycles.
**Decision: streak is weekly, not daily**
Consecutive weeks with at least one completion. Resets on a skipped week.
**Decision: activity heatmap covers 26-week cycle**
GitHub-style contribution graph. Cell darkness = number of types completed
that week.
**Decision: no social layer**
No comments, reactions, or direct messaging. Gamification is visible but
not interactive between employees.
**Decision: public milestone cards, not ranked leaderboard**
At weeks 13 and 26, a public card is posted to the shared activity feed.
Language: "shipped", not "graduated". The leaderboard shows multiple
dimensions (commits, streak, types used, badges) — not a single ranking.
**Decision: named content badges**
Examples: governance_nerd, process_architect, deep_reader. These are seeded
at startup, not user-generated. See data-model.md for badge schema.
---
### R42 chatbot
**Decision: functional only, no personality**
R42 answers questions grounded in the KB. It does not have a defined persona,
tone, or name story beyond the label R42.
**Decision: stateless per session**
No chat history is persisted between sessions. This avoids privacy complexity
and keeps the implementation simple.
**Decision: internal KB scope only**
R42 cannot search external sources. If a question cannot be answered from the
KB, R42 says so explicitly.
**Decision: context-weighted retrieval**
R42 knows the employee's current curriculum week. Retrieval boosts chunks
from the current week's Theme. General KB questions are not restricted.
**Decision: always cites source Topic**
Every R42 response includes the Topic title(s) its answer draws from.
**Decision: Haiku 4.5 for R42, Sonnet 4 for generation**
Low latency matters for chat. The retrieval layer compensates for Haiku's
smaller knowledge base. Sonnet 4 is reserved for generation tasks where
structure and quality matter more than speed.
---
### Infrastructure
**Decision: PocketBase as primary backend**
Auth, file storage, structured data, and admin UI in one binary. SQLite is
sufficient for ~150 users. No PostgreSQL needed at this scale.
**Decision: Qdrant for vector storage**
Separate Docker container. Keeps vector operations out of SQLite.
pgvector was rejected — adding Postgres just for vectors is unnecessary overhead.
**Decision: Next.js 14 PWA for frontend**
Single codebase for admin and employee app. PWA covers mobile without a native
app. Learning platforms do not require native device APIs.
**Decision: five discrete backend services**
Ingestion, generation, curriculum, chat, progress. Each is a separate Fastify
service with its own port and responsibility. They do not call each other
directly — they read/write shared PocketBase collections.
**Decision: PDF parsing starts with pdf-parse (Node.js)**
Switch to pdfplumber Python sidecar only if pdf-parse quality is insufficient
for actual source documents. Do not over-engineer the extraction layer upfront.
---
## What is not yet specced
The following spec files still need to be written before their phases begin:
- /docs/generation-spec.md — micro learning generation service
- /docs/curriculum-spec.md — curriculum generator + versioning
- /docs/r42-spec.md — chat service
- /docs/gamification-spec.md — progress service + gamification mechanics
- /docs/frontend-spec.md — employee app, admin app, PWA config
Do not begin a phase without its spec file. Flag the gap if you reach it.
---
## Source of truth hierarchy
When sources conflict, resolve in this order:
1. This handover document (rationale and decisions)
2. The relevant spec file (implementation detail)
3. data-model.md (schema is authoritative)
4. architecture.md (system structure)
Do not use legacy/ code as a source of truth for anything.

394
docs/implementation-plan.md Normal file
View File

@@ -0,0 +1,394 @@
# Implementation plan
## How to use this document
Work through phases in order. Do not start phase N+1 before phase N passes
all acceptance criteria. Each phase lists the spec file to read, the steps
to execute, and the criteria that define done.
At the start of each session: state the phase and step.
At the end of each session: state completed steps and next starting point.
---
## Phase 1 — Infrastructure + ingestion service
**Spec to read:** /docs/ingestion-spec.md, /docs/data-model.md
### Steps
**1.1 — Repo scaffold**
```
app/
frontend/ (empty, Next.js init comes in phase 4)
services/
ingestion/
generation/ (empty placeholder)
curriculum/ (empty placeholder)
chat/ (empty placeholder)
progress/ (empty placeholder)
```
Create `app/services/ingestion/` with:
- package.json (dependencies from ingestion-spec.md)
- tsconfig.json (strict mode)
- .env.example (all env vars from ingestion-spec.md)
- .gitignore
**1.2 — PocketBase collections**
PocketBase runs as a binary. Create a migration script at
`app/services/ingestion/migrations/001_initial_schema.ts` that uses the
PocketBase JS SDK to create all collections defined in data-model.md:
Collections to create:
- source_documents
- themes
- topics
- micro_learnings (schema only — no data yet)
- curriculum_versions (schema only)
- curriculum_weeks (schema only)
- employee_curriculum_state (schema only)
- session_completions (schema only)
- gamification_profiles (schema only)
- badges (schema only)
- employee_badges (schema only)
- milestone_cards (schema only)
Seed the badges collection with all badge definitions from data-model.md.
**1.3 — Qdrant collections**
Create `app/services/ingestion/migrations/002_qdrant_setup.ts` that
initialises both Qdrant collections:
- source_chunks (1536 dimensions, cosine distance)
- topic_summaries (1536 dimensions, cosine distance)
**1.4 — Ingestion service scaffold**
Build the Fastify server with two routes:
- POST /ingest
- GET /status/:jobId
Use the file structure from ingestion-spec.md exactly.
**1.5 — Stage 1: text extraction**
Implement extract.ts per ingestion-spec.md:
- TXT: direct UTF-8 read
- MD: direct UTF-8 read, preserve heading markers
- PDF: pdf-parse, page break markers
**1.6 — Stage 23: chunking + cleaning**
Implement chunk.ts and clean.ts per ingestion-spec.md:
- MD: heading-based splitting
- TXT: sliding window (800 chars, 150 overlap)
- PDF: page + paragraph splitting
- Cleaning: whitespace, artefacts, minimum length filter
**1.7 — Stage 4: structure extraction**
Implement structure.ts per ingestion-spec.md:
- Claude Sonnet 4 call with system + user prompt from spec
- Zod validation of DraftKB output
- Batch handling for documents > 60 chunks
- Retry logic on parse failure
- Error handling: failed job status + reason
**1.8 — Stage 5: PocketBase write**
Implement the PocketBase write logic:
- Create Theme records (status: draft)
- Create Topic records under each Theme (status: draft)
- Resolve relationships between Topics after all records created
**1.9 — Stage 6: embeddings + Qdrant write**
Implement embed.ts:
- OpenAI text-embedding-3-small, batches of 100
- Write to Qdrant source_chunks collection
- Write to Qdrant topic_summaries collection
- Update Topic.qdrant_chunk_ids in PocketBase
**1.10 — Job status tracking**
Wire all stages into the job queue (jobs/queue.ts):
- Status transitions: queued → extracting → chunking → structuring →
writing → embedding → done / failed
- Progress counters (chunksTotal, chunksEmbedded, themesFound, topicsFound)
- GET /status/:jobId returns current state
### Acceptance criteria
- [ ] POST /ingest with a small MD file completes without error
- [ ] GET /status/:jobId returns `done` after processing
- [ ] PocketBase contains draft Theme + Topic records with correct hierarchy
- [ ] Topic.body contains AI-drafted content (not empty)
- [ ] Topic relationships are resolved (related_topics populated where applicable)
- [ ] Qdrant source_chunks contains vectors with correct payload fields
- [ ] Qdrant topic_summaries contains vectors for each published topic
- [ ] Topic.qdrant_chunk_ids is populated
- [ ] POST /ingest with a PDF file completes without error
- [ ] POST /ingest with a TXT file completes without error
- [ ] A document > 60 chunks triggers batch processing without error
- [ ] A malformed PDF returns status `failed` with reason, not an uncaught exception
- [ ] All Zod validations pass — no `any` types in codebase
---
## Phase 2 — Generation service
**Spec to read:** /docs/generation-spec.md (write this spec before starting)
### Steps
**2.1 — Generation service scaffold**
Fastify service at app/services/generation/
Routes: POST /generate, GET /status/:jobId
**2.2 — Generate all 10 types per topic**
One Claude Sonnet 4 call per type per topic.
Structured JSON output validated against Zod schemas from data-model.md.
Write to micro_learnings collection (status: generated).
**2.3 — Batch generation on theme approval**
When admin approves a Theme batch, queue generation for all Topics in that Theme.
All 10 types per Topic.
**2.4 — Admin publish flow**
Route to update micro_learning status from generated → published or rejected.
This is called by the admin app (built in phase 4).
### Acceptance criteria (to be detailed in generation-spec.md)
- [ ] All 10 micro learning types generated for a test topic
- [ ] All 10 JSON outputs validate against their Zod schemas
- [ ] Generated content written to PocketBase with status: generated
- [ ] Admin can publish or reject individual micro learnings
---
## Phase 3 — Curriculum service
**Spec to read:** /docs/curriculum-spec.md (write this spec before starting)
### Steps
**3.1 — Curriculum service scaffold**
Fastify service at app/services/curriculum/
**3.2 — Curriculum generator**
Claude Sonnet 4 reads full KB graph → produces 26-week schedule.
Written to curriculum_versions + curriculum_weeks.
**3.3 — Versioning logic**
- New version created on regeneration
- Completed weeks frozen (employee_curriculum_state.current_week used as boundary)
- Admin confirmation required before applying new version
**3.4 — Perpetual cycling**
On week 26 completion, cycle increments, new cycle starts on latest version.
Second cycle: varied sequence, surfaces unused micro learning types.
### Acceptance criteria (to be detailed in curriculum-spec.md)
- [ ] Curriculum generated from a populated KB
- [ ] 26 weeks produced, all Themes covered
- [ ] Prerequisites respected in ordering
- [ ] Regeneration does not alter completed weeks
- [ ] Admin confirmation flow works correctly
---
## Phase 4 — Frontend: admin app
**Spec to read:** /docs/frontend-spec.md (write this spec before starting)
### Steps
**4.1 — Next.js 14 scaffold**
Mobile-first, TypeScript strict, Tailwind CSS, PWA config.
Role-based routing: /admin/* and /app/* from single Next.js codebase.
**4.2 — Auth**
PocketBase auth integration. Admin role routes to /admin/*.
**4.3 — Document upload + ingestion status**
Upload UI → calls ingestion service → polls job status → shows progress.
**4.4 — Theme batch review**
Display draft Themes with their Topic list.
Approve batch / edit individual topics / reject batch.
Triggers generation service on approval.
**4.5 — Curriculum editor**
Display AI-generated curriculum (26 weeks).
Drag-to-reorder weeks. Edit Theme assignment per week.
Confirm regeneration with preview.
### Acceptance criteria (to be detailed in frontend-spec.md)
- [ ] Admin can upload a document and see ingestion progress
- [ ] Admin can approve a Theme batch
- [ ] Admin can edit a Topic before approval
- [ ] Admin can view and reorder the curriculum
- [ ] Admin can confirm a curriculum regeneration with preview
---
## Phase 5 — Frontend: employee app
**Spec to read:** /docs/frontend-spec.md (same file, employee section)
### Steps
**5.1 — Employee auth + onboarding**
PocketBase auth. Employee role routes to /app/*.
Set start date on first login → creates employee_curriculum_state record.
**5.2 — Weekly session flow**
Current week's Theme displayed.
Topics listed with available micro learning types per topic.
Employee selects type → content rendered → mark complete.
**5.3 — Knowledge library**
Browse all published Topics.
Filter by Theme, difficulty, key terms.
**5.4 — R42 chatbot**
Floating button, every screen.
Calls chat service → streams response.
Cites source topic in response.
**5.5 — Gamification profile**
GitHub-style heatmap (26-week view).
Badge display.
Streak + level + commit count.
Public leaderboard (multi-dimension).
Milestone cards in activity feed.
### Acceptance criteria (to be detailed in frontend-spec.md)
- [ ] Employee sees correct week based on start date
- [ ] Employee can complete a topic with a chosen micro learning type
- [ ] Completion is recorded and XP awarded
- [ ] Knowledge library shows all published topics with filters
- [ ] R42 responds with grounded answer and source citation
- [ ] R42 is accessible from every screen
- [ ] Heatmap renders correctly on mobile (375px)
- [ ] Leaderboard shows all employees with multi-dimension data
---
## Phase 6 — Chat service (R42)
**Spec to read:** /docs/r42-spec.md (write this spec before starting)
### Steps
**6.1 — Chat service scaffold**
Fastify service at app/services/chat/
**6.2 — Query → embed → retrieve**
Employee query embedded → Qdrant nearest-neighbour on both collections.
Boost chunks from employee's current Theme.
**6.3 — Response generation**
Top-K chunks injected into Haiku 4.5 prompt.
Response streamed to frontend.
Source Topic titles included in response.
**6.4 — Out-of-scope handling**
If retrieval confidence is below threshold, R42 responds:
"I can only answer questions based on the internal knowledge base.
This topic doesn't appear to be covered."
### Acceptance criteria (to be detailed in r42-spec.md)
- [ ] R42 answers a question about a published topic correctly
- [ ] R42 cites the source topic in its response
- [ ] R42 refuses to answer out-of-scope questions explicitly
- [ ] Response streams to frontend (not batch)
- [ ] Response latency < 3 seconds for typical queries
---
## Phase 7 — Progress service
**Spec to read:** /docs/gamification-spec.md (write this spec before starting)
### Steps
**7.1 — Progress service scaffold**
Fastify service at app/services/progress/
**7.2 — Completion recording**
Write session_completions record on topic completion.
Calculate XP (commits) per type.
**7.3 — Gamification updates**
Update gamification_profiles: commits, level, streak, types_used.
Evaluate badge conditions → write employee_badges on award.
**7.4 — Milestone cards**
Generate milestone_cards record at weeks 13 and 26.
**7.5 — Leaderboard query**
Endpoint returning all gamification_profiles for leaderboard rendering.
### Acceptance criteria (to be detailed in gamification-spec.md)
- [ ] Completion writes to session_completions
- [ ] Commits calculated and added to gamification_profile
- [ ] Level updates correctly at commit thresholds
- [ ] Streak increments on weekly completion, resets on skip
- [ ] Badge awarded when condition is met
- [ ] Milestone card created at weeks 13 and 26
- [ ] Leaderboard endpoint returns all employees with correct data
---
## Phase 8 — Integration + hardening
No new spec required.
### Steps
**8.1 — Service wiring**
Verify all services communicate through PocketBase correctly.
No direct service-to-service calls — all state through PocketBase.
**8.2 — Error handling audit**
Review all services for unhandled promise rejections, missing error states,
and uncaught exceptions. Every external call (AI API, PocketBase, Qdrant,
OpenAI) wrapped in try/catch with meaningful error logging.
**8.3 — Mobile QA**
Test all employee app flows at 375px width.
R42 floating button must not obscure content.
Heatmap must render without horizontal scroll.
**8.4 — Environment variable audit**
Verify no hardcoded values. All .env.example files complete.
**8.5 — Dockerfile update**
Update COPY path from legacy app root to /app.
This is the one manual change that connects the rebuild to the existing pipeline.
### Acceptance criteria
- [ ] Full flow works end-to-end: upload doc → approve → curriculum → employee completes session → R42 answers question → gamification updates
- [ ] No uncaught exceptions in any service under normal operating conditions
- [ ] All screens render correctly on 375px mobile
- [ ] Dockerfile builds successfully pointing at /app
- [ ] Existing pipeline deploys the rebuilt app without modification
---
## Spec files still to be written
Before starting each phase, write the corresponding spec file.
Use ingestion-spec.md as the template for structure and detail level.
| Phase | Spec file needed |
|---|---|
| 2 | /docs/generation-spec.md |
| 3 | /docs/curriculum-spec.md |
| 45 | /docs/frontend-spec.md |
| 6 | /docs/r42-spec.md |
| 7 | /docs/gamification-spec.md |
When you reach a phase without a spec: stop, draft the spec, then proceed.
Do not implement without a spec.

483
docs/ingestion-spec.md Normal file
View File

@@ -0,0 +1,483 @@
# Ingestion service spec
## Responsibility
Accepts uploaded source documents (PDF, MD, TXT), extracts clean text, chunks it,
generates embeddings, and produces a structured draft KB (Themes + Topics +
relationships) ready for admin review.
This service runs entirely server-side. The admin app calls it via REST. All AI
calls go through the Anthropic API. No ingestion logic lives in the frontend.
---
## Service location
```
app/services/ingestion/
├── index.ts entry point, Fastify server
├── routes/
│ └── documents.ts POST /ingest, GET /status/:jobId
├── pipeline/
│ ├── extract.ts format detection + text extraction
│ ├── chunk.ts chunking strategies per format
│ ├── clean.ts chunk cleaning
│ ├── structure.ts Claude call → Theme/Topic extraction
│ └── embed.ts embedding generation + Qdrant write
├── jobs/
│ └── queue.ts async job queue (in-memory, BullMQ later if needed)
├── lib/
│ ├── pocketbase.ts PocketBase client
│ ├── qdrant.ts Qdrant client
│ ├── anthropic.ts Anthropic client
│ └── openai.ts OpenAI embeddings client
└── types.ts shared TypeScript types
```
---
## API surface
### POST /ingest
Triggered by admin app on document upload.
Request:
```json
{
"documentId": "string",
"filename": "string",
"format": "pdf" | "md" | "txt",
"filePath": "string"
}
```
Response (202 Accepted):
```json
{
"jobId": "string",
"status": "queued"
}
```
Processing is async. The admin app polls job status.
---
### GET /status/:jobId
Returns current job progress.
Response:
```json
{
"jobId": "string",
"status": "queued" | "extracting" | "chunking" | "structuring" | "embedding" | "done" | "failed",
"progress": {
"chunksTotal": 42,
"chunksEmbedded": 18,
"themesFound": 3,
"topicsFound": 14
},
"error": "string | null"
}
```
---
## Pipeline stages
### Stage 1 — Text extraction
Input: file path + format
Output: raw text string
```
format === 'txt'
→ read file directly as UTF-8
format === 'md'
→ read file directly as UTF-8
→ preserve heading markers (# ## ###) — used in chunking
format === 'pdf'
→ pdfplumber: extract text page by page
→ concatenate with page break markers: \n\n---PAGE---\n\n
→ strip known PDF artefacts: headers/footers repeating on every page,
page numbers, watermarks
```
Failure handling:
- PDF extraction returns empty string → mark job `failed`, reason: `pdf_extraction_empty`
- File not found → mark job `failed`, reason: `file_not_found`
---
### Stage 2 — Chunking
Input: raw text + format
Output: Chunk[]
Chunking strategy differs per format.
**MD chunking — heading-based (preferred)**
```
Split on heading markers: #, ##, ###
Each heading + its following content = one chunk
Minimum chunk size: 100 characters
→ if heading section is < 100 chars, merge with next sibling
Maximum chunk size: 1500 characters
→ if section exceeds limit, split on paragraph breaks within section
Metadata preserved per chunk:
heading_level: 1 | 2 | 3
heading_text: string
parent_heading: string | null
```
MD chunking produces the highest quality structural signal for Theme/Topic extraction.
Admins should be advised to provide source material as MD where possible.
**TXT chunking — sliding window**
```
Window size: 800 characters
Overlap: 150 characters
Split on: paragraph breaks (\n\n) first, then sentence boundaries, then hard cut
Metadata per chunk:
chunk_index: number
approximate_position: 'start' | 'middle' | 'end'
```
**PDF chunking — page + paragraph**
```
Split on ---PAGE--- markers from extraction stage
Within each page: split on paragraph breaks (\n\n)
Minimum chunk size: 100 characters
→ merge sub-threshold paragraphs with adjacent chunk
Maximum chunk size: 1200 characters
→ hard split at sentence boundary
Metadata per chunk:
page_number: number
chunk_index_on_page: number
```
**Chunk type:**
```typescript
type Chunk = {
id: string // UUID generated at chunking
documentId: string
text: string
format: 'pdf' | 'md' | 'txt'
index: number // global position in document
metadata: {
// MD-specific
headingLevel?: number
headingText?: string
parentHeading?: string
// TXT-specific
approximatePosition?: 'start' | 'middle' | 'end'
// PDF-specific
pageNumber?: number
chunkIndexOnPage?: number
}
}
```
---
### Stage 3 — Chunk cleaning
Input: Chunk[]
Output: Chunk[] (cleaned)
Applied to all formats:
```
- trim leading/trailing whitespace
- collapse 3+ consecutive newlines to 2
- remove null bytes and non-printable characters
- remove chunks where text.length < 80 after cleaning
→ these are likely artefacts (page numbers, standalone headers)
- normalise unicode: NFC normalisation
- do not strip punctuation or alter sentence structure
```
---
### Stage 4 — Structure extraction (AI)
Input: Chunk[]
Output: DraftKB
This is the core AI call. Claude Sonnet 4 reads all chunks and returns a structured
KB draft as JSON.
**Prompt strategy:**
System prompt:
```
You are a knowledge architect. Your task is to analyse a set of text chunks from
a source document and extract a structured knowledge base.
Output ONLY valid JSON matching the schema provided. No preamble, no explanation,
no markdown fences.
Rules:
- Group related content into Themes. A Theme is a broad subject area.
- Under each Theme, identify discrete Topics. A Topic covers one specific concept.
- Identify relationships between Topics: related, prerequisite, or contrast.
- related: Topics that complement each other
- prerequisite: Topic A must be understood before Topic B
- contrast: Topics that represent opposing approaches or concepts
- For each Topic, extract key terms suitable for a glossary.
- Assign a complexity weight (15) to each Topic.
1 = introductory, 5 = advanced
- Draft a body for each Topic (24 paragraphs) based on the source chunks.
- Draft a description for each Theme (12 sentences).
- Every Topic must reference the chunk IDs that contributed to it.
```
User prompt:
```
Source document: {filename}
Format: {format}
Chunks:
{chunks mapped as: [CHUNK-{id}]\n{text}\n}
Extract the knowledge base structure from these chunks.
```
**Output schema:**
```typescript
type DraftKB = {
themes: DraftTheme[]
}
type DraftTheme = {
title: string
description: string
topics: DraftTopic[]
}
type DraftTopic = {
title: string
body: string
difficulty: 'introductory' | 'intermediate' | 'advanced'
complexityWeight: number // 15
keyTerms: string[]
sourceChunkIds: string[] // references Chunk.id values
relationships: {
related: string[] // topic titles (resolved to IDs after write)
prerequisites: string[]
contrasts: string[]
}
}
```
**AI call configuration:**
```typescript
{
model: 'claude-sonnet-4-20250514',
max_tokens: 8000,
temperature: 0 // deterministic output for structured extraction
}
```
**Chunking strategy for large documents:**
If total chunk count exceeds 60 chunks, split into batches of 40 with 5-chunk
overlap. Run one Claude call per batch. Merge resulting DraftKB objects:
- Themes with identical titles → merge Topics
- Duplicate Topic titles within a Theme → keep longer body, merge sourceChunkIds
- Relationships are resolved after full merge
**Error handling:**
- JSON parse failure → retry once with stricter prompt ("ensure valid JSON only")
- Second failure → mark job `failed`, reason: `structure_extraction_failed`, log raw response
- Empty themes array → mark job `failed`, reason: `no_structure_found`
---
### Stage 5 — Write to PocketBase
Input: DraftKB
Output: written Theme + Topic records with status `draft`
```
For each DraftTheme:
create themes record {
title, description,
status: 'draft',
source_documents: [documentId]
}
For each DraftTopic under the theme:
create topics record {
theme: themeId,
title, body, difficulty, complexity_weight, key_terms,
status: 'draft',
qdrant_chunk_ids: [] // populated in stage 6
}
After all topics created:
resolve relationship titles → topic IDs
update topics.related_topics, prerequisite_topics, contrast_topics
If a relationship title cannot be resolved to an existing topic:
skip silently (cross-document relationships resolved in a later pass)
```
---
### Stage 6 — Embedding generation + Qdrant write
Input: Chunk[], written Topic records
Output: vectors in Qdrant, qdrant_chunk_ids updated on Topic records
**Source chunk embeddings:**
```
For each Chunk (post-cleaning):
embed Chunk.text → text-embedding-3-small (1536 dimensions)
write to Qdrant collection: source_chunks {
id: Chunk.id,
vector: float[],
payload: {
source_document_id: documentId,
chunk_index: Chunk.index,
text: Chunk.text,
theme_id: resolved themeId | null,
topic_id: resolved topicId | null,
format: Chunk.format
}
}
```
**Topic summary embeddings:**
```
For each published Topic:
embed Topic.body → text-embedding-3-small
write to Qdrant collection: topic_summaries {
id: UUID,
vector: float[],
payload: {
topic_id: Topic.id,
theme_id: Topic.theme,
title: Topic.title,
text: Topic.body
}
}
Update Topic.qdrant_chunk_ids with all Chunk.ids that reference this topic
```
**Batching:**
OpenAI embeddings API: batch in groups of 100 texts per request to stay within
rate limits and reduce latency.
---
## Job lifecycle
```
POST /ingest received
Job created → status: queued
Stage 1: extracting
Stage 23: chunking
Stage 4: structuring
Stage 5: writing to PocketBase
Stage 6: embedding
status: done
Admin notification: "Document processed. N themes, N topics ready for review."
Curriculum regeneration queued (status: pending_admin_confirm)
```
On any stage failure:
```
status: failed
error: { stage, reason, detail }
Source document status → 'failed' in PocketBase
Admin notification: "Ingestion failed: {reason}"
```
---
## Environment variables required
```
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
POCKETBASE_URL=
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
QDRANT_URL=
QDRANT_API_KEY= # empty string if running locally without auth
INGESTION_PORT=3001
```
---
## Dependencies
```json
{
"dependencies": {
"fastify": "^4",
"@anthropic-ai/sdk": "^0.24",
"openai": "^4",
"@qdrant/js-client-rest": "^1.9",
"pocketbase": "^0.21",
"pdfplumber": "NOT JS — see note below",
"pdf-parse": "^1.1",
"uuid": "^9",
"zod": "^3"
}
}
```
**PDF extraction note:**
`pdfplumber` is a Python library. Two options:
1. Use `pdf-parse` (Node.js) — simpler, covers 90% of cases
2. Run `pdfplumber` as a Python sidecar process via child_process — higher quality
for complex PDFs with tables and columns
Default to `pdf-parse` initially. Add pdfplumber sidecar only if extraction
quality is insufficient for actual source documents.
---
## TypeScript strict mode requirements
- No `any` types
- All Claude response parsing through Zod schema validation
- All PocketBase writes typed against collection schemas from `data-model.md`
- Qdrant payloads typed explicitly — no untyped objects
---
## What this service does NOT do
- Does not generate micro learnings → generation service
- Does not build or update the curriculum → curriculum service
- Does not handle admin approval → admin app + PocketBase directly
- Does not serve R42 queries → chat service
- Does not handle auth → PocketBase + admin app
---
## Testing checkpoints
Before handing to Claude Code for implementation, verify manually:
1. Upload a short MD file (< 10 headings) → inspect chunk output → confirm heading structure preserved
2. Upload a simple PDF (< 5 pages) → inspect chunk output → confirm no artefacts
3. Run structure extraction on known chunks → validate JSON parses against Zod schema
4. Confirm PocketBase draft records created with correct theme → topic hierarchy
5. Confirm Qdrant source_chunks collection populated with correct payload fields
6. Confirm topic.qdrant_chunk_ids updated after embedding stage