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

View File

@@ -1,573 +0,0 @@
# AI Pipeline Hardening — Implementation Plan
> **Audience:** an AI agent executing this plan against the Respellion Learning Platform.
> **Owner before this work:** Raymond Verhoef (rve@respellion.nl).
> **Source of truth for repo conventions:** [`AI_AGENT.md`](AI_AGENT.md). Read it before starting.
This plan upgrades the platform's interaction with the Anthropic API: how prompts are built, how responses are parsed, how the model is retried, and how outputs are validated. It is broken into six phases that can be implemented and shipped independently. Each phase ends with verifiable acceptance criteria.
> **Status (2026-05-20):** Phases 15 implemented and shipped. Phase 6 (eval harness) is intentionally **out of scope** for this initiative — the production pipeline is hardened to the level the platform needs, and a golden-set runner can be reopened later as a stand-alone task if regression risk grows. The repo no longer carries any TODO from this plan.
---
## 0. Operating principles
These rules govern every phase. Re-read them before you commit.
1. **PocketBase is the source of truth.** No persistent state in localStorage (see [`AI_AGENT.md`](AI_AGENT.md) §2). The Anthropic API is proxied via Caddy; **never** add `x-api-key` headers in frontend code.
2. **No behaviour regressions.** Existing UI flows (extraction, weekly learning, weekly quiz, R42 chat, handbook sync, analyze-graph) must keep working after every phase. Phases are additive.
3. **Schema-first.** Where the model produces structured output, define a JSON Schema (Zod) and validate every response. Reject (don't paper over) malformed output.
4. **Single LLM entry point.** After Phase 1 there is exactly one module that talks to `/api/anthropic/v1/messages`. All callers go through it.
5. **No silent truncation.** If `stop_reason === 'max_tokens'` on a structured-output call, throw — never persist a partial parse.
6. **Cache what is stable, vary what is dynamic.** Use prompt caching for system prompts and KB context. User messages are never cached.
7. **Comments and docs:** follow the repo's terse style. Don't add explanatory comments unless the *why* is non-obvious.
8. **Migrations:** when changing a PocketBase schema, add a migration in `pb_migrations/` following the existing timestamp prefix convention. Never edit shipped migrations.
9. **Stop and ask** if you encounter a decision the plan doesn't cover (e.g. a model deprecation, a missing collection, a failing test that looks pre-existing).
### Files you will touch (or create) across all phases
| Path | Purpose |
|---|---|
| `src/lib/llm.js` *(new, Phase 1)* | Single Anthropic client wrapper |
| `src/lib/llmSchemas.js` *(new, Phase 1)* | Zod schemas for every structured task |
| `src/lib/llmRetry.js` *(new, Phase 1)* | Retry + backoff + abort policy |
| `src/lib/random.js` *(new, Phase 4)* | FisherYates shuffle + RNG helpers |
| `src/lib/api.js` | Becomes a thin re-export from `llm.js` (back-compat) |
| `src/lib/extractionPipeline.js` | Migrated to `llm.js` + tool use + overlap chunking |
| `src/lib/learningService.js` | Migrated to `llm.js` + tool use + patch-refine |
| `src/lib/testService.js` | Migrated to `llm.js` + tool use + dedup + shuffle fix |
| `src/components/admin/KnowledgeGraph.jsx` | `analyzeGraph` → tool use + dry-run preview |
| `src/components/chat/rag.js` | Retrieval (TF-IDF) + `lookup_topic` tool |
| `src/components/chat/prompts.js` | Split system prompt into cacheable + dynamic |
| `src/components/chat/useChat.js` | Wire retrieval + truncation |
| `pb_migrations/*` | Schema additions for `llm_calls`, question `difficulty`, topic `relevance_locked` |
| `evals/` *(new, Phase 6)* | Golden-set eval harness |
---
## Phase 1 — Foundation (single LLM client + robust parsing)
**Goal:** every LLM call goes through one module that handles retry, timeout, abort, JSON extraction, and schema validation. No behaviour change visible to the user.
### 1.1 Create `src/lib/llmRetry.js`
Implements the retry policy used by `llm.js`.
**Behaviour:**
- Exponential backoff with full jitter, base 1000ms, cap 16000ms.
- Retries only on these HTTP statuses: `408, 425, 429, 500, 502, 503, 504, 529`.
- Honours `Retry-After` header (seconds or HTTP date). If present and ≤ 60s, use it; if > 60s, fail fast.
- Default `maxRetries = 4`.
- Does **not** retry on `AbortError`.
**Exported interface:**
```js
// withRetry: (fn: (attempt:number) => Promise<T>, opts?) => Promise<T>
// RetryableError(status, retryAfterMs)
export async function withRetry(fn, { maxRetries = 4, signal } = {}) { ... }
export class RetryableError extends Error { constructor(status, retryAfterMs) { ... } }
```
### 1.2 Create `src/lib/llmSchemas.js`
One Zod schema per structured task. Install Zod (`npm i zod`).
Required schemas (names + shape match what callers already produce — do not change field names):
- `extractionResultSchema``{ topics: Topic[], relations: Relation[] }` matching the existing `SYSTEM_PROMPT` in [`extractionPipeline.js`](src/lib/extractionPipeline.js).
- `handbookResultSchema` — same shape, but `relation.type` enum unified to `related_to | depends_on | part_of | executed_by` (see Phase 3 task 3.5 — for now the schema accepts both `executes` and `executed_by`, normalize `executes → executed_by` post-validation).
- `learningArticleSchema`, `learningSlidesSchema`, `learningInfographicSchema`, `learningAllSchema` matching [`learningService.js`](src/lib/learningService.js).
- `quizQuestionsSchema``{ questions: Question[] }` with `options.length === 4` and `correctIndex ∈ [0,3]`.
- `customTopicSchema``{ label, type: 'concept'|'role'|'process', description }`.
- `graphActionsSchema``{ merges, deletions, newRelations, relevanceUpdates }` matching [`KnowledgeGraph.jsx:329`](src/components/admin/KnowledgeGraph.jsx).
- `proposeGraphDeltaSchema` — matches `PROPOSE_GRAPH_DELTA_TOOL.input_schema` in [`prompts.js`](src/components/chat/prompts.js).
**Acceptance:** every schema has at least one happy-path Vitest test in `src/lib/__tests__/llmSchemas.test.js` (add `vitest` if not present).
### 1.3 Create `src/lib/llm.js`
The single Anthropic client. All other modules must call only this one.
**Public interface:**
```js
// Task tier — used to pick a model from settings.
// 'fast' → admin:model:fast (default: claude-haiku-4-5-20251001)
// 'standard' → admin:model:standard (default: claude-sonnet-4-6)
// 'reasoning' → admin:model:reasoning (default: claude-opus-4-7)
export async function callLLM({
task, // string, e.g. 'extract.source' — used for logging only
tier = 'standard',
system, // string OR Array<{ type:'text', text:string, cache_control?:{type:'ephemeral'} }>
messages, // [{ role, content }] OR omitted (use `user`)
user, // shorthand for [{role:'user', content: user}]
tools, // optional Anthropic tool definitions
toolChoice, // optional, e.g. { type:'tool', name:'emit_knowledge_graph' }
schema, // optional Zod schema for text→JSON path (used only when no tool)
maxTokens = 4096,
temperature = 0,
signal, // AbortSignal
}): Promise<{
text: string,
toolUses: Array<{ name, input }>,
stopReason: string,
usage: { input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens },
requestId: string | null,
model: string,
durationMs: number,
}>
```
**Key requirements:**
1. **Simulation mode** preserved: if `storage.get('admin:use_simulation') === true`, return a deterministic stub (use existing `simulateResponse` payload for backward compatibility — branch on `task` prefix to return a matching stub).
2. **Fetch** with `AbortController`; default **60-second timeout** if caller didn't pass a signal.
3. **Retry** through `withRetry` (Phase 1.1).
4. **Auth-portal detection** preserved: if response is not `application/json`, throw `Your session has expired. Please refresh the page and log in again.` exactly as today.
5. **No truncation acceptance**: if `stop_reason === 'max_tokens'` AND caller passed `schema` OR `toolChoice` requested a tool, throw `LLMTruncatedError`.
6. **Robust JSON extraction** when caller passed `schema` (and no tool was used): use `parseStructuredText(text)` that
- strips ```` ```json ```` and ```` ``` ```` fences,
- finds the outermost balanced JSON value (object **or** array) via a tiny brace-matching scan, not regex,
- throws `LLMOutputError` if no balanced JSON found,
- runs Zod `schema.parse` on the result.
7. **Tool path:** when `tools` is provided and the model emits `tool_use`, return them under `toolUses`. Validate each tool's input against the corresponding Zod schema if the caller wired one in (via `toolSchemas: { [toolName]: ZodSchema }`).
8. **Logging:** after every call, append a row to a new PocketBase collection `llm_calls` (best-effort — never block on this; catch and console.debug failures). Fields: `task, model, tier, duration_ms, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, stop_reason, ok, error_msg`. See Phase 5 task 5.6 for the migration.
9. **Custom errors**: `LLMHttpError`, `LLMTruncatedError`, `LLMOutputError`, `LLMValidationError`. All extend `Error` and set `name` for `instanceof` checks.
### 1.4 Make `src/lib/api.js` a thin shim
Replace the existing `anthropicApi.generateContent` and `anthropicApi.chat` implementations with calls into `llm.js`. Preserve the exact exported names and return shapes so no caller breaks.
```js
// api.js after Phase 1
export const anthropicApi = {
async generateContent(systemPrompt, userMessage, maxRetries = 1) {
const { text } = await callLLM({
task: 'legacy.generateContent',
tier: 'standard',
system: systemPrompt,
user: userMessage,
maxTokens: 8192,
temperature: 0,
});
return text;
},
async chat(systemPrompt, messages, opts = {}) {
const r = await callLLM({
task: 'legacy.chat',
tier: 'standard',
system: systemPrompt,
messages,
tools: opts.tools,
maxTokens: 1024,
temperature: 0.3, // chat default — see Phase 5
});
return {
content: [
...(r.text ? [{ type:'text', text: r.text }] : []),
...r.toolUses.map(tu => ({ type:'tool_use', name: tu.name, input: tu.input })),
],
stop_reason: r.stopReason,
};
},
};
```
### 1.5 Update default model + tiered settings
- Replace `DEFAULT_MODEL = 'claude-sonnet-4-20250514'` with the three tier defaults above.
- In **Admin → Settings**, add three model selects (`fast`, `standard`, `reasoning`). Read existing `admin:model` as a legacy fallback for `standard` (so existing users don't lose their override).
### Phase 1 acceptance criteria
- [ ] `npm run lint` passes; `npm run test` passes (Vitest).
- [ ] Every existing user flow (extraction, weekly content, weekly quiz, R42, handbook sync, analyze graph) still works against the live API.
- [ ] `grep -r "fetch.*anthropic" src/` returns only `src/lib/llm.js`.
- [ ] Simulation mode toggle still returns stubbed responses for all flows.
- [ ] Manually verify: kill the network mid-call → request aborts within 60s and surfaces a clear error message.
- [ ] Manually verify: rate-limit the proxy (429 + `Retry-After: 5`) → call retries once after ~5s and then succeeds.
---
## Phase 2 — Prompt caching & tool-based structured outputs
**Goal:** structured-output tasks no longer parse JSON out of prose. Large stable prompts are cached.
### 2.1 Migrate extraction to tool use
In [`extractionPipeline.js`](src/lib/extractionPipeline.js):
- Replace the "Return JSON only" instruction with a tool: `emit_knowledge_graph` whose `input_schema` mirrors `extractionResultSchema`.
- Replace `anthropicApi.generateContent(...)` with `callLLM({ ..., tools:[emitKnowledgeGraphTool], toolChoice:{ type:'tool', name:'emit_knowledge_graph' } })`.
- Read the validated object from `toolUses[0].input`.
- Same migration for `analyzeHandbookDelta` (tool `emit_handbook_delta`).
- Delete every `responseText.match(/\{[\s\S]*\}/)` site.
### 2.2 Migrate learning, quiz, custom-topic, graph-actions to tool use
Same pattern, in:
- [`learningService.js`](src/lib/learningService.js): tools `emit_learning_article`, `emit_learning_slides`, `emit_learning_infographic`, `emit_learning_all`, `emit_custom_topic`.
- [`testService.js`](src/lib/testService.js): tool `emit_quiz_questions`.
- [`KnowledgeGraph.jsx:297`](src/components/admin/KnowledgeGraph.jsx): tool `emit_graph_actions`.
### 2.3 Prompt caching
Pass `system` as an array of blocks so the stable parts can be cached:
```js
system: [
{ type:'text', text: STABLE_SYSTEM_HEADER, cache_control: { type:'ephemeral' } },
{ type:'text', text: dynamicPart }, // not cached
],
```
Apply caching to:
- Extraction `SYSTEM_PROMPT` and `HANDBOOK_SYSTEM_PROMPT` (both fully stable → cache the whole block).
- R42 system prompt — split into three blocks: stable preamble (cached), KB context (cached *only* while the graph hasn't changed; bust by appending a short hash of the topic IDs+labels — Phase 5 details), and per-turn role line (not cached).
### 2.4 Patch-based learning refinement
Refactor `refineLearningContent` ([`learningService.js:147`](src/lib/learningService.js:147)) from "return the full updated JSON" to **patch operations** via tools:
- `set_section(heading: string, body: string)` — replace one section by heading match.
- `add_section(heading: string, body: string, position: 'start'|'end')`.
- `remove_section(heading: string)`.
- `replace_takeaways(items: string[])`.
- `set_intro(intro: string)`.
Apply patches client-side to the cached object. Re-validate against `learningArticleSchema` after patching; reject the whole turn if invalid.
### Phase 2 acceptance criteria
- [ ] No regex JSON extraction left in `src/`: `grep -rn "match(/\\\\{\\[\\\\s\\\\S\\]\\*\\\\}/)" src/` returns nothing.
- [ ] Token usage telemetry shows `cache_read_input_tokens > 0` on the second extraction call within 5 minutes (cache hit).
- [ ] Re-running extraction on a known source produces the same topic count ±10% as before this phase.
- [ ] `refineLearningContent` round-trip ("make the intro shorter") produces only the changed section in the diff against the prior cached content.
---
## Phase 3 — Extraction quality
**Goal:** fewer near-duplicate topics, no silent truncation, adaptive throttling, unified vocabulary.
### 3.1 Sentence-aware chunking with overlap
Replace `chunkText` in [`extractionPipeline.js:87`](src/lib/extractionPipeline.js:87):
- Target **~2000 input tokens per chunk**. Approximate as `chars / 4`. Configurable via `MAX_CHUNK_CHARS = 8000`.
- **200-token overlap** between chunks (`OVERLAP_CHARS = 800`).
- Split on sentence boundaries (`/(?<=[.!?])\s+/`) first; fall back to paragraph boundary if a sentence is too long; never produce a chunk larger than `MAX_CHUNK_CHARS`.
- Add a guard: if a single sentence exceeds `MAX_CHUNK_CHARS`, hard-split at character boundary and log a warning.
### 3.2 Stateful extraction
Before each chunk after the first, prepend to the user message:
```text
Already-extracted topic IDs (do NOT create new IDs for these — reuse them if the same concept appears here):
- software-engineer
- onboarding-buddy
...
```
Cap the list at 200 IDs by recency to keep token cost bounded. The model will then reuse IDs instead of inventing variants like `software-developer`.
### 3.3 Adaptive throttling
Replace the hard `setTimeout(r, 12000)` in [`extractionPipeline.js:127`](src/lib/extractionPipeline.js:127) and the 15s sleep in [`KnowledgeGraph.jsx:274`](src/components/admin/KnowledgeGraph.jsx:274) with a shared token-bucket limiter in `src/lib/llmRetry.js`:
```js
export const extractionLimiter = createLimiter({ rps: 5/60, burst: 1 }); // 5 req/min
// usage: await extractionLimiter.acquire();
```
`callLLM` accepts an optional `limiter` param that it `await`s before fetch. On 429 with `Retry-After`, the limiter is paused for that duration.
### 3.4 Preserve admin-edited relevance
Add a migration introducing `relevance_locked: bool` on `topics`. Set it to `true` whenever an admin edits `learning_relevance` via the UI ([`KnowledgeGraph.jsx`](src/components/admin/KnowledgeGraph.jsx) edit handler — locate by searching for `setLearningRelevance` or the relevance form field).
In `mergeKnowledgeGraph` ([`extractionPipeline.js:167`](src/lib/extractionPipeline.js:167)), when `relevance_locked`, never overwrite `learning_relevance`.
### 3.5 Unify relation vocabulary
Pick **one** set: `related_to | depends_on | part_of | executed_by`. Migrate:
- `HANDBOOK_SYSTEM_PROMPT` ([`extractionPipeline.js:42`](src/lib/extractionPipeline.js:42)) — change `executes` to `executed_by` and swap the source/target in the prompt example.
- Write a one-shot migration script `pb_migrations/<timestamp>_normalize_relation_types.js` that rewrites any existing `executes` relation to `executed_by` and swaps `source ↔ target`.
- Verify R42's `validateDelta` ([`rag.js:108`](src/components/chat/rag.js:108)) already enforces this set (it does) — no change needed there.
### 3.6 Cancellation
Add a "Cancel" button to the source-processing UI in `ContentManager.jsx` / `UploadZone.jsx` (locate the one that displays extraction progress). Wire it to abort the in-flight `callLLM` via the `signal` it receives. On cancel, set source status to `cancelled` (add to status enum migration).
### Phase 3 acceptance criteria
- [ ] Running extraction twice on the same `sources/ROLES.md` produces zero new topics on the second run (idempotency through reused IDs).
- [ ] Locked-relevance topics survive re-extraction.
- [ ] No fixed `setTimeout` ≥ 5s anywhere in `src/` (`grep -rn "setTimeout" src/`).
- [ ] Cancelling an extraction mid-run leaves the source in `cancelled` state, not `processing`.
- [ ] `pb_migrations` includes the relation-vocabulary normalization and the `relevance_locked` column.
---
## Phase 4 — Quiz & content quality
**Goal:** quiz questions are positionally unbiased, deduped, and difficulty-tagged. Random helpers are correct.
### 4.1 Random helpers
Create `src/lib/random.js`:
```js
export function shuffle(arr) { /* FisherYates, returns NEW array */ }
export function sample(arr, n) { /* unbiased sample without replacement */ }
export function pickInt(min, maxInclusive) { /* uniform integer */ }
```
Replace every `.sort(() => 0.5 - Math.random())` with `shuffle(arr)`:
- [`testService.js:122`](src/lib/testService.js:122) and [`testService.js:163`](src/lib/testService.js:163).
- Any other site found by `grep -rn "0.5 - Math.random()" src/`.
### 4.2 Debias `correctIndex` in quiz prompt
In [`testService.js:81`](src/lib/testService.js:81):
- Change the example in the prompt to use `"correctIndex": 2` (not 0).
- Add to the prompt: *"Distribute correctIndex roughly evenly across 0, 1, 2, and 3. Do not place the correct answer at the same position more than 4 out of 10 times."*
- After parsing, run a check: if more than 50% of the batch share the same `correctIndex`, log a warning and re-roll up to 2 times.
### 4.3 Difficulty field
- Add to `quizQuestionsSchema`: `difficulty: 'easy'|'medium'|'hard'`.
- Update the prompt to require difficulty on every question (current prompt says "4 easy, 4 medium, 2 hard" but never tagged — now tag it).
- Migration: add `difficulty` to the `quiz_banks.questions[]` element. PocketBase stores `questions` as JSON, so the migration is a no-op at the column level; older records get `difficulty: 'medium'` on read (add a normalizer in `db.js`).
### 4.4 Question dedup
In `forceGenerateTopicQuestions` ([`testService.js:65`](src/lib/testService.js:65)):
- Normalize question text (lowercase, strip punctuation, collapse whitespace) → `normKey`.
- Before persisting, drop any new question whose `normKey` matches an existing bank question.
- Log dropped duplicates with `console.debug('[quiz] dropped duplicate:', text)`.
### 4.5 Quality gate
In the same function, after schema validation:
- Reject the whole batch if any question has fewer than 4 distinct options.
- Reject if any option contains `"all of the above"`, `"none of the above"`, `"both A and B"` (case-insensitive).
- Reject if `explanation.trim().length < 20`.
- Surface the rejection to the admin UI with a "Retry" button.
### 4.6 Custom topic ID hygiene
In `generateCustomTopic` ([`learningService.js:177`](src/lib/learningService.js:177)):
- Generate kebab-case ID from the polished label, not `Date.now()`.
- Collision check against existing topics (append `-2`, `-3`, … if needed).
- Default `learning_relevance: 'standard'` when the model omits it.
### Phase 4 acceptance criteria
- [ ] No `.sort(() => 0.5 - Math.random())` anywhere in `src/`.
- [ ] Sample of 50 fresh quiz questions across 5 topics: no position holds >40% of correct answers.
- [ ] Re-running quiz generation for the same topic does not grow the bank with semantic duplicates.
- [ ] Custom topics created via R42 use kebab-case IDs and pass schema validation.
---
## Phase 5 — R42 retrieval & telemetry
**Goal:** R42 stops shipping the entire KG every turn; conversations are bounded; every call is logged.
### 5.1 TF-IDF retrieval in the browser
Create `src/lib/retrieval.js`:
```js
export function buildIndex(topics) { /* TF-IDF over label + description */ }
export function retrieveTopK(index, query, k = 10) { /* returns Topic[] */ }
```
Implementation: a small dependency-free TF-IDF — tokenize on `/[a-zA-Z0-9-]+/`, lowercase, drop stopwords (Dutch + English short list). Cache the index on the `topics` array reference. About 100 lines.
### 5.2 Rewrite `buildKbContext`
In [`rag.js:11`](src/components/chat/rag.js:11):
- Use `retrieveTopK(index, userMessage, 10)` to pick which topics go into the system prompt.
- Always include any topic whose ID or label is mentioned verbatim (existing behaviour).
- Drop the full "every topic" dump.
- Return `{ context, retrievedTopics, allTopics }``validateDelta` continues to use `allTopics`.
### 5.3 `lookup_topic` tool
Add a second R42 tool in [`prompts.js`](src/components/chat/prompts.js):
```js
export const LOOKUP_TOPIC_TOOL = {
name: 'lookup_topic',
description: 'Fetch the full description and any deeper learning content for a topic. Use when the retrieved context does not contain enough to answer.',
input_schema: { type:'object', properties: { id:{type:'string'} }, required:['id'] },
};
```
In `useChat.js`, when the model emits a `lookup_topic`, fetch via `db.getTopics()` + `db.getContent(id)` and append a `tool_result` block, then call `callLLM` again with the extended messages. Cap to **3 lookup hops per turn** to avoid loops.
### 5.4 R42 prompt cache busting
KB-context block is cached as ephemeral. The cache key is automatic per text, so any change busts it. **Append a stable suffix** to the KB block: `"\n[kb_hash: <first-8-chars-of-sha256-of-sorted-topic-ids>]"`. This guarantees the block content changes the moment a topic is added/removed, even if the included topic list looks similar.
### 5.5 Conversation truncation
In `useChat.js`:
- Keep last **12 turns** verbatim in `apiMessages`.
- Older messages: if more than 12 turns exist, summarize the older ones with a cheap (`tier: 'fast'`) call and prepend a single `{ role:'system'-equivalent inside the user history is not allowed by Anthropic — instead prepend a user/assistant pair }` block, OR simply drop older turns. **Default: drop older turns and prepend a one-line `assistant` message saying "(earlier conversation truncated)".** Summarization is an optional follow-up.
### 5.6 `llm_calls` collection
Add a migration `pb_migrations/<timestamp>_created_llm_calls.js` for collection `llm_calls`:
| Field | Type | Notes |
|---|---|---|
| `task` | text | e.g. `extract.source` |
| `model` | text | resolved model ID |
| `tier` | text | `fast` / `standard` / `reasoning` |
| `duration_ms` | number | |
| `input_tokens` | number | |
| `output_tokens` | number | |
| `cache_read_tokens` | number | |
| `cache_create_tokens` | number | |
| `stop_reason` | text | |
| `ok` | bool | |
| `error_msg` | text | nullable |
Wire `callLLM` to write to it (best-effort, never throws). Add a minimal `Admin → Diagnostics` view that shows the last 100 calls and aggregate cost (using public Anthropic prices in a constant; refresh manually).
### 5.7 R42 defaults
- `temperature: 0.3` for R42 chat in `callLLM`.
- `maxTokens: 2048` for R42 (text + tool budget).
### Phase 5 acceptance criteria
- [ ] R42 system prompt is ≤ 4000 tokens regardless of KG size (verify on a graph with 200+ topics).
- [ ] Adding a topic in the admin UI causes the **next** R42 call to show `cache_read_tokens === 0` for the KB block, then subsequent calls to show non-zero.
- [ ] R42 successfully answers a question about a topic whose ID was not in the user's message by emitting `lookup_topic` (manual verification).
- [ ] `Admin → Diagnostics` shows recent LLM calls with model, tokens, duration.
---
## Phase 6 — Eval harness (NOT IMPLEMENTED — out of scope)
> Phase 6 was deliberately skipped. The acceptance criteria for Phases 15 give enough confidence in extraction, content, quiz, R42, and telemetry that a golden-set harness is not currently load-bearing. Leave this section intact as a starting point for a future, separately-scoped initiative.
**Goal:** prompt or model changes can be measured before they ship.
### 6.1 Golden sets
Create `evals/` at the repo root:
```
evals/
extraction/
cases/
roles-handbook.txt
governance-handbook.txt
expected/
roles-handbook.json # { mustContain: ['software-engineer', ...], minTopics: 20 }
governance-handbook.json
quiz/
cases/
software-engineer.json # topic to generate quiz for
rubric.md # human-readable quality rubric
chat/
scripts/
ask-about-known-topic.json
ask-about-unknown-topic.json
propose-new-role.json
```
10 extraction cases, 5 quiz topics, 10 chat scripts is enough to start.
### 6.2 Runner
`evals/run.mjs` — Node script that:
1. Loads each case.
2. Invokes the same code path the app uses (import from `src/lib/*` directly; reuse the simulation toggle off).
3. Compares against expectations:
- **Extraction:** `mustContain` IDs present? `minTopics` met? No `stop_reason: 'max_tokens'`?
- **Quiz:** distribution of `correctIndex`, mean explanation length, banned phrases.
- **Chat:** for each script, did the expected tool fire? Did the reply contain expected anchors?
4. Writes `evals/results/<ISO-timestamp>.json` and a Markdown diff against the previous baseline.
Add `npm run eval` to `package.json`.
### 6.3 Prompt versioning
In each prompt module, export `PROMPT_VERSION = '2026-05-20-001'`. Persist it on the artifact (`content.data.prompt_version`, `quiz_banks.questions[i].prompt_version`, `topics.metadata.prompt_version`). Add an admin button "Mark stale content for regeneration" that lists artifacts whose version is older than the current.
### Phase 6 acceptance criteria
- [ ] `npm run eval` runs end-to-end against the live API and produces a result file.
- [ ] CI (or a manual check) runs evals on every change to `src/lib/llm.js`, prompts, or schemas.
- [ ] Each AI-generated artifact in PocketBase carries a `prompt_version` and is filterable by it.
---
## Cross-phase verification checklist
After every phase, run this short checklist before merging:
1. **Build:** `npm run build` succeeds.
2. **Lint:** `npm run lint` clean.
3. **Tests:** `npm run test` green.
4. **Smoke flows** in dev (simulation off, real API key):
- Add a source via `Admin → Sources`, extract, verify topics appear.
- Visit `Leren` for the current week, generate article. Then slides.
- Visit `Testen`, generate weekly quiz. Submit. Score lands on leaderboard.
- Open R42, ask a known and an unknown question; propose a new topic; admin accepts it.
- Run `Admin → Knowledge Graph → Analyze & Optimize Graph`.
- Run `Admin → Knowledge Graph → Sync Handbook` (small repo or mock).
5. **Simulation toggle:** flip simulation mode on and confirm no real API calls happen (Network tab).
---
## Rollback strategy
Every phase is shippable on its own. If a phase introduces a regression:
- **Phase 12:** `git revert` the merge commit. `api.js` retains the legacy interface, so callers that haven't migrated still work.
- **Phase 3:** revert + the relation-vocabulary migration is reversible (rerun the reverse swap).
- **Phase 4:** revert; quiz schema additions are forward-compatible (older readers ignore `difficulty`).
- **Phase 5:** revert + drop the `llm_calls` collection if undesired.
- **Phase 6:** purely additive; remove the `evals/` folder if abandoned.
Never `git push --force` to `main`. PR-per-phase.
---
## Out of scope (do not do as part of this plan)
- Replacing PocketBase. Stays as-is.
- Server-side embeddings or a vector store. Phase 5 deliberately uses in-browser TF-IDF.
- Streaming responses. Mentioned as a future improvement; not in this plan.
- Multi-tenant changes. The platform serves one company.
- UI redesign of the Admin pages beyond what each phase requires.
---
## Glossary
- **Tier** — coarse model class (`fast`/`standard`/`reasoning`) mapped to a concrete Anthropic model ID via admin settings.
- **Tool use** — Anthropic's structured-output mechanism. The model emits a `tool_use` content block whose `input` is schema-valid JSON.
- **Prompt caching** — Anthropic feature where blocks marked `cache_control: { type:'ephemeral' }` are reused across requests at lower input cost. 5-minute TTL.
- **TF-IDF** — Term-frequency / inverse-document-frequency. A classic IR scoring function used here as a cheap retrieval signal.
- **KB context** — The block in R42's system prompt that lists topics and relations from the knowledge graph.
- **Delta** — A proposed addition to the knowledge graph emitted by R42 via the `propose_graph_delta` tool.

153
CLAUDE.md Normal file
View File

@@ -0,0 +1,153 @@
# CLAUDE.md
## What you are building
A mobile-first progressive web application for employee learning. Employees follow
a perpetual 26-week curriculum built from an internal knowledge base. An AI
assistant called R42 is available on every screen. Admins upload source documents
that are processed into the knowledge base by AI.
Read the full design before writing any code:
- /docs/handover.md — all decisions made, rationale, constraints
- /docs/architecture.md — system design, data flows, tech stack
- /docs/data-model.md — all PocketBase collections, Qdrant schema, types
- /docs/ingestion-spec.md — ingestion service (build this first)
- /docs/implementation-plan.md — ordered build sequence with acceptance criteria
---
## Absolute constraints
These rules are non-negotiable and apply to every session:
1. Never modify any file listed in PROTECTED.md
2. Never modify any file outside /app — the pipeline, Dockerfile, ansible,
and docker-compose files are frozen
3. Never delete files without explicit confirmation
4. Never change package.json scripts that contain 'deploy' or 'build:prod'
5. Ask before acting when scope is unclear — do not infer intent from legacy/ code
---
## Repository structure
```
repo/
├── CLAUDE.md ← you are here
├── PROTECTED.md ← files you must never touch
├── docs/ ← spec files — read, never modify
├── legacy/ ← old prototype — read-only reference only
└── app/ ← your working directory
├── frontend/ ← Next.js 14 PWA
└── services/
├── ingestion/ ← build first
├── generation/ ← build second
├── curriculum/ ← build third
├── embedding/ ← integrated into ingestion, separate later
├── chat/ ← R42
└── progress/ ← gamification
```
All work happens inside /app. No exceptions.
---
## Tech stack
| Layer | Technology |
|---|---|
| Frontend | Next.js 14, TypeScript strict, Tailwind CSS, PWA |
| Backend state | PocketBase (binary, not source — do not scaffold from scratch) |
| Vector store | Qdrant via REST client |
| AI generation | Claude Sonnet 4 — model string: claude-sonnet-4-20250514 |
| AI chat (R42) | Claude Haiku 4.5 — model string: claude-haiku-4-5-20251001 |
| Embeddings | OpenAI text-embedding-3-small |
| Services | Node.js, Fastify, TypeScript strict |
| Validation | Zod on all external data (API responses, AI output, PocketBase) |
---
## Stylesheet
An existing stylesheet lives at /stylesheet.css in the repo root. This is the
authoritative visual style for the application.
Rules:
- Never modify stylesheet.css
- Import it as a global stylesheet in the Next.js frontend
- Do not override its rules with Tailwind utility classes or inline styles
- When Tailwind and the stylesheet conflict, the stylesheet wins
- If a UI element is not covered by the stylesheet, use Tailwind — but match
the visual language (spacing, colour, type scale) of the existing stylesheet
---
## Code conventions
- TypeScript strict mode everywhere — no `any` types
- All Claude API responses validated through Zod before use
- All PocketBase writes typed against collection schemas in data-model.md
- Qdrant payloads explicitly typed — no untyped objects
- No inline hardcoded API keys — environment variables only
- REST conventions for all service APIs
- Mobile-first CSS — design for 375px width, scale up
---
## Build order
Follow /docs/implementation-plan.md exactly.
Do not skip phases. Do not build phase N+1 before phase N passes its
acceptance criteria.
Current phase: **Phase 1 — Infrastructure + ingestion service**
---
## Session discipline
Each session has a defined scope in implementation-plan.md.
At the start of each session:
1. State which phase and step you are working on
2. Read the relevant spec file(s)
3. Propose a file structure or change plan before writing code
4. Implement after the plan is clear
At the end of each session:
1. State what was completed
2. State what acceptance criteria have been met
3. State what the next session should start with
---
## When you are uncertain
Check the spec files in /docs first.
If the spec does not cover it, flag the gap explicitly rather than making
an assumption. Do not look at legacy/ code for implementation guidance —
it is a different stack and will lead you in the wrong direction.
---
## Environment variables
Each service has its own .env file. Templates are defined in each service spec.
Never commit actual values. Always use .env.example with placeholder values.
The full set across all services:
```
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
POCKETBASE_URL=
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
QDRANT_URL=
QDRANT_API_KEY=
INGESTION_PORT=3001
GENERATION_PORT=3002
CURRICULUM_PORT=3003
CHAT_PORT=3004
PROGRESS_PORT=3005
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_POCKETBASE_URL=
```

34
PROTECTED.md Normal file
View File

@@ -0,0 +1,34 @@
# Protected files — do not modify
These files and directories must never be modified by Claude Code.
They belong to the existing deployment pipeline and are frozen.
## Pipeline and CI/CD
.github/
.github/workflows/
.gitlab-ci.yml (if present)
## Container and orchestration
Dockerfile
docker-compose.yml
docker-compose.prod.yml
docker-compose.override.yml (if present)
## Infrastructure and provisioning
ansible/
nginx/
## Stylesheet
stylesheet.css (repo root — use as-is, do not modify)
## Environment templates at repo root
.env.example (repo root only — services may have their own)
## Legacy prototype
legacy/
---
The only infrastructure file that will change is Dockerfile, and only in
Phase 8 step 8.5. That change is made manually by the human, not by
Claude Code.

0
app/frontend/.gitkeep Normal file
View File

View File

View File

View File

4
app/services/ingestion/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log

View File

@@ -0,0 +1,30 @@
{
"name": "ingestion",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"migrate": "tsx src/migrations/001_initial_schema.ts",
"migrate:qdrant": "tsx src/migrations/002_qdrant_setup.ts"
},
"dependencies": {
"fastify": "^4",
"@anthropic-ai/sdk": "^0.24",
"openai": "^4",
"@qdrant/js-client-rest": "^1.9",
"pocketbase": "^0.21",
"pdf-parse": "^1.1",
"uuid": "^9",
"zod": "^3"
},
"devDependencies": {
"typescript": "^5",
"tsx": "^4",
"dotenv": "^16",
"@types/node": "^20",
"@types/pdf-parse": "^1.1",
"@types/uuid": "^9"
}
}

View File

@@ -0,0 +1,18 @@
import 'dotenv/config';
import Fastify from 'fastify';
import documentRoutes from './routes/documents.js';
const PORT = parseInt(process.env['INGESTION_PORT'] ?? '3001', 10);
async function start(): Promise<void> {
const app = Fastify({ logger: true });
await app.register(documentRoutes);
await app.listen({ port: PORT, host: '0.0.0.0' });
}
start().catch((err: unknown) => {
console.error('Failed to start ingestion service:', err);
process.exit(1);
});

View File

@@ -0,0 +1,102 @@
import { v4 as uuid } from 'uuid';
import { extract } from '../pipeline/extract.js';
import { chunk } from '../pipeline/chunk.js';
import { clean } from '../pipeline/clean.js';
import { extractStructure } from '../pipeline/structure.js';
import { writeToPocketBase, markDocumentFailed } from '../pipeline/write.js';
import { embedAndStore } from '../pipeline/embed.js';
import type { Job, JobProgress, IngestBody } from '../types.js';
// ---------------------------------------------------------------------------
// In-memory store
// ---------------------------------------------------------------------------
const jobs = new Map<string, Job>();
const DEFAULT_PROGRESS: JobProgress = {
chunksTotal: 0,
chunksEmbedded: 0,
themesFound: 0,
topicsFound: 0,
};
export function createJob(params: IngestBody): Job {
const job: Job = {
id: uuid(),
documentId: params.documentId,
filename: params.filename,
format: params.format,
filePath: params.filePath,
status: 'queued',
progress: { ...DEFAULT_PROGRESS },
error: null,
createdAt: new Date(),
updatedAt: new Date(),
};
jobs.set(job.id, job);
void runPipeline(job.id);
return job;
}
export function getJob(id: string): Job | undefined {
return jobs.get(id);
}
function updateJob(id: string, updates: Partial<Omit<Job, 'id' | 'createdAt'>>): void {
const job = jobs.get(id);
if (!job) return;
jobs.set(id, { ...job, ...updates, updatedAt: new Date() });
}
function mergeProgress(id: string, partial: Partial<JobProgress>): void {
const job = jobs.get(id);
if (!job) return;
updateJob(id, { progress: { ...job.progress, ...partial } });
}
// ---------------------------------------------------------------------------
// Pipeline orchestration
// ---------------------------------------------------------------------------
async function runPipeline(jobId: string): Promise<void> {
const job = jobs.get(jobId);
if (!job) return;
try {
// Stage 1: text extraction
updateJob(jobId, { status: 'extracting' });
const text = await extract(job.filePath, job.format);
// Stages 23: chunking + cleaning
updateJob(jobId, { status: 'chunking' });
const rawChunks = chunk(text, job.format, job.documentId);
const cleanChunks = clean(rawChunks);
mergeProgress(jobId, { chunksTotal: cleanChunks.length });
// Stage 4: structure extraction (AI)
updateJob(jobId, { status: 'structuring' });
const draftKB = await extractStructure(cleanChunks, job.filename, job.format);
const topicsFound = draftKB.themes.reduce((n, t) => n + t.topics.length, 0);
mergeProgress(jobId, { themesFound: draftKB.themes.length, topicsFound });
// Stage 5: PocketBase write
updateJob(jobId, { status: 'writing' });
const writtenTopics = await writeToPocketBase(draftKB, job.documentId, cleanChunks.length);
// Stage 6: embeddings + Qdrant write
updateJob(jobId, { status: 'embedding' });
await embedAndStore(cleanChunks, writtenTopics, embedded => {
mergeProgress(jobId, { chunksEmbedded: embedded });
});
updateJob(jobId, { status: 'done' });
} catch (err: unknown) {
const reason = err instanceof Error ? err.message : String(err);
updateJob(jobId, { status: 'failed', error: reason });
const j = jobs.get(jobId);
if (j) {
await markDocumentFailed(j.documentId, reason).catch(() => undefined);
}
}
}

View File

@@ -0,0 +1,9 @@
import Anthropic from '@anthropic-ai/sdk';
export const anthropic = new Anthropic({
apiKey: process.env['ANTHROPIC_API_KEY'],
});
export const MODELS = {
SONNET: 'claude-sonnet-4-20250514',
} as const;

View File

@@ -0,0 +1,9 @@
import OpenAI from 'openai';
export const openai = new OpenAI({
apiKey: process.env['OPENAI_API_KEY'],
});
export const EMBEDDING_MODEL = 'text-embedding-3-small';
export const EMBEDDING_DIMENSIONS = 1536;
export const EMBEDDING_BATCH_SIZE = 100;

View File

@@ -0,0 +1,14 @@
import PocketBase from 'pocketbase';
const POCKETBASE_URL = process.env['POCKETBASE_URL'] ?? '';
const POCKETBASE_ADMIN_EMAIL = process.env['POCKETBASE_ADMIN_EMAIL'] ?? '';
const POCKETBASE_ADMIN_PASSWORD = process.env['POCKETBASE_ADMIN_PASSWORD'] ?? '';
const pb = new PocketBase(POCKETBASE_URL);
export async function getPocketBase(): Promise<PocketBase> {
if (!pb.authStore.isValid) {
await pb.admins.authWithPassword(POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD);
}
return pb;
}

View File

@@ -0,0 +1,14 @@
import { QdrantClient } from '@qdrant/js-client-rest';
const QDRANT_URL = process.env['QDRANT_URL'] ?? '';
const QDRANT_API_KEY = process.env['QDRANT_API_KEY'] ?? '';
export const qdrant = new QdrantClient({
url: QDRANT_URL,
...(QDRANT_API_KEY ? { apiKey: QDRANT_API_KEY } : {}),
});
export const QDRANT_COLLECTIONS = {
SOURCE_CHUNKS: 'source_chunks',
TOPIC_SUMMARIES: 'topic_summaries',
} as const;

View File

@@ -0,0 +1,402 @@
import 'dotenv/config';
import PocketBase, { type CollectionModel } from 'pocketbase';
// ---------------------------------------------------------------------------
// Env validation
// ---------------------------------------------------------------------------
const POCKETBASE_URL = process.env['POCKETBASE_URL'] ?? '';
const POCKETBASE_ADMIN_EMAIL = process.env['POCKETBASE_ADMIN_EMAIL'] ?? '';
const POCKETBASE_ADMIN_PASSWORD = process.env['POCKETBASE_ADMIN_PASSWORD'] ?? '';
if (!POCKETBASE_URL || !POCKETBASE_ADMIN_EMAIL || !POCKETBASE_ADMIN_PASSWORD) {
console.error('Missing env vars: POCKETBASE_URL, POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD');
process.exit(1);
}
// ---------------------------------------------------------------------------
// Field types
// ---------------------------------------------------------------------------
interface FieldDef {
name: string;
type: string;
required: boolean;
options: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Field helpers
// ---------------------------------------------------------------------------
const field = {
text: (name: string, required = false): FieldDef => ({
name, type: 'text', required,
options: { min: null, max: null, pattern: '' },
}),
number: (name: string, required = false): FieldDef => ({
name, type: 'number', required,
options: { min: null, max: null, noDecimal: false },
}),
select: (name: string, values: string[], required = false, maxSelect = 1): FieldDef => ({
name, type: 'select', required,
options: { maxSelect, values },
}),
json: (name: string): FieldDef => ({
name, type: 'json', required: false, options: {},
}),
date: (name: string): FieldDef => ({
name, type: 'date', required: false, options: { min: '', max: '' },
}),
editor: (name: string): FieldDef => ({
name, type: 'editor', required: false, options: {},
}),
file: (name: string, mimeTypes: string[] = [], maxSelect = 1): FieldDef => ({
name, type: 'file', required: false,
options: { maxSelect, maxSize: 52428800, mimeTypes, thumbs: [], protected: false },
}),
relation: (name: string, collectionId: string, maxSelect: number | null = 1, required = false): FieldDef => ({
name, type: 'relation', required,
options: { collectionId, cascadeDelete: false, minSelect: null, maxSelect, displayFields: null },
}),
};
// ---------------------------------------------------------------------------
// Badge seed data (from data-model.md + handover.md)
// ---------------------------------------------------------------------------
const BADGES = [
// Bronze — beginner milestones
{ key: 'first_commit', tier: 'bronze', label: 'First Commit', description: 'Complete your first topic', icon: '🔰' },
{ key: 'week_shipped', tier: 'bronze', label: 'Week Shipped', description: 'Complete your first full week', icon: '📦' },
{ key: 'streak_3', tier: 'bronze', label: '3-Week Streak', description: 'Maintain a 3-week learning streak', icon: '🔥' },
{ key: 'quiz_taker', tier: 'bronze', label: 'Quiz Taker', description: 'Complete your first scenario quiz', icon: '❓' },
{ key: 'flashcard_fan', tier: 'bronze', label: 'Flashcard Fan', description: 'Complete your first flashcard set', icon: '🃏' },
{ key: 'reached_junior', tier: 'bronze', label: 'Junior Dev', description: 'Reach Junior level', icon: '👶' },
// Silver — intermediate
{ key: 'streak_13', tier: 'silver', label: '13-Week Streak', description: 'Maintain a 13-week learning streak', icon: '💥' },
{ key: 'half_cycle', tier: 'silver', label: 'Half Cycle', description: 'Complete week 13 — halfway through the cycle', icon: '🔄' },
{ key: 'case_student', tier: 'silver', label: 'Case Student', description: 'Complete 5 case studies', icon: '📋' },
{ key: 'reached_medior', tier: 'silver', label: 'Medior Dev', description: 'Reach Medior level', icon: '💼' },
{ key: 'reached_senior', tier: 'silver', label: 'Senior Dev', description: 'Reach Senior level', icon: '🏅' },
// Gold — advanced
{ key: 'full_cycle', tier: 'gold', label: 'Full Cycle', description: 'Complete a full 26-week cycle', icon: '🏆' },
{ key: 'type_explorer', tier: 'gold', label: 'Type Explorer', description: 'Use all 10 micro learning types at least once', icon: '🗺️' },
{ key: 'reached_staff', tier: 'gold', label: 'Staff Eng', description: 'Reach Staff level', icon: '⭐' },
// Legendary — exceptional
{ key: 'streak_26', tier: 'legendary', label: 'Unbroken', description: 'Complete all 26 weeks without breaking a streak', icon: '⚡' },
{ key: 'second_cycle', tier: 'legendary', label: 'Second Cycle', description: 'Complete two full cycles', icon: '🌀' },
{ key: 'reached_principal', tier: 'legendary', label: 'Principal', description: 'Reach Principal level — maximum rank', icon: '👑' },
// Content — domain mastery
{ key: 'governance_nerd', tier: 'content', label: 'Governance Nerd', description: 'Complete all published governance topics', icon: '⚖️' },
{ key: 'process_architect', tier: 'content', label: 'Process Architect', description: 'Complete all published process topics', icon: '🏗️' },
{ key: 'deep_reader', tier: 'content', label: 'Deep Reader', description: 'Complete 50 or more unique topics', icon: '📚' },
] as const;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function requireId(ids: Map<string, string>, name: string): string {
const id = ids.get(name);
if (id === undefined) throw new Error(`Collection ID not found: ${name}`);
return id;
}
// ---------------------------------------------------------------------------
// Migration
// ---------------------------------------------------------------------------
async function run(): Promise<void> {
console.log('Connecting to PocketBase...');
const pb = new PocketBase(POCKETBASE_URL);
await pb.admins.authWithPassword(POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD);
console.log('Authenticated.\n');
// Snapshot of existing collections before we begin
const existingCollections = await pb.collections.getFullList();
const ids = new Map<string, string>();
for (const col of existingCollections) {
ids.set(col.name, col.id);
}
const existingNames = new Set(ids.keys());
// Create a collection only if it doesn't already exist
async function ensureCollection(params: { name: string; type: string; schema: FieldDef[] }): Promise<void> {
if (ids.has(params.name)) {
console.log(` skip ${params.name}`);
return;
}
const created = await pb.collections.create(params as Record<string, unknown>);
ids.set(created.name, created.id);
console.log(` create ${created.name}`);
}
// ---------------------------------------------------------------------------
// Extend users (auth collection — already exists in PocketBase)
// ---------------------------------------------------------------------------
console.log('users:');
const usersCol = await pb.collections.getFirstListItem<CollectionModel>('name="users"');
ids.set('users', usersCol.id);
const hasRole = usersCol.schema.some(s => s.name === 'role');
if (!hasRole) {
const updateBody: Record<string, unknown> = {
schema: [
...usersCol.schema,
field.select('role', ['admin', 'employee'], true),
field.text('display_name'),
field.file('avatar', ['image/jpeg', 'image/png', 'image/webp']),
],
};
await pb.collections.update(usersCol.id, updateBody);
console.log(' extended with role, display_name, avatar');
} else {
console.log(' skip users (already extended)');
}
// ---------------------------------------------------------------------------
// source_documents
// ---------------------------------------------------------------------------
console.log('\ncollections:');
await ensureCollection({
name: 'source_documents',
type: 'base',
schema: [
field.text('filename', true),
field.file('file', ['application/pdf', 'text/markdown', 'text/x-markdown', 'text/plain']),
field.select('format', ['pdf', 'md', 'txt'], true),
field.select('status', ['processing', 'processed', 'failed'], true),
field.date('ingested_at'),
field.number('chunk_count'),
field.relation('created_by', requireId(ids, 'users')),
],
});
// ---------------------------------------------------------------------------
// themes
// ---------------------------------------------------------------------------
await ensureCollection({
name: 'themes',
type: 'base',
schema: [
field.text('title', true),
field.text('description'),
field.select('status', ['draft', 'published'], true),
field.relation('source_documents', requireId(ids, 'source_documents'), null),
field.relation('approved_by', requireId(ids, 'users')),
field.date('approved_at'),
],
});
// ---------------------------------------------------------------------------
// topics — create without self-referential fields first
// ---------------------------------------------------------------------------
const topicsBaseSchema: FieldDef[] = [
field.relation('theme', requireId(ids, 'themes'), 1, true),
field.text('title', true),
field.editor('body'),
field.select('difficulty', ['introductory', 'intermediate', 'advanced'], true),
field.number('complexity_weight'),
field.select('status', ['draft', 'published'], true),
field.json('key_terms'),
field.json('qdrant_chunk_ids'),
];
const topicsIsNew = !existingNames.has('topics');
await ensureCollection({ name: 'topics', type: 'base', schema: topicsBaseSchema });
// ---------------------------------------------------------------------------
// micro_learnings
// ---------------------------------------------------------------------------
await ensureCollection({
name: 'micro_learnings',
type: 'base',
schema: [
field.relation('topic', requireId(ids, 'topics'), 1, true),
field.select('type', [
'concept_explainer', 'scenario_quiz', 'misconceptions', 'how_to',
'comparison_card', 'reflection_prompt', 'flashcard_set', 'case_study',
'glossary_anchor', 'myth_vs_evidence',
], true),
field.json('content'),
field.select('status', ['queued', 'generated', 'published', 'rejected'], true),
field.text('generation_model'),
field.date('generated_at'),
field.date('published_at'),
],
});
// ---------------------------------------------------------------------------
// curriculum_versions
// ---------------------------------------------------------------------------
await ensureCollection({
name: 'curriculum_versions',
type: 'base',
schema: [
field.number('version', true),
field.select('status', ['draft', 'active', 'superseded'], true),
field.date('generated_at'),
field.relation('approved_by', requireId(ids, 'users')),
field.date('approved_at'),
field.text('generation_notes'),
],
});
// ---------------------------------------------------------------------------
// curriculum_weeks
// ---------------------------------------------------------------------------
await ensureCollection({
name: 'curriculum_weeks',
type: 'base',
schema: [
field.relation('curriculum_version', requireId(ids, 'curriculum_versions'), 1, true),
field.number('week_number', true),
field.relation('theme', requireId(ids, 'themes'), 1, true),
field.relation('topics', requireId(ids, 'topics'), null),
field.json('topic_order'),
field.number('estimated_duration_minutes'),
field.text('admin_notes'),
],
});
// ---------------------------------------------------------------------------
// employee_curriculum_state
// ---------------------------------------------------------------------------
await ensureCollection({
name: 'employee_curriculum_state',
type: 'base',
schema: [
field.relation('user', requireId(ids, 'users'), 1, true),
field.number('current_cycle', true),
field.number('current_week', true),
field.date('start_date'),
field.relation('active_version', requireId(ids, 'curriculum_versions')),
],
});
// ---------------------------------------------------------------------------
// session_completions
// ---------------------------------------------------------------------------
await ensureCollection({
name: 'session_completions',
type: 'base',
schema: [
field.relation('user', requireId(ids, 'users'), 1, true),
field.relation('topic', requireId(ids, 'topics'), 1, true),
field.relation('micro_learning', requireId(ids, 'micro_learnings'), 1, true),
field.number('week_number', true),
field.number('cycle', true),
field.date('completed_at'),
],
});
// ---------------------------------------------------------------------------
// gamification_profiles
// ---------------------------------------------------------------------------
await ensureCollection({
name: 'gamification_profiles',
type: 'base',
schema: [
field.relation('user', requireId(ids, 'users'), 1, true),
field.number('total_commits'),
field.select('current_level', ['intern', 'junior', 'medior', 'senior', 'staff', 'principal']),
field.number('current_streak_weeks'),
field.number('longest_streak_weeks'),
field.json('types_used'),
field.number('last_active_week'),
],
});
// ---------------------------------------------------------------------------
// badges
// ---------------------------------------------------------------------------
await ensureCollection({
name: 'badges',
type: 'base',
schema: [
field.text('key', true),
field.select('tier', ['bronze', 'silver', 'gold', 'legendary', 'content'], true),
field.text('label', true),
field.text('description'),
field.text('icon'),
],
});
// ---------------------------------------------------------------------------
// employee_badges
// ---------------------------------------------------------------------------
await ensureCollection({
name: 'employee_badges',
type: 'base',
schema: [
field.relation('user', requireId(ids, 'users'), 1, true),
field.relation('badge', requireId(ids, 'badges'), 1, true),
field.date('earned_at'),
field.number('cycle'),
],
});
// ---------------------------------------------------------------------------
// milestone_cards
// ---------------------------------------------------------------------------
await ensureCollection({
name: 'milestone_cards',
type: 'base',
schema: [
field.relation('user', requireId(ids, 'users'), 1, true),
field.number('cycle', true),
field.number('week', true),
field.number('total_commits'),
field.number('streak_weeks'),
field.json('badge_keys'),
],
});
// ---------------------------------------------------------------------------
// Add self-referential relations to topics (requires topics ID to exist first)
// ---------------------------------------------------------------------------
if (topicsIsNew) {
console.log('\ntopics self-refs:');
const topicsId = requireId(ids, 'topics');
const updateBody: Record<string, unknown> = {
schema: [
...topicsBaseSchema,
field.relation('related_topics', topicsId, null),
field.relation('prerequisite_topics', topicsId, null),
field.relation('contrast_topics', topicsId, null),
],
};
await pb.collections.update(topicsId, updateBody);
console.log(' updated topics with related_topics, prerequisite_topics, contrast_topics');
}
// ---------------------------------------------------------------------------
// Seed badges
// ---------------------------------------------------------------------------
console.log('\nbadges:');
const existingBadges = await pb.collection('badges').getFullList<{ key: string }>();
const existingBadgeKeys = new Set(existingBadges.map(b => b.key));
for (const badge of BADGES) {
if (existingBadgeKeys.has(badge.key)) {
console.log(` skip ${badge.key}`);
continue;
}
await pb.collection('badges').create(badge);
console.log(` seed ${badge.key}`);
}
console.log('\nDone.');
}
run().catch((err: unknown) => {
console.error('Migration failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,76 @@
import 'dotenv/config';
import { QdrantClient } from '@qdrant/js-client-rest';
// ---------------------------------------------------------------------------
// Env validation
// ---------------------------------------------------------------------------
const QDRANT_URL = process.env['QDRANT_URL'] ?? '';
const QDRANT_API_KEY = process.env['QDRANT_API_KEY'] ?? '';
if (!QDRANT_URL) {
console.error('Missing env var: QDRANT_URL');
process.exit(1);
}
// ---------------------------------------------------------------------------
// Collection definitions
// ---------------------------------------------------------------------------
const VECTOR_SIZE = 1536; // text-embedding-3-small
const DISTANCE = 'Cosine' as const;
const COLLECTIONS = [
{
name: 'source_chunks',
// Payload indices for R42 context-weighted retrieval (boost by theme_id)
payloadIndices: ['theme_id', 'topic_id', 'source_document_id', 'format'],
},
{
name: 'topic_summaries',
payloadIndices: ['theme_id', 'topic_id'],
},
] as const;
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
async function run(): Promise<void> {
console.log('Connecting to Qdrant...');
const client = new QdrantClient({
url: QDRANT_URL,
...(QDRANT_API_KEY ? { apiKey: QDRANT_API_KEY } : {}),
});
const { collections } = await client.getCollections();
const existingNames = new Set(collections.map(c => c.name));
for (const col of COLLECTIONS) {
if (existingNames.has(col.name)) {
console.log(` skip ${col.name}`);
continue;
}
await client.createCollection(col.name, {
vectors: { size: VECTOR_SIZE, distance: DISTANCE },
});
console.log(` create ${col.name}`);
// Keyword payload indices for efficient filtering
for (const field of col.payloadIndices) {
await client.createPayloadIndex(col.name, {
field_name: field,
field_schema: 'keyword',
});
console.log(` index ${col.name}.${field}`);
}
}
console.log('\nDone.');
}
run().catch((err: unknown) => {
console.error('Qdrant setup failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,246 @@
import { v4 as uuid } from 'uuid';
import type { Chunk, DocumentFormat } from '../types.js';
const MD_MIN = 100;
const MD_MAX = 1500;
const TXT_WINDOW = 800;
const TXT_OVERLAP = 150;
const PDF_MIN = 100;
const PDF_MAX = 1200;
// ---------------------------------------------------------------------------
// MD chunking — heading-based
// ---------------------------------------------------------------------------
interface HeadingSection {
level: number;
heading: string;
parent: string | null;
content: string;
}
function parseMdSections(text: string): HeadingSection[] {
const lines = text.split('\n');
const sections: HeadingSection[] = [];
let current: HeadingSection | null = null;
const parentStack: { level: number; heading: string }[] = [];
for (const line of lines) {
const h3 = line.match(/^### (.+)/);
const h2 = line.match(/^## (.+)/);
const h1 = line.match(/^# (.+)/);
const match = h3 ?? h2 ?? h1;
if (match) {
if (current) sections.push(current);
const level = h1 ? 1 : h2 ? 2 : 3;
const heading = match[1] ?? line;
// Maintain parent stack
while (parentStack.length > 0 && (parentStack[parentStack.length - 1]?.level ?? 0) >= level) {
parentStack.pop();
}
const parent = parentStack[parentStack.length - 1]?.heading ?? null;
parentStack.push({ level, heading });
current = { level, heading, parent, content: '' };
} else if (current) {
current.content += line + '\n';
}
}
if (current) sections.push(current);
return sections;
}
function splitOnParagraphs(text: string, maxSize: number): string[] {
const paragraphs = text.split(/\n\n+/);
const parts: string[] = [];
let current = '';
for (const para of paragraphs) {
if ((current + para).length > maxSize && current.length > 0) {
parts.push(current.trim());
current = para;
} else {
current = current ? current + '\n\n' + para : para;
}
}
if (current.trim()) parts.push(current.trim());
return parts;
}
function chunkMd(text: string, documentId: string): Chunk[] {
const sections = parseMdSections(text);
const merged: HeadingSection[] = [];
for (let i = 0; i < sections.length; i++) {
const sec = sections[i];
if (sec === undefined) continue;
const fullText = `${'#'.repeat(sec.level)} ${sec.heading}\n\n${sec.content}`.trim();
if (fullText.length < MD_MIN && i + 1 < sections.length) {
// Merge with next sibling by appending to next's content prefix
const next = sections[i + 1];
if (next !== undefined) {
next.content = fullText + '\n\n' + next.content;
continue;
}
}
merged.push(sec);
}
const chunks: Chunk[] = [];
let globalIndex = 0;
for (const sec of merged) {
const fullText = `${'#'.repeat(sec.level)} ${sec.heading}\n\n${sec.content}`.trim();
const parts = fullText.length > MD_MAX ? splitOnParagraphs(fullText, MD_MAX) : [fullText];
for (const part of parts) {
if (part.length < 1) continue;
chunks.push({
id: uuid(),
documentId,
text: part,
format: 'md',
index: globalIndex++,
metadata: {
headingLevel: sec.level,
headingText: sec.heading,
parentHeading: sec.parent ?? undefined,
},
});
}
}
return chunks;
}
// ---------------------------------------------------------------------------
// TXT chunking — sliding window
// ---------------------------------------------------------------------------
function splitSentences(text: string): string[] {
return text.match(/[^.!?]+[.!?]+\s*/g) ?? [text];
}
function chunkTxt(text: string, documentId: string): Chunk[] {
const paragraphs = text.split(/\n\n+/).filter(p => p.trim().length > 0);
const chunks: Chunk[] = [];
let current = '';
let index = 0;
const total = text.length;
const pushChunk = (t: string) => {
const pos = index * TXT_WINDOW;
chunks.push({
id: uuid(),
documentId,
text: t.trim(),
format: 'txt',
index: index++,
metadata: {
approximatePosition:
pos < total * 0.25 ? 'start' : pos > total * 0.75 ? 'end' : 'middle',
},
});
};
for (const para of paragraphs) {
if ((current + ' ' + para).trim().length <= TXT_WINDOW) {
current = (current + ' ' + para).trim();
} else {
if (current.length >= MD_MIN) pushChunk(current);
// Para may itself exceed window — split on sentences
if (para.length > TXT_WINDOW) {
const sentences = splitSentences(para);
let buf = '';
for (const sent of sentences) {
if ((buf + sent).length > TXT_WINDOW && buf.length >= MD_MIN) {
pushChunk(buf);
// Keep overlap
const words = buf.split(' ');
buf = words.slice(-Math.floor(TXT_OVERLAP / 5)).join(' ') + ' ' + sent;
} else {
buf += sent;
}
}
current = buf;
} else {
current = para;
}
}
}
if (current.trim().length >= MD_MIN) pushChunk(current);
return chunks;
}
// ---------------------------------------------------------------------------
// PDF chunking — page + paragraph
// ---------------------------------------------------------------------------
function chunkPdf(text: string, documentId: string): Chunk[] {
const pages = text.split('---PAGE---');
const chunks: Chunk[] = [];
let globalIndex = 0;
for (let pageIdx = 0; pageIdx < pages.length; pageIdx++) {
const page = pages[pageIdx];
if (page === undefined || !page.trim()) continue;
const paragraphs = page.split(/\n\n+/).filter(p => p.trim().length > 0);
let chunkOnPage = 0;
let accumulator = '';
const flushAccumulator = () => {
const t = accumulator.trim();
if (t.length >= PDF_MIN) {
chunks.push({
id: uuid(),
documentId,
text: t,
format: 'pdf',
index: globalIndex++,
metadata: { pageNumber: pageIdx + 1, chunkIndexOnPage: chunkOnPage++ },
});
}
accumulator = '';
};
for (const para of paragraphs) {
if ((accumulator + '\n\n' + para).trim().length > PDF_MAX) {
flushAccumulator();
// Para may exceed max on its own — hard split at sentence boundary
if (para.length > PDF_MAX) {
const sentences = splitSentences(para);
for (const sent of sentences) {
if ((accumulator + sent).length > PDF_MAX && accumulator.length >= PDF_MIN) {
flushAccumulator();
}
accumulator += sent;
}
} else {
accumulator = para;
}
} else {
accumulator = accumulator ? accumulator + '\n\n' + para : para;
}
}
flushAccumulator();
}
return chunks;
}
// ---------------------------------------------------------------------------
// Public entry
// ---------------------------------------------------------------------------
export function chunk(text: string, format: DocumentFormat, documentId: string): Chunk[] {
switch (format) {
case 'md': return chunkMd(text, documentId);
case 'txt': return chunkTxt(text, documentId);
case 'pdf': return chunkPdf(text, documentId);
}
}

View File

@@ -0,0 +1,20 @@
import type { Chunk } from '../types.js';
const MIN_CLEAN_LENGTH = 80;
export function clean(chunks: Chunk[]): Chunk[] {
return chunks
.map(c => ({ ...c, text: cleanText(c.text) }))
.filter(c => c.text.length >= MIN_CLEAN_LENGTH);
}
function cleanText(text: string): string {
return text
.normalize('NFC')
// Remove null bytes and non-printable characters (keep tabs + newlines)
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
// Collapse 3+ consecutive newlines to 2
.replace(/\n{3,}/g, '\n\n')
// Trim leading/trailing whitespace
.trim();
}

View File

@@ -0,0 +1,102 @@
import { v4 as uuid } from 'uuid';
import { openai, EMBEDDING_MODEL, EMBEDDING_BATCH_SIZE } from '../lib/openai.js';
import { qdrant, QDRANT_COLLECTIONS } from '../lib/qdrant.js';
import { updateTopicQdrantIds } from './write.js';
import type { Chunk, WrittenTopic, SourceChunkPayload, TopicSummaryPayload } from '../types.js';
async function embedTexts(texts: string[]): Promise<number[][]> {
const vectors: number[][] = [];
for (let i = 0; i < texts.length; i += EMBEDDING_BATCH_SIZE) {
const batch = texts.slice(i, i + EMBEDDING_BATCH_SIZE);
const response = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: batch,
});
for (const item of response.data) {
vectors.push(item.embedding);
}
}
return vectors;
}
export async function embedAndStore(
chunks: Chunk[],
writtenTopics: WrittenTopic[],
onProgress: (embedded: number) => void,
): Promise<void> {
// Build chunk → topic mapping
const chunkTopicMap = new Map<string, string>();
const chunkThemeMap = new Map<string, string>();
for (const topic of writtenTopics) {
for (const chunkId of topic.sourceChunkIds) {
chunkTopicMap.set(chunkId, topic.id);
chunkThemeMap.set(chunkId, topic.themeId);
}
}
// -------------------------------------------------------------------------
// Source chunks
// -------------------------------------------------------------------------
const chunkTexts = chunks.map(c => c.text);
const chunkVectors = await embedTexts(chunkTexts);
const sourcePoints = chunks.map((c, i) => {
const vector = chunkVectors[i];
if (!vector) throw new Error(`Missing embedding for chunk index ${i}`);
const payload: SourceChunkPayload = {
source_document_id: c.documentId,
chunk_index: c.index,
text: c.text,
theme_id: chunkThemeMap.get(c.id) ?? null,
topic_id: chunkTopicMap.get(c.id) ?? null,
format: c.format,
};
return { id: c.id, vector, payload };
});
// Upsert in batches
for (let i = 0; i < sourcePoints.length; i += EMBEDDING_BATCH_SIZE) {
const batch = sourcePoints.slice(i, i + EMBEDDING_BATCH_SIZE);
await qdrant.upsert(QDRANT_COLLECTIONS.SOURCE_CHUNKS, { points: batch });
onProgress(i + batch.length);
}
// -------------------------------------------------------------------------
// Topic summaries
// -------------------------------------------------------------------------
const topicTexts = writtenTopics.map(t => t.body);
const topicVectors = await embedTexts(topicTexts);
const summaryPoints = writtenTopics.map((topic, i) => {
const vector = topicVectors[i];
if (!vector) throw new Error(`Missing embedding for topic index ${i}`);
const payload: TopicSummaryPayload = {
topic_id: topic.id,
theme_id: topic.themeId,
title: topic.title,
text: topic.body,
};
return { id: uuid(), vector, payload };
});
for (let i = 0; i < summaryPoints.length; i += EMBEDDING_BATCH_SIZE) {
const batch = summaryPoints.slice(i, i + EMBEDDING_BATCH_SIZE);
await qdrant.upsert(QDRANT_COLLECTIONS.TOPIC_SUMMARIES, { points: batch });
}
// -------------------------------------------------------------------------
// Update topics.qdrant_chunk_ids in PocketBase
// -------------------------------------------------------------------------
for (const topic of writtenTopics) {
const qdrantIds = topic.sourceChunkIds.filter(id => chunkTopicMap.get(id) === topic.id);
if (qdrantIds.length > 0) {
await updateTopicQdrantIds(topic.id, qdrantIds);
}
}
}

View File

@@ -0,0 +1,40 @@
import fs from 'node:fs/promises';
import type { DocumentFormat } from '../types.js';
// Lazy import — pdf-parse has side effects on module load
async function getPdfParse() {
const mod = await import('pdf-parse');
return mod.default ?? mod;
}
export async function extract(filePath: string, format: DocumentFormat): Promise<string> {
let fileBuffer: Buffer;
try {
fileBuffer = await fs.readFile(filePath);
} catch {
throw new Error('file_not_found');
}
if (format === 'txt' || format === 'md') {
return fileBuffer.toString('utf-8');
}
// PDF — collect one text string per page via pagerender callback
const pdfParse = await getPdfParse();
const pageTexts: string[] = [];
await pdfParse(fileBuffer, {
pagerender: (pageData: { getTextContent: () => Promise<{ items: Array<{ str: string }> }> }) =>
pageData.getTextContent().then(tc => {
const text = tc.items.map(i => i.str).join(' ').trim();
if (text) pageTexts.push(text);
return text;
}),
});
if (pageTexts.length === 0) {
throw new Error('pdf_extraction_empty');
}
return pageTexts.join('\n\n---PAGE---\n\n');
}

View File

@@ -0,0 +1,181 @@
import { anthropic, MODELS } from '../lib/anthropic.js';
import { DraftKBSchema, type Chunk, type DraftKB, type DraftTheme, type DraftTopic, type DocumentFormat } from '../types.js';
const BATCH_SIZE = 40;
const BATCH_OVERLAP = 5;
const LARGE_DOC_THRESHOLD = 60;
const 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.
Output schema:
{
"themes": [
{
"title": "string",
"description": "string",
"topics": [
{
"title": "string",
"body": "string",
"difficulty": "introductory" | "intermediate" | "advanced",
"complexityWeight": 1-5,
"keyTerms": ["string"],
"sourceChunkIds": ["chunk-id"],
"relationships": {
"related": ["topic title"],
"prerequisites": ["topic title"],
"contrasts": ["topic title"]
}
}
]
}
]
}`;
const STRICT_SUFFIX = '\n\nCRITICAL: Your entire response must be valid JSON only. No text before or after.';
function buildUserPrompt(chunks: Chunk[], filename: string, format: DocumentFormat, strict: boolean): string {
const chunkText = chunks
.map(c => `[CHUNK-${c.id}]\n${c.text}`)
.join('\n\n');
return `Source document: ${filename}\nFormat: ${format}\n\nChunks:\n${chunkText}\n\nExtract the knowledge base structure from these chunks.${strict ? STRICT_SUFFIX : ''}`;
}
async function callClaude(chunks: Chunk[], filename: string, format: DocumentFormat, strict: boolean): Promise<DraftKB> {
const response = await anthropic.messages.create({
model: MODELS.SONNET,
max_tokens: 8000,
temperature: 0,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: buildUserPrompt(chunks, filename, format, strict) }],
});
const textBlock = response.content.find(b => b.type === 'text');
if (!textBlock || textBlock.type !== 'text') {
throw new Error('structure_extraction_failed: no text block in response');
}
let parsed: unknown;
try {
parsed = JSON.parse(textBlock.text);
} catch {
if (strict) throw new Error('structure_extraction_failed');
return callClaude(chunks, filename, format, true);
}
const result = DraftKBSchema.safeParse(parsed);
if (!result.success) {
if (strict) throw new Error('structure_extraction_failed');
return callClaude(chunks, filename, format, true);
}
if (result.data.themes.length === 0) {
throw new Error('no_structure_found');
}
return result.data;
}
// ---------------------------------------------------------------------------
// DraftKB merge (for large documents processed in batches)
// ---------------------------------------------------------------------------
interface MergedTheme {
title: string;
description: string;
topicMap: Map<string, DraftTopic>;
}
function mergeDraftKBs(batches: DraftKB[]): DraftKB {
const themeMap = new Map<string, MergedTheme>();
for (const kb of batches) {
for (const theme of kb.themes) {
const key = theme.title.toLowerCase().trim();
const existing = themeMap.get(key);
if (!existing) {
themeMap.set(key, {
title: theme.title,
description: theme.description,
topicMap: new Map(theme.topics.map(t => [t.title.toLowerCase().trim(), { ...t }])),
});
} else {
if (theme.description.length > existing.description.length) {
existing.description = theme.description;
}
for (const topic of theme.topics) {
const tKey = topic.title.toLowerCase().trim();
const existingTopic = existing.topicMap.get(tKey);
if (!existingTopic) {
existing.topicMap.set(tKey, { ...topic });
} else {
existingTopic.body =
existingTopic.body.length >= topic.body.length ? existingTopic.body : topic.body;
existingTopic.sourceChunkIds = [
...new Set([...existingTopic.sourceChunkIds, ...topic.sourceChunkIds]),
];
existingTopic.relationships = {
related: [...new Set([...existingTopic.relationships.related, ...topic.relationships.related])],
prerequisites: [...new Set([...existingTopic.relationships.prerequisites, ...topic.relationships.prerequisites])],
contrasts: [...new Set([...existingTopic.relationships.contrasts, ...topic.relationships.contrasts])],
};
}
}
}
}
}
const themes: DraftTheme[] = [...themeMap.values()].map(t => ({
title: t.title,
description: t.description,
topics: [...t.topicMap.values()],
}));
return { themes };
}
// ---------------------------------------------------------------------------
// Public entry
// ---------------------------------------------------------------------------
export async function extractStructure(
chunks: Chunk[],
filename: string,
format: DocumentFormat,
): Promise<DraftKB> {
if (chunks.length <= LARGE_DOC_THRESHOLD) {
return callClaude(chunks, filename, format, false);
}
const batches: DraftKB[] = [];
let start = 0;
while (start < chunks.length) {
const end = Math.min(start + BATCH_SIZE, chunks.length);
const batchChunks = chunks.slice(start, end);
const batchKB = await callClaude(batchChunks, filename, format, false);
batches.push(batchKB);
if (end >= chunks.length) break;
start = end - BATCH_OVERLAP;
}
return mergeDraftKBs(batches);
}

View File

@@ -0,0 +1,91 @@
import { getPocketBase } from '../lib/pocketbase.js';
import type { Chunk, DraftKB, WrittenTopic } from '../types.js';
export async function writeToPocketBase(
draftKB: DraftKB,
documentId: string,
chunkCount: number,
): Promise<WrittenTopic[]> {
const pb = await getPocketBase();
await pb.collection('source_documents').update(documentId, {
status: 'processed',
chunk_count: chunkCount,
ingested_at: new Date().toISOString(),
});
const writtenTopics: WrittenTopic[] = [];
// title → PocketBase topic ID (for relationship resolution)
const topicIdByTitle = new Map<string, string>();
// Pass 1: create all themes and topics (without relationships)
for (const theme of draftKB.themes) {
const themeRecord = await pb.collection('themes').create({
title: theme.title,
description: theme.description,
status: 'draft',
source_documents: [documentId],
});
for (const topic of theme.topics) {
const topicRecord = await pb.collection('topics').create({
theme: themeRecord.id,
title: topic.title,
body: topic.body,
difficulty: topic.difficulty,
complexity_weight: topic.complexityWeight,
status: 'draft',
key_terms: topic.keyTerms,
qdrant_chunk_ids: [],
related_topics: [],
prerequisite_topics: [],
contrast_topics: [],
});
topicIdByTitle.set(topic.title.toLowerCase().trim(), topicRecord.id);
writtenTopics.push({
id: topicRecord.id,
title: topic.title,
themeId: themeRecord.id,
body: topic.body,
sourceChunkIds: topic.sourceChunkIds,
});
}
}
// Pass 2: resolve relationships (title → ID lookup)
for (const theme of draftKB.themes) {
for (const topic of theme.topics) {
const topicId = topicIdByTitle.get(topic.title.toLowerCase().trim());
if (!topicId) continue;
const resolve = (titles: string[]): string[] =>
titles
.map(t => topicIdByTitle.get(t.toLowerCase().trim()))
.filter((id): id is string => id !== undefined);
await pb.collection('topics').update(topicId, {
related_topics: resolve(topic.relationships.related),
prerequisite_topics: resolve(topic.relationships.prerequisites),
contrast_topics: resolve(topic.relationships.contrasts),
});
}
}
return writtenTopics;
}
export async function updateTopicQdrantIds(
topicId: string,
qdrantChunkIds: string[],
): Promise<void> {
const pb = await getPocketBase();
await pb.collection('topics').update(topicId, { qdrant_chunk_ids: qdrantChunkIds });
}
export async function markDocumentFailed(documentId: string, reason: string): Promise<void> {
console.error(`[write] document ${documentId} failed: ${reason}`);
const pb = await getPocketBase();
await pb.collection('source_documents').update(documentId, { status: 'failed' });
}

View File

@@ -0,0 +1,29 @@
import type { FastifyPluginAsync } from 'fastify';
import { IngestBodySchema } from '../types.js';
import { createJob, getJob } from '../jobs/queue.js';
const documentRoutes: FastifyPluginAsync = async (app) => {
app.post('/ingest', async (request, reply) => {
const parsed = IngestBodySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.format() });
}
const job = createJob(parsed.data);
return reply.status(202).send({ jobId: job.id, status: job.status });
});
app.get<{ Params: { jobId: string } }>('/status/:jobId', async (request, reply) => {
const job = getJob(request.params.jobId);
if (!job) {
return reply.status(404).send({ error: 'Job not found' });
}
return reply.send({
jobId: job.id,
status: job.status,
progress: job.progress,
error: job.error,
});
});
};
export default documentRoutes;

View File

@@ -0,0 +1,145 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Shared primitives
// ---------------------------------------------------------------------------
export type DocumentFormat = 'pdf' | 'md' | 'txt';
// ---------------------------------------------------------------------------
// Pipeline: Chunk
// ---------------------------------------------------------------------------
export interface ChunkMetadata {
// MD-specific
headingLevel?: number;
headingText?: string;
parentHeading?: string;
// TXT-specific
approximatePosition?: 'start' | 'middle' | 'end';
// PDF-specific
pageNumber?: number;
chunkIndexOnPage?: number;
}
export interface Chunk {
id: string;
documentId: string;
text: string;
format: DocumentFormat;
index: number;
metadata: ChunkMetadata;
}
// ---------------------------------------------------------------------------
// Pipeline: DraftKB (AI output — validated with Zod before use)
// ---------------------------------------------------------------------------
const DraftTopicRelationshipsSchema = z.object({
related: z.array(z.string()),
prerequisites: z.array(z.string()),
contrasts: z.array(z.string()),
});
export const DraftTopicSchema = z.object({
title: z.string().min(1),
body: z.string().min(1),
difficulty: z.enum(['introductory', 'intermediate', 'advanced']),
complexityWeight: z.number().int().min(1).max(5),
keyTerms: z.array(z.string()),
sourceChunkIds: z.array(z.string()),
relationships: DraftTopicRelationshipsSchema,
});
export const DraftThemeSchema = z.object({
title: z.string().min(1),
description: z.string().min(1),
topics: z.array(DraftTopicSchema).min(1),
});
export const DraftKBSchema = z.object({
themes: z.array(DraftThemeSchema).min(1),
});
export type DraftTopic = z.infer<typeof DraftTopicSchema>;
export type DraftTheme = z.infer<typeof DraftThemeSchema>;
export type DraftKB = z.infer<typeof DraftKBSchema>;
// ---------------------------------------------------------------------------
// Pipeline: PocketBase write result
// ---------------------------------------------------------------------------
export interface WrittenTopic {
id: string;
title: string;
themeId: string;
body: string;
sourceChunkIds: string[];
}
// ---------------------------------------------------------------------------
// Qdrant payload types
// ---------------------------------------------------------------------------
export interface SourceChunkPayload {
source_document_id: string;
chunk_index: number;
text: string;
theme_id: string | null;
topic_id: string | null;
format: DocumentFormat;
}
export interface TopicSummaryPayload {
topic_id: string;
theme_id: string;
title: string;
text: string;
}
// ---------------------------------------------------------------------------
// Job system
// ---------------------------------------------------------------------------
export type JobStatus =
| 'queued'
| 'extracting'
| 'chunking'
| 'structuring'
| 'writing'
| 'embedding'
| 'done'
| 'failed';
export interface JobProgress {
chunksTotal: number;
chunksEmbedded: number;
themesFound: number;
topicsFound: number;
}
export interface Job {
id: string;
documentId: string;
filename: string;
format: DocumentFormat;
filePath: string;
status: JobStatus;
progress: JobProgress;
error: string | null;
createdAt: Date;
updatedAt: Date;
}
// ---------------------------------------------------------------------------
// API schemas (Zod — validates external input)
// ---------------------------------------------------------------------------
export const IngestBodySchema = z.object({
documentId: z.string().min(1),
filename: z.string().min(1),
format: z.enum(['pdf', 'md', 'txt']),
filePath: z.string().min(1),
});
export type IngestBody = z.infer<typeof IngestBodySchema>;

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

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