# 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 (1–5) to each Topic. 1 = introductory, 5 = advanced - Draft a body for each Topic (2–4 paragraphs) based on the source chunks. - Draft a description for each Theme (1–2 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 // 1–5 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 2–3: 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