Add comprehensive documentation for key organizational aspects
- Introduced "Pension Scheme & Benefits" detailing secondary employment benefits and pension specifics. - Created "Roles & Accountabilities" outlining the Holacracy role structure and responsibilities within Respellion. - Added "Security" section covering GDPR compliance and workplace safety protocols. - Established "Spending and Contracting" policy detailing expense categories and submission processes. - Documented "Who We Are" to define Respellion's identity, services, and operational model under Holacracy and ISO 9001.
This commit is contained in:
@@ -1,483 +1,72 @@
|
||||
# Ingestion service spec
|
||||
# Ingestion spec: source documents → knowledge graph
|
||||
|
||||
## Responsibility
|
||||
Turns admin-uploaded text into `topics` and `relations` using Claude. Runs
|
||||
entirely client-side; there is no ingestion service.
|
||||
|
||||
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.
|
||||
- **UI:** `src/components/admin/UploadZone.jsx` (Admin → Sources tab)
|
||||
- **Pipeline:** `src/lib/extractionPipeline.js`
|
||||
- **Tool/schema:** `emit_knowledge_graph` (`src/lib/llmTools.js`) validated by
|
||||
`extractionResultSchema` (`src/lib/llmSchemas.js`)
|
||||
|
||||
---
|
||||
|
||||
## Service location
|
||||
## Upload
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
- Accepted formats: **`.txt` and `.md`**, max **5 MB** per file.
|
||||
- Drag-and-drop or click-to-browse. Unsupported files are skipped with a toast.
|
||||
- The queue tracks each file: `pending → processing → done / failed / cancelled`.
|
||||
- Progress is polled every ~2s from the `sources.progress` field
|
||||
(`{ current, total, message }`), shown as "Chunk N/total".
|
||||
- **Orphan detection:** sources stuck in `processing` for >5 minutes (e.g. a closed
|
||||
tab) can be marked failed or deleted.
|
||||
|
||||
---
|
||||
|
||||
## API surface
|
||||
## Pipeline: `processSourceText(textContent, sourceName, { signal })`
|
||||
|
||||
### POST /ingest
|
||||
1. **Create source record** with `status='processing'`.
|
||||
2. **Chunk** the text (`chunkText`): target ~8000 chars per chunk with ~800 chars
|
||||
overlap, splitting on sentence/paragraph boundaries (hard-splitting oversized
|
||||
sentences). Overlap preserves cross-boundary context.
|
||||
3. **Known-topics hint** (`buildKnownIdsHint`): up to the 200 most recent existing
|
||||
topics are listed so the model reuses existing ids instead of duplicating them.
|
||||
4. **Per-chunk extraction** via `callLLM`:
|
||||
- tier `standard`, `maxTokens: 8192`, `timeoutMs: 180_000`
|
||||
- forced `toolChoice` on `emit_knowledge_graph`
|
||||
- rate-limited (~20 req/min, burst 2) to protect quota across many chunks
|
||||
- system prompt instructs: ≤15 topics per chunk; topic `type` ∈
|
||||
{`concept`, `role`, `process`}; `learning_relevance` ∈
|
||||
{`core`, `standard`, `peripheral`, `exclude`}; relation `type` ∈
|
||||
{`related_to`, `depends_on`, `part_of`, `executed_by`}
|
||||
5. **Update progress** before each chunk.
|
||||
6. **Merge** (`mergeKnowledgeGraph`): topics keyed by `id` (new data updates
|
||||
existing, but `learning_relevance` is preserved when `relevance_locked` is true);
|
||||
relations de-duplicated on `(source, target, type)`. Persisted via
|
||||
`db.saveTopics` / `db.saveRelations`.
|
||||
7. **Finalize:** `status='completed'`, or `failed` (error) / `cancelled` (abort).
|
||||
|
||||
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.
|
||||
Aborting via the `signal` stops the run and marks the source `cancelled`.
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
## Output shape (`emit_knowledge_graph`)
|
||||
|
||||
```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"
|
||||
}
|
||||
"topics": [ { "id", "label", "type", "description", "learning_relevance" } ],
|
||||
"relations":[ { "source", "target", "type" } ]
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
`theme`, `complexity_weight`, and `difficulty` are **not** set here — they are
|
||||
added later by the curriculum enrichment step (see `docs/curriculum-spec.md`).
|
||||
|
||||
---
|
||||
|
||||
## TypeScript strict mode requirements
|
||||
## Gotchas
|
||||
|
||||
- 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
|
||||
- If extraction logs a truncation (`LLMTruncatedError`, `stop_reason: max_tokens`),
|
||||
tighten the per-chunk topic cap before raising `max_tokens`.
|
||||
- A source already `completed` is not re-processed; delete it to force re-analysis.
|
||||
- There are no embeddings produced here — R42 retrieval is computed at query time
|
||||
with TF-IDF over `topics`.
|
||||
|
||||
Reference in New Issue
Block a user