Skip to content

muskan2408/MultiAgentChatApp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Dr. Squiggles 🐟

Crohn's Disease Patient Companion — Real-Time Chat Application

A full-stack chat prototype built for PharmaCorp's patient engagement platform. Patients on biologic therapy can ask common questions about their treatment — dosage timing, side effects, injection logistics, diet — and get instant, consistent answers, 24/7.


Table of Contents


System Diagram

┌─────────────────────────────────────────────────────┐
│                    EXPO CLIENT                       │
│                                                      │
│  ChatScreen                                          │
│    └── useChat (hook — owns all state)               │
│          ├── apiClient.ts  ──► GET /api/messages/    │
│          └── wsClient.ts   ──► WS  /ws/{session_id}  │
│                                                      │
│  Components (all memoized, props-only):              │
│    MessageBubble, TypingIndicator,                   │
│    ConnectionBanner, ChatInput                       │
└─────────────────┬───────────────────────────────────┘
                  │
     REST on load │ WebSocket for live messages
                  │
┌─────────────────▼───────────────────────────────────┐
│                  FASTAPI BACKEND                      │
│                                                      │
│  chat_router.py          ← transport only (HTTP/WS)  │
│      │                                               │
│      ▼                                               │
│  chat_service.py         ← orchestration, no I/O     │
│  chatbot_service.py      ← pure function, no I/O     │
│      │                                               │
│      ▼                                               │
│  message_repository.py   ← only layer touching DB    │
│      │                                               │
│      ▼                                               │
│  domain/models.py        ← SQLAlchemy ORM            │
│  domain/schemas.py       ← Pydantic in/out shapes    │
└─────────────────┬───────────────────────────────────┘
                  │  asyncpg
┌─────────────────▼───────────────────────────────────┐
│               POSTGRESQL 16 (Docker)                 │
│                                                      │
│  messages                                            │
│   ├── id          UUID PRIMARY KEY                   │
│   ├── session_id  VARCHAR(255)  [indexed]            │
│   ├── role        VARCHAR(10)   CHECK ('user'|'bot') │
│   ├── content     TEXT                               │
│   └── created_at  TIMESTAMPTZ  DEFAULT now()         │
└─────────────────────────────────────────────────────┘

Data Flow

App load — REST bootstrap

1. App opens
2. useChat calls apiClient.getMessages("default")
3. GET /api/messages/default → chat_router → chat_service → message_repository
4. If no history exists, a welcome message is seeded and persisted
5. Messages populate the FlatList
6. useChat immediately opens a WebSocket connection

The REST call and the WebSocket connection start in sequence — history first, then live. This avoids a race condition where a message could arrive over WebSocket before history loads.

Live message — WebSocket

1. User types → sendMessage("Can I drink coffee?")
2. Message appended to local state immediately (optimistic UI)
3. wsClient sends { "content": "Can I drink coffee?" }
4. Rate limit check — if < 0.5s since last message, error returned, message dropped
5. Server validates via Pydantic WSMessageIn (empty check, max 2000 chars)
6. Single DB session opens
     → user message persisted
     → server sends { "type": "typing" } → client shows animated dots
     → chatbot_service.respond() runs — pure function, no I/O
     → bot message persisted
     → session commits and closes
7. Server sends { "type": "message", ...bot_msg }
8. Client hides typing indicator → appends bot message

Both the user message and bot response are written in the same DB session. If anything fails between the two saves, neither commits — no orphaned user messages with missing replies.

WebSocket reconnect

Connection drops
  → status = "disconnected"
  → retry after 1s → 2s → 4s → ... (doubles each time, caps at 30s)
  → reconnected → status = "connected" → retry delay resets to 1s

Tech Stack

Layer Choice Why
Frontend React Native (Expo SDK 52) Required. Single codebase for iOS, Android, and web
Frontend language TypeScript Catches type mismatches between WS event shapes and component props at compile time
Backend Python + FastAPI Native async WebSocket support, Pydantic validation built in, no extra WS library needed
Database PostgreSQL 16 UUID primary keys, timezone-aware timestamps, reliable async driver (asyncpg)
ORM SQLAlchemy 2.0 async Type-safe mapped columns, async session management
Containerisation Docker Compose Single command to start DB and backend, reproducible across machines

Architectural Decisions

1. REST for bootstrap, WebSocket for live — not one or the other

The app makes one REST call on load to fetch conversation history, then switches to WebSocket for everything new. This is intentional.

REST is the right tool for history: it's straightforward to reason about, the response is complete and ordered, and it works reliably even before the WebSocket handshake completes. WebSocket is the right tool for live chat: low latency, bidirectional, no polling.

Using WebSocket for history too would mean handling ordering, completeness guarantees, and connection timing all at once. Not worth it.

2. Single DB session per WebSocket message — user and bot writes together

WebSocket connections are long-lived. A single shared SQLAlchemy async session held open for the duration of a connection would accumulate state, hold a connection pool slot indefinitely, and behave unpredictably under concurrent load.

Each incoming message opens one session, does both writes inside it, and closes:

async with SessionLocal() as db:
    service = ChatService(repository=MessageRepository(db), chatbot=DrSquigglesChatbotService())
    await service.handle_user_message(session_id, incoming.content)
    await send({"type": "typing"})   # WebSocket send — no DB session needed
    bot_msg = await service.generate_bot_response(session_id, incoming.content)
# session commits and closes — pool slot returned

The typing signal fires between the two saves because it is just a WebSocket send — it does not need a DB session. If the process dies after the user message saves but before the bot response commits, the session rolls back and neither write persists. No orphaned messages.

3. Per-connection rate limiting on the WebSocket endpoint

No rate limiting means a script — or an impatient user — can flood the endpoint with unlimited DB writes and bot responses. The router tracks the last message timestamp per connection using time.monotonic(). Messages arriving within 0.5s of the previous one get an error event back and are dropped — no DB write, no bot response generated. Per-connection means legitimate concurrent users are unaffected by each other.

4. Repository pattern — DB access in one place

All SQLAlchemy lives in message_repository.py. Nothing else imports it. ChatService depends on AbstractMessageRepository, not the PostgreSQL implementation.

The service layer is testable without a database (swap in an in-memory implementation), and the DB can be swapped without touching service logic. For a prototype these are not urgent — but the cost of doing it right here was minimal and the structure pays off fast when requirements change.

5. Chatbot as a pure function

DrSquigglesChatbotService.respond() takes a string and returns a string. No DB session, no network call, no file read. This means:

  • Trivially testable: call respond("I feel nauseous"), assert on the output
  • Adding a new topic means adding entries to _TOPIC_KEYWORDS and _RESPONSES — the respond() logic itself never changes
  • Swapping to an LLM-backed chatbot means implementing AbstractChatbotService and changing one line in the router. Nothing else changes.

6. Optimistic UI — no server echo of user messages

When the user sends a message, it is appended to local state immediately before the server responds. The server persists the user message but does not echo it back — it only sends the bot reply.

This avoids duplicates (the user message is already in local state), makes the app feel instant, and keeps the WebSocket protocol simple. If the server rejects the message, the optimistic entry is rolled back via the error event type.

7. Client state in a single hook

All state lives in useChat.ts: message list, connection status, typing indicator, retry logic. Components receive props only — no knowledge of WebSocket or REST. This makes the transport layer easy to reason about in isolation and components trivial to test.


Chatbot Persona

Dr. Squiggles is an overly enthusiastic goldfish MD who exclusively answers Crohn's Disease and biologic treatment questions. Every response uses goldfish analogies and disproportionate excitement.

3-layer response strategy

User message
      │
      ▼
Layer 1 — URGENCY CHECK (always first, character dropped entirely)
      │
      │  Keywords: "emergency", "severe pain", "bleeding", "hospital",
      │            "chest pain", "can't breathe", "faint", "ambulance"
      │
   match? ──YES──► Plain language. Direct to emergency services (112/911).
      │            No goldfish puns. No exceptions.
      NO
      │
      ▼
Layer 2 — TOPIC KEYWORD MATCH
      │
   match? ──YES──► Random response from that topic pool (in character)
      │
      NO
      │
      ▼
Layer 3 — IN-CHARACTER DEFLECT (catches everything else)

"Blub blub! That's outside my fishbowl! Dr. Squiggles only swims
 in Crohn's, biologics, medication, diet, and treatment waters."

Layer 1 exists because this is a patient-facing medical app. A user typing "I'm in severe pain" should not get goldfish puns — they need to be directed to emergency services clearly and immediately. Layer 3 is the guardrail for everything else: the bot either answers a health question or redirects in character. It cannot be pulled into general conversation regardless of what the user sends.

Topics covered

Keywords matched Topic
emergency, severe pain, bleeding, hospital, chest pain... Urgency — character dropped
hello, hi, hey, howdy Greeting
dose, dosage, forgot, miss, skip, late Missed / late doses
side effect, nausea, headache, fatigue, fever Side effects
food, eat, drink, coffee, alcohol, diet, dairy Diet and lifestyle
inject, injection, needle, pen, syringe, fridge Injection logistics
crohn, flare, symptom, bowel, ibd, remission General Crohn's info
anything else Fishbowl deflection

Extra Features Implemented

Welcome message on first load — new sessions are seeded with a welcome message from Dr. Squiggles on the first REST call. Persisted to the DB so it appears in all subsequent history loads. No user ever sees an empty screen.

Connection status indicators — three states, rendered in real time. connecting: yellow banner. connected: green dot, no banner. disconnected: red banner with auto-retry. Input is disabled when not connected.

Typing indicator — animated dots appear between the user sending and the bot responding, driven by the { "type": "typing" } WebSocket event the server sends before generating a response.

Exponential backoff reconnect — retries at 1s, 2s, 4s, doubling each time up to a 30s cap. Resets to 1s on successful reconnect.

Optimistic UI — user messages appear instantly without waiting for server confirmation. Makes the app feel native on slow connections.

Per-connection rate limiting — WebSocket endpoint throttled to one message per 0.5s per connection. Messages arriving faster than that get an error event back — no DB write, no bot response.

FlatList performanceremoveClippedSubviews, maxToRenderPerBatch, windowSize tuned. renderItem and keyExtractor wrapped in useCallback to prevent unnecessary re-renders on every state update.

Input validation — Pydantic validates every incoming WebSocket message server-side before any DB write: non-empty, max 2000 characters. Invalid messages get an error event back rather than a silent failure.

DB-level role constraintCHECK (role IN ('user', 'bot')) enforced at the PostgreSQL level, not just in application code. Invalid values cannot reach the table regardless of how data gets written.

Stale connection detectionpool_pre_ping=True on the SQLAlchemy engine. Detects dead DB connections after a container restart instead of failing silently on the next query.


Security Considerations

Implemented:

Measure Where
CORS origins from CORS_ORIGINS env var main.py — defaults to * in dev, restrict in prod by setting the env var
Input validation — non-empty, max 2000 chars schemas.py — Pydantic WSMessageIn, runs before any DB write
WebSocket payload validation — malformed JSON returns error event chat_router.py — connection stays open, error event returned
Per-connection rate limiting — 0.5s between messages chat_router.pytime.monotonic() check, no DB write on breach
Parameterised queries All DB access via SQLAlchemy ORM — no raw SQL
DB-level role constraint models.pyCheckConstraint enforced in PostgreSQL

Production additions (not implemented, documented):

WebSocket authentication — issue a short-lived signed JWT on login, validate in a FastAPI dependency before accepting the connection. The session_id comes from the token payload, not the URL — prevents a user accessing another patient's session by changing the path:

@router.websocket("/ws/{session_id}")
async def ws_endpoint(
    websocket: WebSocket,
    session_id: str = Depends(verify_ws_token),
):
    ...

Transport — HTTPS/WSS via reverse proxy (nginx, Caddy). Plain HTTP/WS rejected at the proxy level.

Global rate limiting — the current per-connection throttle handles individual abuse. Production would add global rate limiting via Redis or API gateway for coordinated traffic.

Session isolation — already scoped by session_id in every DB query. In production this maps to the JWT subject, giving per-patient data isolation with no changes to the repository layer.


Setup & Run

Prerequisites

  • Docker + Docker Compose
  • Node.js 18+
  • Expo CLI: npm install -g expo-cli

1. Clone the repo

git clone <your-repo-url>
cd HealthChat

2. Start the backend and database

docker-compose up --build

This starts PostgreSQL on port 5432 and the FastAPI backend on port 8000. Database tables are created automatically on startup via SQLAlchemy's init_db().

First run after pulling latest changes? Drop the existing volume so the DB recreates with the correct schema including the CHECK constraint on role:

docker-compose down -v && docker-compose up --build

3. Verify the backend is up

curl http://localhost:8000/api/messages/default
# Returns the seeded welcome message from Dr. Squiggles

# Interactive API docs
open http://localhost:8000/docs

4. Configure the client IP

The client auto-detects the platform — no manual changes needed for simulators or web:

  • iOS simulator / web — uses localhost automatically
  • Android emulator — uses 10.0.2.2 automatically
  • Physical device — open client/src/constants/config.ts, set MANUAL_IP to your machine's local IP

Find your IP:

# macOS
ipconfig getifaddr en0

# Linux
hostname -I | awk '{print $1}'

# Windows (PowerShell)
(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -notmatch 'Loopback' }).IPAddress

5. Install client dependencies

cd client
npm install

6. Start the client

# Web (instant, no setup needed)
npx expo start --web

# iOS Simulator (requires Xcode)
npx expo start --clear
# press 'i'

# Physical device via Expo Go (same WiFi network)
npx expo start --lan
# scan the QR code in the Expo Go app

API Reference

REST

GET /api/messages/{session_id}

Returns full conversation history ordered by created_at ASC.
Seeds a welcome message if the session has no prior history.

Response 200:
[
  {
    "id": "uuid",
    "session_id": "default",
    "role": "user" | "bot",
    "content": "string",
    "created_at": "2024-01-01T00:00:00Z"
  }
]

WebSocket

Connect: ws://localhost:8000/ws/{session_id}

Client → Server:
  { "content": "Can I drink coffee?" }

Server → Client:
  { "type": "typing" }
  { "type": "message", "id": "uuid", "role": "bot", "content": "...", "created_at": "..." }
  { "type": "error", "detail": "Content cannot be empty" }
  { "type": "error", "detail": "Slow down! Wait 0.3s before sending again." }

Environment Variables

Copy .env.example to .env:

cp .env.example .env
Variable Default Description
DATABASE_URL `postgresql+asyncpg://:@db:5432/chatdb
` Async DB connection string passed to SQLAlchemy
CORS_ORIGINS * Comma-separated allowed origins. In production: https://yourapp.com
Screenshot 2026-03-29 at 13 10 14

About

Real-time chat companion for Crohn's Disease patients — React Native + FastAPI + PostgreSQL.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages