# 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
