- Introduced gamification service spec detailing responsibilities, API surface, XP calculation, levels, streaks, badges, milestone cards, and heatmap data. - Added generation service spec outlining the process for generating micro learning content, including API endpoints, AI call configuration, prompt strategies, and error handling. - Created R42 chat service spec covering chatbot interactions, retrieval pipeline, prompt construction, response generation, and stateless design principles.
337 lines
8.6 KiB
Markdown
337 lines
8.6 KiB
Markdown
# R42 chat service spec
|
|
|
|
## Responsibility
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## Service location
|
|
|
|
```
|
|
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
|
|
```
|
|
|
|
---
|
|
|
|
## API surface
|
|
|
|
### POST /chat
|
|
|
|
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"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Retrieval pipeline
|
|
|
|
### Step 1 — Embed query
|
|
|
|
Embed the employee query using OpenAI text-embedding-3-small (1536 dimensions).
|
|
Same model used during ingestion — vectors are comparable.
|
|
|
|
```typescript
|
|
const queryVector = await embedText(query) // float[1536]
|
|
```
|
|
|
|
---
|
|
|
|
### Step 2 — Qdrant search
|
|
|
|
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
|