# Technical Requirements Document — Milestone 1
## AI-Guided Cardio Thoracic Surgery Patient Intake System

**Version:** 3.0
**Date:** 2026-05-12
**Status:** Draft

> Read alongside [technical-architecture.md](technical-architecture.md) and [db-architecture.md](db-architecture.md).

---

## 1. API Contracts — FastAPI

All endpoints require `Authorization: Bearer {jwt}` except `POST /auth/login`.
All responses are `Content-Type: application/json`.
All error responses follow: `{ "error": "ERROR_CODE", "message": "human readable" }`.

---

### POST `/auth/login`

**Request**
```json
{
  "email": "staff@hospital.com",
  "pin": "123456"
}
```

**Validation**
- `email`: valid email format, required
- `pin`: exactly 6 digits, numeric only, required

**Responses**

| Status | Condition | Body |
|---|---|---|
| 200 | Login successful | `{ accessToken, staff: { id, name, role, department: { id, name } } }` |
| 401 | Wrong email or PIN | `{ error: "INVALID_CREDENTIALS" }` |
| 403 | Account locked | `{ error: "ACCOUNT_LOCKED", lockedAt }` |
| 403 | Account inactive | `{ error: "ACCOUNT_INACTIVE" }` |
| 503 | Max 5 concurrent sessions reached | `{ error: "MAX_SESSIONS_REACHED" }` |

**Side effects on success**
- `staff.failed_pin_attempts` reset to 0
- `staff:{staffId}:session` key created in Redis (TTL 30min)
- Concurrent session count checked via count of `staff:*:session` keys

---

### POST `/auth/logout`

**Request:** No body

**Responses**

| Status | Body |
|---|---|
| 200 | `{ success: true }` |

**Side effects**
- `staff:{staffId}:session` deleted from Redis
- `patient_lock:{deptId}:{dailyId}` released if held by this staff

---

### POST `/staff`

Admin creates a new staff account. Role is stored but not enforced in M1.

**Request**
```json
{
  "name": "Dr. Priya Sharma",
  "role": "MEDICAL_STAFF",
  "email": "priya@hospital.com",
  "pin": "456789",
  "departmentId": "uuid"
}
```

**Validation**
- `name`: non-empty, max 100 chars
- `role`: one of `MEDICAL_STAFF`, `CARDIOLOGIST`, `ADMIN`
- `email`: valid email format, unique
- `pin`: exactly 6 digits, numeric only
- `departmentId`: valid UUID, must exist in `departments` table

**Responses**

| Status | Body |
|---|---|
| 201 | `{ id, name, role, email, departmentId, createdAt }` |
| 409 | `{ error: "EMAIL_ALREADY_EXISTS" }` |
| 404 | `{ error: "DEPARTMENT_NOT_FOUND" }` |

---

### PATCH `/staff/{id}`

Update staff details, reset PIN, deactivate account, or unlock account.

**Request** (all fields optional)
```json
{
  "name": "Dr. Priya S.",
  "pin": "789012",
  "isActive": false,
  "unlock": true
}
```

**Validation**
- `pin`: exactly 6 digits if provided
- `isActive`: boolean
- `unlock`: boolean — if true, sets `locked_at = NULL` and `failed_pin_attempts = 0`

**Responses**

| Status | Body |
|---|---|
| 200 | `{ id, name, role, email, isActive, updatedAt }` |
| 404 | `{ error: "STAFF_NOT_FOUND" }` |

**Side effects on deactivation (`isActive: false`)**
- Any active session lock held by this staff released from Redis

---

### GET `/sessions`

Returns all intake sessions for the logged-in user's department — shown on the intake list screen.

**Responses**

| Status | Body |
|---|---|
| 200 | `{ sessions: [{ sessionId, patient: { dailyId, name, age, gender }, status, createdAt, department }] }` |

---

### POST `/sessions`

Start a new intake session or resume an existing one for the same patient.

**Request**
```json
{
  "dailyPatientId": "P001",
  "name": "Rajan Kumar",
  "age": 54,
  "gender": "Male"
}
```

**Validation**
- `dailyPatientId`: non-empty, max 50 chars, trimmed
- `name`: non-empty, max 100 chars
- `age`: integer, 1–120
- `gender`: non-empty, max 20 chars

**Logic**
1. Check Redis `patient:{deptId}:{dailyPatientId}:sessionId`
2. **If exists** → check `patient_lock:{deptId}:{dailyPatientId}`
   - Lock held → return 409
   - Lock free → acquire lock, add contributor if new staff, load all responses
3. **If not exists** → create patient + session in PostgreSQL, set Redis keys, call Claude (internal) for first question (returns question overview + question 1)

**Responses**

| Status | Condition | Body |
|---|---|---|
| 201 | New session created | `{ sessionId, status: "CREATED", patient, intakeHistory: [], firstQuestion: { id, text } }` |
| 200 | Existing session resumed | `{ sessionId, status: "RESUMED", patient, intakeHistory, responses, resumeFromQuestion: { id, text } }` |
| 409 | Session lock held by another staff | `{ error: "SESSION_IN_USE", currentWriter: { name, joinedAt } }` |

**`intakeHistory` structure**
```json
[
  { "staffName": "Ananya R.", "role": "MEDICAL_STAFF", "joinedAt": "2026-05-12T10:00:00Z", "sequenceNumber": 1 },
  { "staffName": "Ravi M.", "role": "MEDICAL_STAFF", "joinedAt": "2026-05-12T10:22:00Z", "sequenceNumber": 2 }
]
```

---

### GET `/sessions/{id}`

Get full session state, conversation history, and intake history log.

**Responses**

| Status | Body |
|---|---|
| 200 | `{ session: { id, status, startedAt, completedAt }, patient, intakeHistory, responses }` |
| 404 | `{ error: "SESSION_NOT_FOUND" }` |

---

### POST `/sessions/{id}/respond`

Submit a transcribed answer for the current question. Transcription has already happened in the browser via Web Speech API — only text is sent to the backend.

**Request**
```json
{
  "questionId": "q_chest_pain",
  "text": "Sharp chest pain for two days",
  "inputType": "VOICE",
  "inputLanguage": "ENGLISH"
}
```

**Validation**
- `questionId`: optional; null for LLM-generated follow-up questions that have no protocol ID
- `text`: non-empty, max 2000 chars, required
- `inputType`: one of `VOICE`, `TEXT`
- `inputLanguage`: one of `ENGLISH`, `HINDI`, `TELUGU`

**Processing order**
1. Verify session is ACTIVE and staff holds the lock
2. Refresh `patient_lock` and `staff:{staffId}:session` TTL → 30min
3. INSERT into `question_responses` with `transcribed_text = text`, `input_type`, `input_language`
4. Call Claude (internal function) with full conversation history + question bank
5. If `intakeComplete: true` → scan `question_responses`, set `is_flagged = true` on non-substantive answers
6. Return response

**Responses**

| Status | Condition | Body |
|---|---|---|
| 200 | Next question ready | `{ nextQuestion: { id, text }, intakeComplete: false }` |
| 200 | Intake complete | `{ nextQuestion: null, intakeComplete: true, flaggedQuestions: [{ questionText, sequenceNumber }] }` |
| 423 | Staff no longer holds session lock | `{ error: "SESSION_LOCK_LOST" }` |
| 409 | Session already completed | `{ error: "SESSION_COMPLETED" }` |

---

### POST `/sessions/{id}/complete`

Mark session complete and generate summary. Synchronous — client waits up to 30 seconds for summary.

**Request:** No body

**Processing**
1. Verify session is ACTIVE
2. UPDATE `intake_sessions` → `status = COMPLETED`, `completed_at = now()`
3. Load all `question_responses` for session
4. Call Claude summary function (internal) with conversation history
5. INSERT into `patient_summaries` (or UPDATE if record already exists from a previous complete)
6. Release patient lock and staff session slot

**Responses**

| Status | Condition | Body |
|---|---|---|
| 200 | Summary generated | `{ summaryId, summaryText, generatedAt, latencyMs }` |
| 400 | Session not in ACTIVE state | `{ error: "SESSION_NOT_ACTIVE" }` |
| 408 | Claude summary timeout (>30s) | `{ error: "SUMMARY_TIMEOUT" }` |

**Side effects**
- `intake_sessions.status` → `COMPLETED`, `completed_at` → now()
- `patient_lock:{deptId}:{dailyId}` released
- `staff:{staffId}:session` key deleted

---

### GET `/sessions/{id}/summary`

Fetch an already-generated summary.

**Responses**

| Status | Body |
|---|---|
| 200 | `{ summaryId, summaryText, generatedAt, latencyMs }` |
| 404 | `{ error: "SUMMARY_NOT_FOUND" }` |

---

### POST `/sessions/{id}/ask-more`

Re-open a completed session for continued Q&A. Full conversation history is passed to Claude so it picks up exactly where it left off.

**Request:** No body

**Processing**
1. Verify session is COMPLETED
2. SCAN Redis `staff:*:session` — if count ≥ 5, return 503
3. UPDATE `intake_sessions` → `status = ACTIVE`, `completed_at = NULL`
4. SET `patient_lock:{deptId}:{dailyId}` = staffId TTL 30min
5. SET `staff:{staffId}:session` TTL 30min
6. INSERT into `session_contributors` if this staff has no existing row for the session
7. Load all `question_responses` for session
8. Call Claude (internal function) with full conversation history
9. Return next question

**Responses**

| Status | Condition | Body |
|---|---|---|
| 200 | Session re-opened | `{ nextQuestion: { id, text }, conversationHistory }` |
| 400 | Session is not COMPLETED | `{ error: "SESSION_NOT_COMPLETED" }` |
| 409 | Session lock held by another staff | `{ error: "SESSION_IN_USE", currentWriter: { name, joinedAt } }` |
| 503 | Max 5 concurrent sessions reached | `{ error: "MAX_SESSIONS_REACHED" }` |

---

## 2. Internal Claude Functions (FastAPI)

These are internal Python service functions within FastAPI — not HTTP endpoints. They are called directly from the route handlers above.

---

### `get_next_question(system_prompt, question_bank, conversation_history)`

**Input**
```python
system_prompt: str        # from departments.llm_system_prompt
question_bank: list       # from protocols.question_bank
conversation_history: list  # all question_responses so far
```

`conversation_history` item structure:
```json
{
  "questionId": "q_chief_complaint",
  "questionText": "What brings the patient in today?",
  "transcribedText": "Chest pain and breathlessness",
  "inputType": "VOICE",
  "isFlagged": false
}
```

- `conversation_history`: empty list `[]` on first call — Claude returns question overview + first question
- `questionId`: null in history entries for LLM-generated follow-up questions

**Returns**
```json
{
  "nextQuestion": { "id": "q_chest_pain_duration", "text": "How long has the chest pain been present?" },
  "intakeComplete": false
}
```

When intake is complete:
```json
{
  "nextQuestion": null,
  "intakeComplete": true
}
```

---

### `generate_summary(summary_prompt, patient_info, conversation_history)`

**Input**
```python
summary_prompt: str       # from departments.llm_summary_prompt
patient_info: dict        # { name, age, gender, dailyId }
conversation_history: list  # all question_responses for the session
```

**Returns**
```json
{
  "summaryText": "PATIENT CASE SUMMARY\n...",
  "latencyMs": 18400
}
```

---

## 3. Validation Rules

| Field | Rule |
|---|---|
| PIN | Exactly 6 digits, numeric only (`/^\d{6}$/`) |
| Email | Valid RFC 5322 email format |
| Age | Integer, 1–120 |
| Gender | Non-empty string, max 20 chars |
| Daily Patient ID | Non-empty, max 50 chars, trimmed (leading/trailing whitespace removed) |
| Staff name | Non-empty, max 100 chars |
| Role | One of: `MEDICAL_STAFF`, `CARDIOLOGIST`, `ADMIN` |
| Input text (response) | Non-empty, max 2000 chars |
| Input type | One of: `VOICE`, `TEXT` |
| Input language | One of: `ENGLISH`, `HINDI`, `TELUGU` |

---

## 4. Error Handling Strategy

### FastAPI
- Global exception handler catches all unhandled errors — never exposes stack traces to client
- Database constraint violations mapped to specific 4xx responses (unique violation → 409)
- Claude API timeout (>30s for summary, >5s for next question) → return `408 SUMMARY_TIMEOUT` or `504 CLAUDE_TIMEOUT`
- Claude API error → return `502 UPSTREAM_ERROR`
- All 5xx errors log the full stack trace internally

### Client (React PWA)
- Web Speech API failure → show text input fallback automatically
- `423 SESSION_LOCK_LOST` → alert staff that session lock expired, prompt re-entry of patient ID
- `503 MAX_SESSIONS_REACHED` → show message that max sessions reached
- `502 / 504` → show "system error, please try again" with retry option

---

## 5. Authentication Details

| Property | Value |
|---|---|
| Algorithm | HS256 |
| JWT payload | `{ sub: staffId, role, departmentId, iat, exp }` |
| Expiry | 30 minutes from issue |
| Storage | Memory only (React state) — cleared on tab close |
| Refresh | No refresh tokens in M1 — staff re-logs in after expiry |
| PIN storage | bcrypt, rounds: 12 |
| Lockout | 5 consecutive failures → `locked_at` set; Admin resets via `PATCH /staff/{id}` with `unlock: true` |

---

## 6. Concurrency Logic

```
On login:
  count = SCAN Redis for keys matching staff:*:session
  if count >= 5 → return 503 MAX_SESSIONS_REACHED
  else → issue JWT, SET staff:{staffId}:session TTL 30min

On /respond:
  EXPIRE staff:{staffId}:session 1800
  EXPIRE patient_lock:{deptId}:{dailyId} 1800

On /ask-more:
  count = SCAN Redis for keys matching staff:*:session
  if count >= 5 → return 503 MAX_SESSIONS_REACHED
  SET patient_lock:{deptId}:{dailyId} = staffId TTL 1800
  SET staff:{staffId}:session TTL 1800

On logout or /sessions/{id}/complete:
  DEL staff:{staffId}:session
  DEL patient_lock:{deptId}:{dailyId}

On JWT expiry (inactivity):
  staff:{staffId}:session auto-expires → slot freed automatically
  patient_lock auto-expires → session available for resumption
```

---

## 7. Data Retention Wipe

```
FastAPI APScheduler task — runs every hour:

DELETE FROM patients
WHERE expires_at < NOW();

→ CASCADE deletes intake_sessions, session_contributors,
  question_responses, patient_summaries automatically

Redis keys self-expire via TTL — no manual cleanup needed
```

---

## 8. SLA Targets per Endpoint

| Endpoint | SLA | Bottleneck |
|---|---|---|
| `POST /auth/login` | ≤ 500ms | bcrypt (12 rounds ~200ms) + DB lookup |
| `GET /sessions` | ≤ 500ms | DB read only |
| `POST /sessions` | ≤ 1s (new), ≤ 2s (resume) | DB insert + Claude first question call |
| `POST /sessions/{id}/respond` | ≤ 2s | Claude next question only — no server-side transcription |
| `POST /sessions/{id}/complete` | ≤ 30s | Claude summary generation |
| `GET /sessions/{id}/summary` | ≤ 200ms | DB read only |
| `POST /sessions/{id}/ask-more` | ≤ 2s | DB reads + Claude first follow-up call |
| `POST /staff` | ≤ 500ms | bcrypt hash + DB insert |

---

## 9. Environment Variables

### FastAPI
| Variable | Description | Example |
|---|---|---|
| `DATABASE_URL` | PostgreSQL connection string | `postgresql+asyncpg://user:pass@postgres:5432/nims` |
| `REDIS_URL` | Redis connection string | `redis://redis:6379` |
| `JWT_SECRET` | Secret for signing JWTs | 64-char random string |
| `JWT_EXPIRY_MINUTES` | JWT lifetime in minutes | `30` |
| `MAX_CONCURRENT_SESSIONS` | Max simultaneous staff sessions | `5` |
| `SESSION_LOCK_TTL_SECONDS` | Redis lock TTL in seconds | `1800` |
| `DATA_RETENTION_HOURS` | Patient data lifetime in hours | `48` |
| `BCRYPT_ROUNDS` | bcrypt work factor | `12` |
| `ANTHROPIC_API_KEY` | Claude API key | `sk-ant-...` |
| `CLAUDE_MODEL` | Claude model ID | `claude-sonnet-4-6` |
| `CLAUDE_TIMEOUT_SECONDS` | Max wait for Claude response | `30` |

---

## 10. Third-Party Integration Specs

### Web Speech API (Browser)
- Built into Chrome/Chromium — no API key or external service needed
- Activated via `window.SpeechRecognition` or `window.webkitSpeechRecognition`
- Language set per response via `recognition.lang` (e.g., `en-IN`, `hi-IN`, `te-IN`)
- Output: transcribed text string in the language spoken
- Staff may speak in English, Hindi, or Telugu — Claude's system prompt instructs it to understand all three and always respond in English; no translation step needed in the frontend
- Fallback: if Web Speech API is unavailable or fails, render the text input field immediately

### Claude API (Anthropic)
- SDK: `anthropic` Python SDK (in FastAPI)
- Model: `claude-sonnet-4-6`
- Called as internal Python functions — not separate HTTP endpoints
- Usage:
  - Intake next question: `max_tokens: 200`, system prompt + conversation history
  - Summary generation: `max_tokens: 1500`, system prompt + full conversation
- Timeout: 30 seconds for summary, 5 seconds for next question
