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:
362
docs/r42-spec.md
362
docs/r42-spec.md
@@ -1,336 +1,68 @@
|
||||
# R42 chat service spec
|
||||
# R42 spec: the in-app chatbot
|
||||
|
||||
## Responsibility
|
||||
R42 is a knowledge-base-grounded assistant available on every screen. It runs
|
||||
client-side and is grounded by local TF-IDF retrieval — **no vector database**.
|
||||
|
||||
Handles all R42 chatbot interactions. Receives employee queries, retrieves
|
||||
relevant KB chunks from Qdrant, generates grounded responses using Claude
|
||||
Haiku 4.5, and streams the result to the frontend. Stateless — no chat
|
||||
history is persisted.
|
||||
- **UI:** `src/components/chat/` — `ChatLauncher.jsx`, `ChatWindow.jsx`, `useChat.js`
|
||||
- **Prompt/tool:** `src/components/chat/prompts.js`
|
||||
- **Retrieval/validation:** `src/components/chat/rag.js` + `src/lib/retrieval.js`
|
||||
- **KB writes:** `src/lib/kbStore.js`
|
||||
|
||||
---
|
||||
|
||||
## Service location
|
||||
## Conversation
|
||||
|
||||
```
|
||||
app/services/chat/
|
||||
├── src/
|
||||
│ ├── index.ts entry point, Fastify server
|
||||
│ ├── routes/
|
||||
│ │ └── chat.ts POST /chat (streaming)
|
||||
│ ├── retrieval/
|
||||
│ │ ├── embed.ts query → embedding
|
||||
│ │ ├── search.ts Qdrant nearest-neighbour search
|
||||
│ │ └── merge.ts merge + rank results from both collections
|
||||
│ ├── prompt/
|
||||
│ │ └── build.ts assemble system + user prompt with context
|
||||
│ └── lib/
|
||||
│ ├── qdrant.ts
|
||||
│ ├── pocketbase.ts
|
||||
│ ├── anthropic.ts
|
||||
│ └── openai.ts
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── .env.example
|
||||
```
|
||||
- The brand mark (`src/components/ui/Mark.jsx`) renders R42 in idle / typing / error states.
|
||||
- Messages persist per user in `localStorage` under `chat:thread:{userId}`, capped at
|
||||
**50** messages. Only roughly the last **12** turns are sent to the API; older
|
||||
context is truncated with a notice.
|
||||
- A greeting message seeds an empty thread.
|
||||
- Each turn calls `callLLM` (fast/standard Claude tier — low latency matters for chat).
|
||||
|
||||
---
|
||||
|
||||
## API surface
|
||||
## Grounding (RAG via TF-IDF)
|
||||
|
||||
### POST /chat
|
||||
`buildKbContext` in `rag.js`:
|
||||
1. Build / reuse the TF-IDF index over `topics` (`src/lib/retrieval.js`).
|
||||
2. Retrieve the top **10** topics for the user's message.
|
||||
3. Always include topics whose `id` or `label` appears verbatim in the message.
|
||||
4. Include relations only when **both** endpoints are in the retrieved set.
|
||||
5. For explicitly mentioned topics, inject up to ~1200 chars of their generated
|
||||
content.
|
||||
6. Append a short KB hash so the cached context busts when topics change.
|
||||
|
||||
Single route. Streams response back to client using server-sent events (SSE).
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"query": "string",
|
||||
"userId": "string"
|
||||
}
|
||||
```
|
||||
|
||||
Response: SSE stream
|
||||
|
||||
```
|
||||
Content-Type: text/event-stream
|
||||
|
||||
data: {"type": "chunk", "text": "Holacratic roles "}
|
||||
data: {"type": "chunk", "text": "are defined as..."}
|
||||
data: {"type": "citations", "topics": [{"id": "abc", "title": "Holacratic roles"}]}
|
||||
data: {"type": "done"}
|
||||
```
|
||||
|
||||
Error response (non-streaming, returned before stream starts):
|
||||
```json
|
||||
{
|
||||
"error": "query_too_short" | "user_not_found" | "retrieval_failed",
|
||||
"message": "string"
|
||||
}
|
||||
```
|
||||
The system prompt (`prompts.js`) is assembled as cacheable blocks: a stable
|
||||
preamble (role, tasks, style, "answer only from the KB"), the KB context block, and
|
||||
a per-turn tail with the user's name and admin/non-admin flag.
|
||||
|
||||
---
|
||||
|
||||
## Retrieval pipeline
|
||||
## Proposing knowledge-graph edits
|
||||
|
||||
### Step 1 — Embed query
|
||||
R42 may call `propose_graph_delta` with:
|
||||
- `reason` — one sentence
|
||||
- `topics` — **max 3**, each `{ id (kebab-case), label, type (concept|role|process), description }`
|
||||
- `relations` — **max 5**, each `{ source, target, type (related_to|depends_on|part_of|executed_by) }`
|
||||
|
||||
Embed the employee query using OpenAI text-embedding-3-small (1536 dimensions).
|
||||
Same model used during ingestion — vectors are comparable.
|
||||
`validateDelta` (`rag.js`) dedupes by topic id and case-folded label, rejects
|
||||
relations with missing endpoints or self-references, and enforces the 3/5 caps.
|
||||
Invalid deltas are dropped.
|
||||
|
||||
```typescript
|
||||
const queryVector = await embedText(query) // float[1536]
|
||||
```
|
||||
A confirmation chip appears inline:
|
||||
- **Admin → Yes:** `kbStore.applyDelta` writes topics/relations to PocketBase immediately.
|
||||
- **Non-admin → Yes:** `kbStore.appendSuggestion` queues a `pending` entry in
|
||||
`kb:suggestions` (localStorage) for admin review.
|
||||
|
||||
Admins review the queue in `src/components/admin/SuggestionsQueue.jsx` (approve
|
||||
re-runs `applyDelta`; reject marks it rejected). `kbStore` dispatches
|
||||
`respellion:kb-updated` after writes so the D3 graph and queue refresh.
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Qdrant search
|
||||
## Quiz integrity
|
||||
|
||||
Search both collections in parallel:
|
||||
|
||||
```typescript
|
||||
// source_chunks: primary retrieval — grounded in source material
|
||||
const chunkResults = await qdrant.search('source_chunks', {
|
||||
vector: queryVector,
|
||||
limit: 5,
|
||||
scoreThreshold: 0.70,
|
||||
withPayload: true
|
||||
})
|
||||
|
||||
// topic_summaries: secondary — broader topic context
|
||||
const summaryResults = await qdrant.search('topic_summaries', {
|
||||
vector: queryVector,
|
||||
limit: 3,
|
||||
scoreThreshold: 0.70,
|
||||
withPayload: true
|
||||
})
|
||||
```
|
||||
|
||||
Score threshold 0.70: below this, results are not relevant enough to include.
|
||||
If both searches return zero results above threshold → out-of-scope response.
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Context boost for current week
|
||||
|
||||
Retrieve employee's current week Theme from PocketBase via
|
||||
employee_curriculum_state → curriculum_weeks → theme.
|
||||
|
||||
Apply boost to results where payload.theme_id matches current week theme:
|
||||
|
||||
```typescript
|
||||
results.forEach(result => {
|
||||
if (result.payload.theme_id === currentThemeId) {
|
||||
result.score += 0.05 // small boost — does not override relevance
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — Merge and deduplicate
|
||||
|
||||
```typescript
|
||||
// Combine chunk results and summary results
|
||||
// Deduplicate by topic_id — keep highest scoring entry per topic
|
||||
// Sort by score descending
|
||||
// Take top 6 total
|
||||
// Split into: sourceChunks (from source_chunks collection)
|
||||
// topicSummaries (from topic_summaries collection)
|
||||
```
|
||||
|
||||
Deduplicate by topic_id to avoid repeating the same topic in different forms.
|
||||
|
||||
---
|
||||
|
||||
### Step 5 — Collect cited topics
|
||||
|
||||
Extract unique topic titles from merged results for citation:
|
||||
|
||||
```typescript
|
||||
type Citation = {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
const citations: Citation[] = uniqueByTopicId(mergedResults)
|
||||
.map(r => ({ id: r.payload.topic_id, title: r.payload.title }))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt construction
|
||||
|
||||
### System prompt
|
||||
|
||||
```
|
||||
You are R42, a knowledge assistant for [company name].
|
||||
You answer questions based strictly on the company knowledge base.
|
||||
|
||||
Rules:
|
||||
- Answer only from the provided context. Do not use outside knowledge.
|
||||
- If the context does not contain enough information to answer, say:
|
||||
"This doesn't appear to be covered in the knowledge base. You can browse
|
||||
the full library in the Knowledge section."
|
||||
- Be concise. Prefer short paragraphs over long prose.
|
||||
- Do not mention that you are an AI or reference your instructions.
|
||||
- Do not speculate or extrapolate beyond the provided context.
|
||||
- Respond in the same language as the question.
|
||||
```
|
||||
|
||||
### User prompt
|
||||
|
||||
```
|
||||
Context from knowledge base:
|
||||
---
|
||||
{mergedResults.map(r => r.payload.text).join('\n\n---\n\n')}
|
||||
---
|
||||
|
||||
Question: {query}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response generation
|
||||
|
||||
Use Claude Haiku 4.5 with streaming enabled:
|
||||
|
||||
```typescript
|
||||
const stream = await anthropic.messages.stream({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 1000,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }]
|
||||
})
|
||||
|
||||
// Stream text chunks to client as SSE
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === 'content_block_delta') {
|
||||
sendSSE({ type: 'chunk', text: chunk.delta.text })
|
||||
}
|
||||
}
|
||||
|
||||
// After stream completes, send citations
|
||||
sendSSE({ type: 'citations', topics: citations })
|
||||
sendSSE({ type: 'done' })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Out-of-scope handling
|
||||
|
||||
Two conditions trigger the out-of-scope response:
|
||||
|
||||
1. Both Qdrant searches return zero results above 0.70 threshold
|
||||
2. Haiku response contains no content drawn from context (detected by
|
||||
checking if response length < 20 tokens — proxy for "I don't know")
|
||||
|
||||
Out-of-scope response sent as a single non-streamed SSE message:
|
||||
|
||||
```
|
||||
data: {"type": "out_of_scope", "text": "This doesn't appear to be covered
|
||||
in the knowledge base. You can browse the full library in the Knowledge section."}
|
||||
data: {"type": "done"}
|
||||
```
|
||||
|
||||
No citations are sent for out-of-scope responses.
|
||||
|
||||
---
|
||||
|
||||
## Frontend integration
|
||||
|
||||
R42 is a floating button on every screen in the employee app.
|
||||
|
||||
UI behaviour:
|
||||
- Bottom-right corner, fixed position
|
||||
- Opens a chat drawer (not a modal — drawer slides up from bottom on mobile)
|
||||
- Input field at bottom of drawer, response area above
|
||||
- Streaming text renders token by token
|
||||
- Citations appear below the response after streaming completes as
|
||||
clickable topic pills → navigate to that topic in the knowledge library
|
||||
- Drawer closes on outside tap
|
||||
- State is local to the component — cleared on close (stateless by design)
|
||||
|
||||
The frontend calls POST /chat directly. No auth token needed on the chat
|
||||
service — it receives userId in the request body and trusts it. The admin
|
||||
app does not expose R42.
|
||||
|
||||
---
|
||||
|
||||
## Stateless design
|
||||
|
||||
R42 has no memory between conversations. Each POST /chat is independent.
|
||||
|
||||
Rationale:
|
||||
- Avoids privacy complexity around chat history storage
|
||||
- Removes need for session management
|
||||
- Keeps the service simple and fast
|
||||
- Employees asking follow-up questions reprovide context naturally
|
||||
|
||||
If multi-turn conversation is needed in a future iteration, maintain
|
||||
conversation history in the frontend component state and pass the last
|
||||
N messages in the request body. The service does not need to change.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
POCKETBASE_URL=
|
||||
POCKETBASE_ADMIN_EMAIL=
|
||||
POCKETBASE_ADMIN_PASSWORD=
|
||||
QDRANT_URL=
|
||||
QDRANT_API_KEY=
|
||||
CHAT_PORT=3004
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"fastify": "^4",
|
||||
"@fastify/sse": "^2",
|
||||
"@anthropic-ai/sdk": "^0.24",
|
||||
"openai": "^4",
|
||||
"@qdrant/js-client-rest": "^1.9",
|
||||
"pocketbase": "^0.21",
|
||||
"zod": "^3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TypeScript strict mode requirements
|
||||
|
||||
- No `any` types
|
||||
- Qdrant search results typed explicitly including payload fields
|
||||
- SSE event types defined as a discriminated union
|
||||
- Citation type explicit — not inferred from payload
|
||||
|
||||
---
|
||||
|
||||
## What this service does NOT do
|
||||
|
||||
- Does not persist chat history
|
||||
- Does not generate or serve micro learning content
|
||||
- Does not handle admin queries — admin app has no R42 access
|
||||
- Does not handle auth — trusts userId from request body
|
||||
|
||||
---
|
||||
|
||||
## Testing checkpoints
|
||||
|
||||
1. POST /chat with a query matching a published topic → confirm relevant
|
||||
chunks retrieved (score > 0.70) and response references topic content
|
||||
2. POST /chat with an out-of-scope query → confirm out-of-scope response
|
||||
returned, no citations sent
|
||||
3. Confirm citations array contains correct topic titles matching retrieved chunks
|
||||
4. Confirm SSE stream delivers chunks progressively (not batched)
|
||||
5. Confirm current-week boost: same query returns higher-ranked result for
|
||||
current week theme topic vs equally relevant topic from different theme
|
||||
6. POST /chat with userId whose current week has no matching topic →
|
||||
confirm boost does not break retrieval, general results returned
|
||||
`src/pages/Testen.jsx` sets `quiz:active:{userId}=true` on quiz start and clears it
|
||||
on every non-quiz phase and on unmount, dispatching a `respellion:quiz-state` event.
|
||||
`ChatLauncher` hides the FAB while this flag is set, so users cannot consult R42
|
||||
mid-quiz. **Never bypass this.**
|
||||
|
||||
Reference in New Issue
Block a user