Files
learning-platform/docs/ingestion-spec.md
RaymondVerhoef dda20612e9 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.
2026-05-23 15:38:09 +02:00

484 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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