- Project Overview
- System Architecture
- Technology Stack
- Database Design
- Component 1: AI Symptom Journal (Patient Mobile App)
- Component 2: Wearable Health Integration
- Component 3: Researcher Safety Dashboard
- AI/ML Pipeline
- Alert Engine
- Authentication & Authorization
- Data Privacy & Compliance
- API Specification
- Deployment & Infrastructure
- Testing Strategy
- Hackathon Prototype Scope
- Hackathon Demo Setup & Mock Data Guide
TrialPulse is an AI-powered clinical trial patient safety and engagement platform composed of three integrated components:
- AI Symptom Journal — A React Native mobile app where trial patients report symptoms through a conversational AI interface (text chat + real-time voice via LiveKit) that adapts questions based on their specific trial protocol.
- Wearable Health Integration — A passive data pipeline that ingests health metrics from consumer wearables (Apple Watch, Fitbit, Garmin, etc.) and runs anomaly detection against patient-specific baselines.
- Researcher Safety Dashboard — A web dashboard where clinical research coordinators (CRCs) and principal investigators (PIs) monitor patient health in real time, triage AI-generated alerts, and identify cohort-level safety signals.
The platform closes the gap between scheduled clinical visits by providing continuous, intelligent monitoring. Today, a patient experiencing a gradual increase in resting heart rate or a new persistent headache might not report it until their next visit weeks later. TrialPulse catches these signals in real time and routes them to the right person.
| User | Role | Primary Interactions |
|---|---|---|
| Trial Patient | Enrolled participant in a clinical trial | Daily symptom check-ins via mobile app (text chat or voice); passive wearable data sharing |
| Clinical Research Coordinator (CRC) | Site-level staff managing day-to-day patient interactions | Dashboard monitoring; alert triage; patient messaging |
| Principal Investigator (PI) | Physician overseeing trial safety at the site | Cohort analytics; serious adverse event review; safety signal assessment |
| Medical Monitor | Sponsor-side physician responsible for overall trial safety | Escalated alerts; cross-site safety signal analysis |
| Study Manager | Sponsor-side operational lead | Protocol configuration; enrollment tracking; site performance metrics |
┌─────────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ Patient Mobile │ │ Researcher Web Dashboard │ │
│ │ App (React │ │ (React + TypeScript) │ │
│ │ Native + Expo) │ │ │ │
│ │ │ │ ┌────────┐ ┌────────────┐ │ │
│ │ ┌──────────────┐ │ │ │Patient │ │ Cohort │ │ │
│ │ │ AI Chat UI │ │ │ │List │ │ Analytics │ │ │
│ │ ├──────────────┤ │ │ ├────────┤ ├────────────┤ │ │
│ │ │ LiveKit Voice│ │ │ │Patient │ │ Alert │ │ │
│ │ │ Check-In │ │ │ │Detail │ │ Queue │ │ │
│ │ ├──────────────┤ │ │ └────────┘ └────────────┘ │ │
│ │ │ Health │ │ │ │ │
│ │ │ Timeline │ │ │ │ │
│ │ ├──────────────┤ │ │ │ │
│ │ │ Wearable │ │ │ │ │
│ │ │ Dashboard │ │ │ │ │
│ │ └──────────────┘ │ │ │ │
│ └────────┬─────────┘ └──────────────┬───────────────┘ │
│ │ │ │
└────────────┼────────────────────────────────────┼───────────────────┘
│ HTTPS / WSS │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ NGINX REVERSE PROXY (local) │
│ Rate Limiting │ Request Routing │ CORS │ Static File Serving │
└────────────┬────────────────────────────────────┬───────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ PYTHON / FASTAPI MONOLITH BACKEND │
│ (fully async — asyncio) │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ LangChain + LangGraph │ │
│ │ LLM Orchestration Layer │ │
│ │ (vendor-agnostic: swap OpenAI, Anthropic, local models) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ Symptom │ │ Wearable │ │ Alert │ │ Analytics │ │
│ │ Journal │ │ Ingestion │ │ Engine │ │ Module │ │
│ │ Module │ │ Module │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └────┬─────┘ └──────┬───────┘ │
│ │ │ │ │ │
│ ┌──────┴───────┐ ┌──────┴───────┐ │ │ │
│ │ AI/NLP │ │ Anomaly │ │ │ │
│ │ Classifier │ │ Detection │ │ │ │
│ │ (LangGraph) │ │ (numpy/ │ │ │ │
│ │ │ │ scikit) │ │ │ │
│ └──────────────┘ └──────────────┘ │ │ │
│ │ │ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ LiveKit Integration Layer │ │
│ │ Real-time voice check-in agent (STT → LLM → TTS pipeline) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ WebSocket Manager (native FastAPI WebSockets) │ │
│ │ Real-time dashboard updates, chat streaming │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└──────────┬───────────────┬──────────────┬───────────────┬──────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
│ (all dockerized locally) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PostgreSQL │ │ Redis 7 │ │ MinIO │ │
│ │ 16 │ │ (events, │ │ (S3-compat │ │
│ │ (Relational) │ │ cache, │ │ object │ │
│ │ │ │ pub/sub) │ │ storage) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ LiveKit │ │
│ │ Server │ │
│ │ (self-hosted │ │
│ │ Docker) │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
The entire backend is a single Python FastAPI application organized into logical modules. This is a deliberate hackathon decision — a monolith avoids inter-service network complexity, simplifies debugging, and allows shared in-process access to database connections, the Redis event bus, and LangChain/LangGraph agent state.
Module boundaries within the monolith:
app/
├── main.py # FastAPI app factory, lifespan events, middleware
├── config.py # Pydantic Settings (env-based config)
├── deps.py # Dependency injection (DB sessions, Redis, etc.)
│
├── modules/
│ ├── auth/ # JWT issuance, password hashing, demo bypass
│ ├── checkin/ # Symptom journal chat + voice session management
│ ├── wearable/ # Wearable data ingestion, normalization, baselines
│ ├── alert/ # Alert rule engine, deduplication, risk scoring
│ ├── analytics/ # Cohort queries, AE incidence, exports
│ ├── dashboard/ # Staff-facing API endpoints
│ └── voice/ # LiveKit agent integration
│
├── ai/ # LangChain + LangGraph orchestration
│ ├── chains/ # Reusable LangChain chains
│ ├── graphs/ # LangGraph state machines (check-in flow, classifier)
│ ├── prompts/ # Prompt templates
│ └── tools/ # LangChain tools (MedDRA lookup, risk calc, etc.)
│
├── models/ # SQLAlchemy async ORM models
├── schemas/ # Pydantic request/response schemas
├── events/ # Redis pub/sub event bus
└── ws/ # WebSocket connection manager
Since the backend is a monolith, all communication happens in-process via an async event bus backed by Redis pub/sub. This keeps the architecture decoupled internally while running in a single process.
Checkin Module
│
├──▶ publishes "symptom.reported" event (via Redis pub/sub)
│ │
│ ▼
│ Alert Engine (in-process subscriber)
│ │
│ ├──▶ evaluates rules
│ ├──▶ publishes "alert.generated" event
│ │ │
│ │ ▼
│ │ WebSocket Manager ──▶ pushes to dashboard clients
│ │
│ └──▶ updates risk_score in PostgreSQL
│
└──▶ stores symptom data in PostgreSQL
Wearable Module
│
├──▶ stores time-series data in PostgreSQL (JSONB + timestamped rows)
├──▶ publishes "wearable.data_received" event
│ │
│ ▼
│ Anomaly Detection (in-process)
│ │
│ ├──▶ compares against patient baseline
│ ├──▶ publishes "anomaly.detected" event
│ │ │
│ │ ▼
│ │ Alert Engine (same pipeline as above)
│ │
│ └──▶ stores anomaly records in PostgreSQL
│
└──▶ updates patient baseline statistics
Event bus implementation (Redis pub/sub):
# app/events/bus.py
import asyncio
import json
from typing import Callable, Dict, List
import redis.asyncio as aioredis
class EventBus:
def __init__(self, redis: aioredis.Redis):
self.redis = redis
self._handlers: Dict[str, List[Callable]] = {}
self._pubsub = None
async def publish(self, event_type: str, payload: dict):
"""Publish an event to all subscribers."""
message = json.dumps({"type": event_type, "payload": payload})
await self.redis.publish("trialpulse:events", message)
def subscribe(self, event_type: str, handler: Callable):
"""Register an async handler for an event type."""
if event_type not in self._handlers:
self._handlers[event_type] = []
self._handlers[event_type].append(handler)
async def start_listening(self):
"""Background task that listens for events and dispatches to handlers."""
self._pubsub = self.redis.pubsub()
await self._pubsub.subscribe("trialpulse:events")
async for message in self._pubsub.listen():
if message["type"] == "message":
data = json.loads(message["data"])
event_type = data["type"]
for handler in self._handlers.get(event_type, []):
asyncio.create_task(handler(data["payload"]))| Component | Technology | Rationale |
|---|---|---|
| Patient Mobile App | React Native + Expo | Cross-platform (iOS/Android) from a single codebase; demo on a real mobile device |
| Chat UI | React Native Gifted Chat | Battle-tested chat interface with typing indicators, quick replies, and accessibility |
| Voice Check-In | LiveKit React Native SDK (@livekit/react-native) |
Real-time voice sessions with the AI agent; low-latency STT/TTS |
| Researcher Dashboard | React 18 + TypeScript | Type safety for complex data models; large ecosystem of charting and table libraries |
| Dashboard Charts | Recharts + D3.js | Recharts for standard charts (bar, line, area); D3 for custom visualizations like patient timelines |
| Dashboard Tables | TanStack Table (React Table v8) | Headless table engine supporting sorting, filtering, pagination, and column resizing |
| State Management | Zustand (mobile) + React Query (both) | Zustand for lightweight local state; React Query for server state caching and background refetching |
| Styling | NativeWind (mobile) + Tailwind CSS (web) | Consistent utility-first styling across platforms |
| Component | Technology | Rationale |
|---|---|---|
| Web Framework | Python 3.12 + FastAPI | Fully async; auto-generated OpenAPI docs; native WebSocket support; Pydantic validation |
| ASGI Server | Uvicorn | High-performance async server; runs the FastAPI app |
| LLM Orchestration | LangChain + LangGraph | Vendor-agnostic LLM abstraction; LangGraph for stateful multi-step agent workflows (check-in flow, classification); swap providers without code changes |
| Real-Time Voice | LiveKit Agents SDK (livekit-agents) + LiveKit server (self-hosted Docker) |
Real-time voice pipeline: STT (Deepgram/Whisper) → LangGraph agent → TTS (ElevenLabs/Coqui); runs as a worker within the monolith |
| ORM | SQLAlchemy 2.0 (async) + asyncpg | Async PostgreSQL access; declarative models; migration support via Alembic |
| Migrations | Alembic | Database schema versioning and migration |
| Event Bus | Redis pub/sub (via redis.asyncio) |
Decoupled in-process event-driven architecture; async pub/sub for alert pipeline |
| Background Tasks | FastAPI BackgroundTasks + asyncio tasks + APScheduler | Async task execution for risk score recalculation, check-in reminders, and data aggregation |
| WebSockets | Native FastAPI WebSockets | Real-time dashboard updates; no external dependency needed |
| Validation | Pydantic v2 | Request/response validation, settings management, JSON schema generation |
| Store | Technology | What It Stores |
|---|---|---|
| Primary Database | PostgreSQL 16 (Docker) | Patients, trials, protocols, symptom entries, alerts, user accounts, wearable time-series data (JSONB), audit trail |
| Cache + Event Bus | Redis 7 (Docker) | Pub/sub event bus, session data, rate limiting counters, real-time dashboard state, LLM response cache |
| Object Storage | MinIO (Docker, S3-compatible) | Consent documents, exported reports, audio recordings from voice check-ins |
| Voice Infrastructure | LiveKit Server (Docker, self-hosted) | Real-time voice room management, media routing for STT/TTS pipeline |
Note: InfluxDB has been removed. For a hackathon prototype, wearable time-series data is stored directly in PostgreSQL using timestamped rows with JSONB fields. This eliminates an extra service and simplifies the stack. A dedicated time-series DB can be introduced later for production scale.
| Component | Technology |
|---|---|
| Container Runtime | Docker |
| Orchestration | Docker Compose |
| Reverse Proxy | Nginx (Docker) |
| Database GUI (optional) | pgAdmin 4 (Docker) or DBeaver (local) |
Explicitly excluded for hackathon: CI/CD pipelines, monitoring/alerting (Datadog, Grafana, Prometheus), log aggregation (Loki, CloudWatch), secrets management services (Vault, AWS Secrets Manager), CDN, cloud hosting, Kubernetes — none of these are needed for a local prototype.
-- =============================================================
-- TRIALS AND PROTOCOLS
-- =============================================================
CREATE TABLE trials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sponsor_name VARCHAR(255) NOT NULL,
protocol_number VARCHAR(100) NOT NULL UNIQUE,
trial_title TEXT NOT NULL,
therapeutic_area VARCHAR(100) NOT NULL, -- e.g., 'oncology', 'cardiology', 'dermatology'
phase VARCHAR(10) NOT NULL, -- 'I', 'II', 'III', 'IV'
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'setup', 'active', 'completed', 'terminated'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE protocol_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trial_id UUID NOT NULL REFERENCES trials(id),
checkin_frequency VARCHAR(20) NOT NULL DEFAULT 'daily', -- 'daily', 'weekly', 'twice_daily'
checkin_window_hours INT NOT NULL DEFAULT 24,
expected_side_effects JSONB NOT NULL DEFAULT '[]',
-- JSON array of objects: [{"term": "Headache", "meddra_code": "10019211", "expected_frequency": "common"}]
symptom_questions JSONB NOT NULL DEFAULT '[]',
-- Ordered list of baseline questions for this protocol
wearable_required BOOLEAN NOT NULL DEFAULT false,
wearable_metrics JSONB NOT NULL DEFAULT '["heart_rate", "steps", "sleep"]',
alert_thresholds JSONB NOT NULL DEFAULT '{}',
-- {"heart_rate_resting_max": 100, "spo2_min": 92, "anomaly_z_threshold": 2.5}
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- =============================================================
-- SITES AND STAFF
-- =============================================================
CREATE TABLE sites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trial_id UUID NOT NULL REFERENCES trials(id),
site_number VARCHAR(50) NOT NULL,
site_name VARCHAR(255) NOT NULL,
country VARCHAR(100) NOT NULL,
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
status VARCHAR(20) NOT NULL DEFAULT 'active',
UNIQUE(trial_id, site_number)
);
CREATE TABLE staff (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
role VARCHAR(30) NOT NULL, -- 'crc', 'pi', 'medical_monitor', 'study_manager'
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE staff_site_access (
staff_id UUID NOT NULL REFERENCES staff(id),
site_id UUID NOT NULL REFERENCES sites(id),
role VARCHAR(30) NOT NULL, -- role at this specific site
PRIMARY KEY (staff_id, site_id)
);
-- =============================================================
-- PATIENTS
-- =============================================================
CREATE TABLE patients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id),
enrollment_code VARCHAR(50) NOT NULL UNIQUE, -- code given to patient at screening
subject_id VARCHAR(50) NOT NULL, -- blinded trial subject number
treatment_arm VARCHAR(50), -- null if still being randomized
enrollment_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'enrolled',
-- 'screening', 'enrolled', 'active', 'completed', 'withdrawn', 'discontinued'
app_registered BOOLEAN NOT NULL DEFAULT false,
wearable_connected BOOLEAN NOT NULL DEFAULT false,
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
language VARCHAR(10) NOT NULL DEFAULT 'en',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(site_id, subject_id)
);
CREATE TABLE patient_app_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL UNIQUE REFERENCES patients(id),
device_token TEXT, -- push notification token
device_platform VARCHAR(10), -- 'ios', 'android'
app_version VARCHAR(20),
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- =============================================================
-- SYMPTOM JOURNAL
-- =============================================================
CREATE TABLE checkin_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
session_type VARCHAR(20) NOT NULL DEFAULT 'scheduled', -- 'scheduled', 'ad_hoc'
modality VARCHAR(10) NOT NULL DEFAULT 'text', -- 'text', 'voice'
status VARCHAR(20) NOT NULL DEFAULT 'in_progress', -- 'in_progress', 'completed', 'abandoned'
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
duration_seconds INT,
overall_feeling INT, -- 1-5 scale quick rating at start of check-in
voice_room_id VARCHAR(255), -- LiveKit room ID if voice session
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE checkin_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES checkin_sessions(id),
sequence_number INT NOT NULL,
role VARCHAR(10) NOT NULL, -- 'ai', 'patient'
content TEXT NOT NULL, -- message text (or transcribed voice)
message_type VARCHAR(20) NOT NULL DEFAULT 'text',
-- 'text', 'quick_reply', 'scale_rating', 'date_picker', 'multi_select', 'voice_transcript'
quick_replies JSONB, -- available quick reply options (for AI messages)
selected_reply VARCHAR(255), -- which quick reply the patient selected
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE symptom_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
session_id UUID REFERENCES checkin_sessions(id),
symptom_text TEXT NOT NULL, -- patient's original description
meddra_pt_code VARCHAR(20), -- MedDRA Preferred Term code
meddra_pt_term VARCHAR(255), -- MedDRA Preferred Term text
meddra_soc VARCHAR(255), -- System Organ Class
severity_grade INT, -- CTCAE v5 grade: 1-5
onset_date DATE,
is_ongoing BOOLEAN DEFAULT true,
resolution_date DATE,
relationship VARCHAR(30), -- 'unrelated', 'unlikely', 'possible', 'probable', 'definite'
action_taken VARCHAR(50), -- 'none', 'dose_reduced', 'drug_interrupted', 'drug_discontinued'
ai_confidence FLOAT, -- 0.0-1.0 confidence in classification
crc_reviewed BOOLEAN NOT NULL DEFAULT false,
crc_reviewed_at TIMESTAMPTZ,
crc_reviewed_by UUID REFERENCES staff(id),
crc_override_term VARCHAR(255), -- if CRC disagrees with AI classification
crc_override_grade INT,
is_sae BOOLEAN NOT NULL DEFAULT false, -- serious adverse event flag
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- =============================================================
-- WEARABLE DATA (stored in PostgreSQL for hackathon simplicity)
-- =============================================================
CREATE TABLE wearable_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
platform VARCHAR(20) NOT NULL, -- 'apple_healthkit', 'google_health_connect', 'fitbit'
device_name VARCHAR(255),
device_model VARCHAR(255),
last_sync_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE wearable_readings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
metric VARCHAR(50) NOT NULL, -- 'heart_rate', 'resting_heart_rate', 'steps', 'sleep_minutes', 'spo2'
value FLOAT NOT NULL,
source VARCHAR(50), -- 'apple_watch', 'fitbit', 'garmin', 'mock'
quality VARCHAR(20) DEFAULT 'raw', -- 'raw', 'hourly_avg', 'daily_avg'
recorded_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_wearable_readings_patient_metric ON wearable_readings(patient_id, metric, recorded_at DESC);
CREATE TABLE wearable_baselines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
metric VARCHAR(50) NOT NULL, -- 'resting_heart_rate', 'steps_daily', 'sleep_hours', 'spo2'
baseline_mean FLOAT NOT NULL,
baseline_stddev FLOAT NOT NULL,
baseline_min FLOAT,
baseline_max FLOAT,
sample_count INT NOT NULL,
baseline_start DATE NOT NULL,
baseline_end DATE NOT NULL,
is_current BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(patient_id, metric, is_current) -- only one active baseline per metric
);
CREATE TABLE wearable_anomalies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
metric VARCHAR(50) NOT NULL,
anomaly_type VARCHAR(30) NOT NULL, -- 'point_anomaly', 'trend_anomaly', 'threshold_breach'
detected_at TIMESTAMPTZ NOT NULL,
value FLOAT NOT NULL,
baseline_mean FLOAT NOT NULL,
z_score FLOAT,
trend_slope FLOAT, -- for trend anomalies: rate of change per day
trend_window INT, -- days in the trend window
severity VARCHAR(10) NOT NULL, -- 'low', 'medium', 'high'
resolved BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- =============================================================
-- ALERTS AND RISK SCORES
-- =============================================================
CREATE TABLE alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
alert_type VARCHAR(30) NOT NULL,
-- 'symptom_severe', 'symptom_new', 'wearable_anomaly', 'missed_checkin',
-- 'engagement_decline', 'risk_score_elevated', 'sae_reported'
severity VARCHAR(10) NOT NULL, -- 'low', 'medium', 'high', 'critical'
title TEXT NOT NULL,
description TEXT NOT NULL,
source_type VARCHAR(30), -- 'symptom_entry', 'wearable_anomaly', 'system'
source_id UUID, -- reference to the triggering record
status VARCHAR(20) NOT NULL DEFAULT 'open',
-- 'open', 'acknowledged', 'in_progress', 'resolved', 'dismissed'
assigned_to UUID REFERENCES staff(id),
acknowledged_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
resolution_note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE risk_scores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
score INT NOT NULL, -- 0-100
tier VARCHAR(10) NOT NULL, -- 'low', 'medium', 'high'
symptom_component FLOAT NOT NULL, -- 0-40
wearable_component FLOAT NOT NULL, -- 0-30
engagement_component FLOAT NOT NULL, -- 0-15
compliance_component FLOAT NOT NULL, -- 0-15
contributing_factors JSONB NOT NULL DEFAULT '[]',
-- [{"factor": "Grade 3 headache reported", "weight": 15}, ...]
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_risk_scores_patient_date ON risk_scores(patient_id, calculated_at DESC);
CREATE INDEX idx_alerts_patient_status ON alerts(patient_id, status);
CREATE INDEX idx_symptom_entries_patient ON symptom_entries(patient_id, created_at DESC);
CREATE INDEX idx_checkin_sessions_patient ON checkin_sessions(patient_id, started_at DESC);The AI symptom journal uses a LangGraph state machine that orchestrates the check-in conversation. Each check-in session progresses through defined graph nodes, with the LLM generating natural conversational text at each step. The graph is vendor-agnostic — the underlying LLM can be swapped via LangChain's provider abstraction.
┌───────────┐ ┌───────────────┐ ┌────────────────┐
│ GREETING │────▶│ OVERALL │────▶│ SYMPTOM │
│ │ │ FEELING │ │ SCREENING │
└───────────┘ │ (1-5 scale) │ │ (open-ended │
└───────────────┘ │ question) │
└───────┬────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ NO SYMPTOMS │ │ SYMPTOM │
│ (brief │ │ DEEP DIVE │
│ closing) │ │ (per symptom)│
└──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ For each │
│ symptom: │
│ │
│ 1. Severity │
│ 2. Onset │
│ 3. Duration │
│ 4. Character │
│ 5. Treatment │
│ 6. Impact on │
│ daily life│
└──────┬───────┘
│
┌────────────────────┤
▼ ▼
┌──────────────┐ ┌──────────────┐
│ MORE │ │ PROTOCOL- │
│ SYMPTOMS? │────▶│ SPECIFIC Qs │
│ (loop back) │ │ (from config)│
└──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ SUMMARY & │
│ CONFIRMATION │
└──────┬───────┘
│
▼
┌──────────────┐
│ CLOSING & │
│ NEXT STEPS │
└──────────────┘
LangGraph Implementation:
# app/ai/graphs/checkin_graph.py
from langgraph.graph import StateGraph, END
from langchain_core.messages import HumanMessage, AIMessage
from pydantic import BaseModel, Field
from typing import Literal
from app.ai.chains.conversation import get_checkin_chain
class CheckinState(BaseModel):
"""State tracked across the check-in conversation."""
messages: list = Field(default_factory=list)
phase: str = "greeting"
overall_feeling: int | None = None
reported_symptoms: list = Field(default_factory=list)
current_symptom_index: int = 0
symptom_detail_step: int = 0
protocol_context: dict = Field(default_factory=dict)
session_complete: bool = False
async def greeting_node(state: CheckinState) -> dict:
chain = get_checkin_chain(state.protocol_context)
response = await chain.ainvoke({
"phase": "greeting",
"messages": state.messages,
"protocol": state.protocol_context,
})
return {
"messages": state.messages + [AIMessage(content=response.content)],
"phase": "overall_feeling",
}
async def symptom_screening_node(state: CheckinState) -> dict:
chain = get_checkin_chain(state.protocol_context)
response = await chain.ainvoke({
"phase": "symptom_screening",
"messages": state.messages,
"protocol": state.protocol_context,
})
return {
"messages": state.messages + [AIMessage(content=response.content)],
"phase": "awaiting_symptoms",
}
def route_after_screening(state: CheckinState) -> Literal["deep_dive", "closing"]:
"""Determine if patient reported symptoms or not."""
if state.reported_symptoms:
return "deep_dive"
return "closing"
# Build the graph
def build_checkin_graph():
graph = StateGraph(CheckinState)
graph.add_node("greeting", greeting_node)
graph.add_node("overall_feeling", overall_feeling_node)
graph.add_node("symptom_screening", symptom_screening_node)
graph.add_node("deep_dive", symptom_deep_dive_node)
graph.add_node("protocol_questions", protocol_questions_node)
graph.add_node("summary", summary_node)
graph.add_node("closing", closing_node)
graph.set_entry_point("greeting")
graph.add_edge("greeting", "overall_feeling")
graph.add_edge("overall_feeling", "symptom_screening")
graph.add_conditional_edges("symptom_screening", route_after_screening)
graph.add_edge("deep_dive", "protocol_questions")
graph.add_edge("protocol_questions", "summary")
graph.add_edge("summary", "closing")
graph.add_edge("closing", END)
return graph.compile()All LLM calls go through LangChain's abstraction layer, ensuring no vendor lock-in. The provider is configured via environment variable.
# app/ai/chains/conversation.py
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import JsonOutputParser
from app.ai.llm import get_llm
def get_checkin_chain(protocol_context: dict):
"""Build the check-in conversation chain using LangChain."""
llm = get_llm() # vendor-agnostic LLM
system_prompt = """You are a compassionate clinical trial health assistant for a patient
enrolled in a {therapeutic_area} trial (Protocol: {protocol_number}).
YOUR ROLE:
- Guide the patient through a daily symptom check-in
- Ask clear, warm questions in plain language (no medical jargon)
- Collect enough detail to classify symptoms using MedDRA terminology
- Never provide medical advice, diagnoses, or treatment recommendations
- If the patient reports something concerning, acknowledge it warmly and
let them know their clinical team will be notified
EXPECTED SIDE EFFECTS FOR THIS PROTOCOL:
{expected_side_effects}
SYMPTOM ASSESSMENT CHECKLIST (collect for each reported symptom):
1. What the symptom feels like (description in patient's own words)
2. When it started (onset)
3. How bad it is on a scale of 1-10
4. Whether it's constant or comes and goes
5. Whether they took anything for it
6. Whether it affects their daily activities
RESPONSE FORMAT:
- Keep messages short (2-3 sentences max)
- Use one question per message
- Offer quick-reply options where appropriate
- End each check-in with a brief summary of what was reported
SAFETY ESCALATION:
If the patient reports any of the following, immediately flag as HIGH PRIORITY:
- Chest pain, difficulty breathing, severe allergic reaction
- Suicidal thoughts or severe depression
- Seizures, loss of consciousness
- Any symptom rated 9-10 severity
Current phase: {phase}"""
prompt = ChatPromptTemplate.from_messages([
("system", system_prompt),
MessagesPlaceholder("messages"),
])
return prompt | llm
# app/ai/llm.py
from langchain_anthropic import ChatAnthropic
from langchain_openai import ChatOpenAI
from app.config import settings
def get_llm():
"""Return a LangChain LLM instance based on configuration.
Swap providers by changing LLM_PROVIDER env var — no code changes needed.
"""
if settings.LLM_PROVIDER == "anthropic":
return ChatAnthropic(
model=settings.LLM_MODEL,
api_key=settings.LLM_API_KEY,
max_tokens=1024,
)
elif settings.LLM_PROVIDER == "openai":
return ChatOpenAI(
model=settings.LLM_MODEL,
api_key=settings.LLM_API_KEY,
max_tokens=1024,
)
else:
raise ValueError(f"Unsupported LLM provider: {settings.LLM_PROVIDER}")When a check-in session completes, the full conversation is sent to a classification LangGraph that extracts and classifies symptoms:
# app/ai/graphs/classifier_graph.py
from langgraph.graph import StateGraph, END
from langchain_core.output_parsers import JsonOutputParser
from pydantic import BaseModel, Field
from app.ai.llm import get_llm
class SymptomClassification(BaseModel):
symptom_text: str
meddra_pt: str
meddra_code: str
severity_ctcae: int = Field(ge=1, le=5)
onset: str | None = None
ongoing: bool = True
confidence: float = Field(ge=0.0, le=1.0)
class ClassifierState(BaseModel):
conversation_history: list = Field(default_factory=list)
protocol_context: dict = Field(default_factory=dict)
extracted_symptoms: list = Field(default_factory=list)
classifications: list = Field(default_factory=list)
is_validated: bool = False
async def extract_symptoms_node(state: ClassifierState) -> dict:
llm = get_llm()
parser = JsonOutputParser(pydantic_object=list[SymptomClassification])
prompt = f"""Analyze this check-in conversation and extract every symptom
the patient reported. For each symptom, classify it with a MedDRA Preferred Term,
CTCAE v5 severity grade, and your confidence score.
Conversation:
{state.conversation_history}
Protocol context (expected side effects):
{state.protocol_context.get('expected_side_effects', [])}
{parser.get_format_instructions()}"""
response = await llm.ainvoke(prompt)
classifications = parser.parse(response.content)
return {"classifications": classifications}
async def validate_node(state: ClassifierState) -> dict:
"""Validate classifications against known MedDRA codes."""
validated = []
for c in state.classifications:
# Validate MedDRA code exists, severity is within range, etc.
if 1 <= c.get("severity_ctcae", 0) <= 5 and c.get("confidence", 0) > 0.5:
validated.append(c)
return {"classifications": validated, "is_validated": True}
def build_classifier_graph():
graph = StateGraph(ClassifierState)
graph.add_node("extract", extract_symptoms_node)
graph.add_node("validate", validate_node)
graph.set_entry_point("extract")
graph.add_edge("extract", "validate")
graph.add_edge("validate", END)
return graph.compile()Patients can choose to complete their check-in via voice instead of text. The voice pipeline uses a self-hosted LiveKit server with the LiveKit Agents SDK running inside the Python monolith.
Architecture:
┌─────────────────────┐ ┌──────────────────────┐
│ React Native App │ │ LiveKit Server │
│ │ WebRTC│ (Docker, self-hosted)│
│ ┌───────────────┐ │◄─────▶│ │
│ │ LiveKit RN │ │ │ Manages rooms, │
│ │ SDK │ │ │ routes media │
│ │ │ │ └──────────┬───────────┘
│ │ Mic → Publish │ │ │
│ │ Subscribe ← ▸ │ │ │ Agent connects
│ └───────────────┘ │ │ as participant
│ │ ▼
│ ┌───────────────┐ │ ┌──────────────────────┐
│ │ Text Chat │ │ │ Python Backend │
│ │ (parallel) │ │ │ (LiveKit Agent) │
│ └───────────────┘ │ │ │
│ │ │ Audio In ──▶ STT │
└─────────────────────┘ │ (Deepgram/ │
│ Whisper) │
│ │ │
│ ▼ │
│ Transcript ──▶ │
│ LangGraph Check-In │
│ Agent (same graph │
│ as text check-in) │
│ │ │
│ ▼ │
│ Response Text ──▶ │
│ TTS (Coqui / │
│ ElevenLabs) │
│ │ │
│ ▼ │
│ Audio Out ──▶ │
│ Publish to room │
└──────────────────────┘
LiveKit Agent Implementation:
# app/modules/voice/agent.py
from livekit.agents import (
AutoSubscribe,
JobContext,
WorkerOptions,
cli,
llm as livekit_llm,
)
from livekit.agents.voice_assistant import VoiceAssistant
from livekit.plugins import deepgram, silero
from app.ai.graphs.checkin_graph import build_checkin_graph, CheckinState
from app.ai.llm import get_llm
class TrialPulseVoiceAgent:
"""LiveKit voice agent that runs the same LangGraph check-in flow
used by the text chat, but with voice input/output."""
def __init__(self, protocol_context: dict, patient_id: str):
self.protocol_context = protocol_context
self.patient_id = patient_id
self.checkin_graph = build_checkin_graph()
self.state = CheckinState(protocol_context=protocol_context)
async def handle_transcript(self, transcript: str) -> str:
"""Process a speech transcript through the LangGraph check-in flow."""
from langchain_core.messages import HumanMessage
self.state.messages.append(HumanMessage(content=transcript))
result = await self.checkin_graph.ainvoke(self.state)
self.state = CheckinState(**result)
# Return the latest AI message for TTS
ai_messages = [m for m in self.state.messages if hasattr(m, 'type') and m.type == 'ai']
return ai_messages[-1].content if ai_messages else "I'm here to help."
async def entrypoint(ctx: JobContext):
"""LiveKit agent entrypoint — called when a patient joins a voice room."""
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
# Extract patient/protocol info from room metadata
room_metadata = ctx.room.metadata # JSON with patient_id, protocol_context
import json
meta = json.loads(room_metadata) if room_metadata else {}
agent = TrialPulseVoiceAgent(
protocol_context=meta.get("protocol_context", {}),
patient_id=meta.get("patient_id", ""),
)
# Configure STT + TTS
stt = deepgram.STT()
tts = deepgram.TTS() # or use Coqui for fully local TTS
vad = silero.VAD.load()
assistant = VoiceAssistant(
vad=vad,
stt=stt,
llm=get_llm(), # LangChain LLM for fallback
tts=tts,
chat_ctx=livekit_llm.ChatContext(),
)
assistant.start(ctx.room)
# To run as a worker alongside FastAPI:
# Worker is started as an asyncio task during app lifespanReact Native Voice UI:
┌──────────────────────────────┐
│ Voice Check-In │
│ │
│ ┌────────────────────────┐ │
│ │ [TrialPulse Avatar] │ │
│ │ │ │
│ │ "Hi! How are you │ │
│ │ feeling today?" │ │
│ │ │ │
│ │ 🔊 Speaking... │ │
│ └────────────────────────┘ │
│ │
│ ┌────────────────────────┐ │
│ │ 📝 Live Transcript: │ │
│ │ "I've had a headache │ │
│ │ since yesterday..." │ │
│ └────────────────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 🎙️ Listening │ │
│ │ (tap to mute)│ │
│ └──────────────┘ │
│ │
│ [Switch to Text Chat] │
└──────────────────────────────┘
- Top section: Greeting with patient's first name and current date
- Status card: Shows check-in status for today (completed/pending/overdue) with a countdown timer to the check-in window closing
- Quick action buttons: Two CTAs — "Start Text Check-In" and "Start Voice Check-In"
- Health summary strip: Mini cards showing latest wearable metrics (heart rate, steps, sleep) pulled from the last sync
- Timeline preview: Last 3 symptom entries with dates and severity indicators
- Bottom nav: Home, Timeline, Wearable, Profile
- Header: "Daily Check-In" with session timer and a minimize button
- Message list: Scrollable chat bubbles; AI messages on the left (with TrialPulse avatar), patient messages on the right
- Quick reply bar: When the AI offers quick replies, they appear as tappable chips above the text input (e.g., "No new symptoms", "Same as yesterday", "Yes", "No")
- Scale widget: When severity is requested, a horizontal slider (1-10) replaces the text input temporarily
- Date picker: When onset is requested, a date picker appears inline
- Text input: Standard text input with send button; microphone icon to switch to voice mode
- Progress indicator: Subtle dots at the top showing approximate session progress
- Header: "Voice Check-In" with session timer and close button
- Agent avatar: Animated TrialPulse avatar that pulses/glows when the AI is speaking
- Live transcript panel: Scrolling transcript of both patient speech and AI responses, providing a visual record of the voice conversation
- Voice activity indicator: Visual feedback showing when the patient's mic is active vs when the AI is speaking
- Control bar: Mute/unmute toggle, "Switch to Text" button, end session button
- Quick reply chips: Still available for structured inputs (severity rating, yes/no) even during voice mode — tappable as an alternative to speaking
- Vertical timeline: Chronological list of all events: symptom reports, wearable anomaly flags, scheduled visits, and medications
- Each event card shows: date/time, event type icon, brief description, severity color indicator
- Filter chips at top: "All", "Symptoms", "Wearable Alerts", "Visits"
- Tap to expand: Tapping a symptom entry shows full details including AI classification and CRC review status
- Connection status: Card showing connected device name, last sync time, battery level (if available)
- Metric cards (one per tracked metric):
- Current value with trend arrow (↑↓→)
- Sparkline showing last 7 days
- Baseline range indicator (green band = normal range)
- Anomaly alerts: If any metrics are flagged, a yellow/red banner appears at the top with a brief explanation
- Trial info: Protocol number, site name, enrollment date, treatment arm (if unblinded)
- Notification preferences: Toggle and time-of-day preference for check-in reminders
- Wearable management: Connect/disconnect devices
- Language preference: Dropdown for supported languages
- Support: Contact info for their CRC and a general help link
┌──────────────────┬──────────────────────────────────────────────────┐
│ Platform │ Integration Method │
├──────────────────┼──────────────────────────────────────────────────┤
│ Apple HealthKit │ Native iOS API via react-native-health │
│ │ Reads: HKQuantityType for HR, steps, SpO2, │
│ │ sleep, HRV, respiratory rate │
│ │ Background delivery: enabled for real-time HR │
│ │ │
│ Google Health │ Android Health Connect API via │
│ Connect │ react-native-health-connect │
│ │ Reads: HeartRateRecord, StepsRecord, │
│ │ SleepSessionRecord, OxygenSaturationRecord │
│ │ │
│ Mock Data │ Pre-generated seed data for hackathon demo │
│ (Hackathon) │ 30-day history per patient with baked-in │
│ │ anomaly patterns │
└──────────────────┴──────────────────────────────────────────────────┘
┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Wearable │ │ Phone Health │ │ TrialPulse │
│ Device │────▶│ Platform │────▶│ Mobile App │
│ (Watch) │ BLE │ (HealthKit / │ API │ │
│ │ │ Health Connect) │ │ Reads metrics │
└─────────────┘ └──────────────────┘ │ every 15 min │
│ (configurable) │
└────────┬─────────┘
│
HTTPS POST
(batch upload)
│
▼
┌──────────────────┐
│ Wearable Module │
│ (FastAPI) │
│ │
│ 1. Validate & │
│ normalize │
│ 2. Write to │
│ PostgreSQL │
│ 3. Publish event │
│ (Redis) │
└────────┬─────────┘
│
event: "wearable
.data_received"
│
▼
┌──────────────────┐
│ Anomaly │
│ Detection │
│ (in-process) │
│ │
│ 1. Load baseline │
│ 2. Compare │
│ 3. Flag if │
│ anomalous │
└──────────────────┘
# app/modules/wearable/normalization.py
NORMALIZATION_RULES = {
"heart_rate": {
"unit": "bpm",
"valid_range": (30, 250),
"precision": 0,
},
"resting_heart_rate": {
"unit": "bpm",
"valid_range": (30, 150),
"precision": 0,
},
"steps": {
"unit": "count",
"valid_range": (0, 100_000),
"precision": 0,
"aggregation": "sum",
},
"sleep_minutes": {
"unit": "minutes",
"valid_range": (0, 1440),
"precision": 0,
},
"spo2": {
"unit": "percent",
"valid_range": (70, 100),
"precision": 1,
},
"hrv": {
"unit": "ms",
"valid_range": (5, 300),
"precision": 1,
},
}
async def normalize_reading(metric: str, value: float) -> float | None:
"""Validate and normalize a single wearable reading. Returns None if invalid."""
rules = NORMALIZATION_RULES.get(metric)
if not rules:
return None
low, high = rules["valid_range"]
if not (low <= value <= high):
return None
return round(value, rules["precision"])# app/modules/wearable/anomaly_detection.py
import numpy as np
async def detect_point_anomaly(
value: float,
baseline_mean: float,
baseline_stddev: float,
z_threshold: float = 2.5,
) -> dict | None:
"""
Flag a single data point as anomalous if it deviates significantly
from the patient's personal baseline.
"""
if baseline_stddev == 0:
return None
z_score = abs(value - baseline_mean) / baseline_stddev
if z_score >= z_threshold:
severity = "low" if z_score < 3.0 else ("medium" if z_score < 4.0 else "high")
direction = "elevated" if value > baseline_mean else "depressed"
return {
"anomaly_type": "point_anomaly",
"z_score": round(z_score, 2),
"value": value,
"baseline_mean": baseline_mean,
"direction": direction,
"severity": severity,
}
return Noneasync def detect_trend_anomaly(
daily_values: list[tuple],
window_days: int = 14,
min_slope_per_day: float = 0.5,
) -> dict | None:
"""
Detect gradual trends that wouldn't trigger point anomalies.
Example: resting heart rate increasing by 0.4 BPM/day over 14 days
= +5.6 BPM total, which is clinically meaningful.
"""
if len(daily_values) < window_days:
return None
recent = daily_values[-window_days:]
x = np.arange(len(recent))
y = np.array([v for _, v in recent])
slope, intercept = np.polyfit(x, y, 1)
y_pred = slope * x + intercept
ss_res = np.sum((y - y_pred) ** 2)
ss_tot = np.sum((y - np.mean(y)) ** 2)
r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
if abs(slope) >= min_slope_per_day and r_squared >= 0.5:
direction = "increasing" if slope > 0 else "decreasing"
total_change = slope * window_days
severity = "low" if abs(total_change) < 5 else (
"medium" if abs(total_change) < 10 else "high"
)
return {
"anomaly_type": "trend_anomaly",
"slope_per_day": round(slope, 3),
"total_change": round(total_change, 2),
"r_squared": round(r_squared, 3),
"window_days": window_days,
"direction": direction,
"severity": severity,
}
return Noneasync def should_suppress_anomaly(anomaly: dict, patient_context: dict) -> bool:
"""Reduce false positives by checking if there's a reasonable explanation."""
metric = anomaly["metric"]
detected_at = anomaly["detected_at"]
if metric == "heart_rate" and anomaly.get("direction") == "elevated":
recent_activities = patient_context.get("reported_activities", [])
for activity in recent_activities:
if abs((detected_at - activity["timestamp"]).total_seconds()) < 7200:
if activity["type"] in ("exercise", "physical_activity", "walking"):
return True
if metric == "sleep_minutes":
if patient_context.get("recent_travel", False):
return True
if metric == "steps" and anomaly.get("direction") == "depressed":
if patient_context.get("visit_today", False):
return True
return Falseasync def calculate_baseline(
patient_id: str,
metric: str,
readings: list[dict],
min_days: int = 7,
max_days: int = 14,
) -> dict | None:
"""
Establish a personalized baseline from the patient's initial enrollment period.
"""
if len(readings) < min_days:
return None
recent = readings[-max_days:]
values = np.array([r["value"] for r in recent])
q1, q3 = np.percentile(values, [25, 75])
iqr = q3 - q1
lower_bound = q1 - 1.5 * iqr
upper_bound = q3 + 1.5 * iqr
filtered = values[(values >= lower_bound) & (values <= upper_bound)]
return {
"patient_id": patient_id,
"metric": metric,
"baseline_mean": round(float(np.mean(filtered)), 2),
"baseline_stddev": round(float(np.std(filtered)), 2),
"baseline_min": round(float(np.min(filtered)), 2),
"baseline_max": round(float(np.max(filtered)), 2),
"sample_count": len(filtered),
"baseline_start": recent[0]["date"],
"baseline_end": recent[-1]["date"],
}┌─────────────────────────────────────────────────────────────────┐
│ TrialPulse [Trial: ABC-123 ▼] [Site: All ▼] 🔔 3 👤 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Active Alerts: 3 critical, 7 medium Overdue Check-ins: 5 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Search patients... [Risk ▼] [Arm ▼] [Status ▼]│ │
│ ├─────┬──────────┬─────┬──────────┬──────────┬───────────┤ │
│ │Risk │ Subject │ Arm │ Last │ Open │ Latest │ │
│ │Score│ ID │ │ Check-In │ Alerts │ Symptom │ │
│ ├─────┼──────────┼─────┼──────────┼──────────┼───────────┤ │
│ │ 🔴 │ 001-0042 │ A │ 2h ago │ 2 (1 🔴) │ Nausea G3 │ │
│ │ 82 │ │ │ │ │ │ │
│ ├─────┼──────────┼─────┼──────────┼──────────┼───────────┤ │
│ │ 🟡 │ 001-0017 │ B │ 18h ago │ 1 (🟡) │ Fatigue G2│ │
│ │ 55 │ │ │ │ │ │ │
│ ├─────┼──────────┼─────┼──────────┼──────────┼───────────┤ │
│ │ 🟢 │ 001-0089 │ A │ 4h ago │ 0 │ None │ │
│ │ 12 │ │ │ │ │ │ │
│ └─────┴──────────┴─────┴──────────┴──────────┴───────────┘ │
│ │
│ Showing 1-25 of 142 patients [◀ 1 2 3 4 5 6 ▶] │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ← Back Patient 001-0042 Arm A Enrolled: 2026-01-15 │
│ Risk Score: 82 🔴 [Message Patient] [Export] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─── Tabs ─────────────────────────────────────────────────┐ │
│ │ [Timeline] [Symptoms] [Wearables] [Alerts] [Notes] │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ === TIMELINE VIEW === │
│ │
│ Mar 28 ─── 🔴 Alert: Resting HR elevated (88 bpm, baseline │
│ 10:30am 72±5). Z-score: 3.2 │
│ [Acknowledge] [Dismiss] [Escalate] │
│ │
│ Mar 28 ─── 💬 Check-in completed (voice) │
│ 08:15am Reported: Nausea (Grade 3), Headache (Grade 2) │
│ AI confidence: 0.94, 0.91 │
│ [Review & Confirm] [Edit Classification] │
│ │
│ Mar 27 ─── ⌚ Wearable sync │
│ 11:00pm HR avg: 82 bpm (↑12% vs baseline) │
│ Sleep: 5.2 hrs (↓22% vs baseline) │
│ Steps: 3,200 (↓45% vs baseline) │
│ │
│ Mar 27 ─── 💬 Check-in completed (text) │
│ 08:00am Reported: Nausea (Grade 2), Fatigue (Grade 1) │
│ ✅ CRC reviewed by J. Smith │
│ │
│ ═══════════════════════════════════════════════════════════ │
│ │
│ === WEARABLE CHARTS (visible in Wearables tab) === │
│ │
│ Resting Heart Rate (14-day trend) │
│ bpm │
│ 90 ┤ ●──● │
│ 85 ┤ ●──●──● │
│ 80 ┤ ●──●──● │
│ 75 ┤ ●──●──●──●──●──● ← baseline band │
│ 70 ┤ ●──●──●──●──● │
│ 65 ┤ │
│ └──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬── │
│ 14 13 12 11 10 9 8 7 6 5 4 3 2 1 │
│ Days Ago │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Cohort Analytics [Trial: ABC-123 ▼] Date Range: [════] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌── Summary Cards ──────────────────────────────────────────┐ │
│ │ Enrolled: 142 │ Active: 128 │ Withdrawn: 8 │ Completed: 6│ │
│ │ Avg Risk: 34 │ High Risk: 12 (8.5%) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌── AE Incidence by Treatment Arm ─────────────────────────┐ │
│ │ │ │
│ │ Symptom │ Arm A (n=71) │ Arm B (n=71) │ p-value │ │
│ │ ───────────────┼──────────────┼──────────────┼───────── │ │
│ │ Nausea │ 42% (30) │ 18% (13) │ 0.003* │ │
│ │ Headache │ 31% (22) │ 28% (20) │ 0.72 │ │
│ │ Fatigue │ 55% (39) │ 48% (34) │ 0.41 │ │
│ │ Rash │ 8% (6) │ 4% (3) │ 0.31 │ │
│ │ │ │
│ │ * statistically significant (Fisher's exact test) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌── Wearable Metric Distributions ─────────────────────────┐ │
│ │ [Box plots comparing Arm A vs Arm B for each metric] │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌── Engagement Over Time ──────────────────────────────────┐ │
│ │ [Line chart: check-in compliance % by week, per arm] │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Alert Queue Open: 10 Acknowledged: 5 Today: 3 │
│ [Critical Only] [Medium+] [All] [My Alerts ▼] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 🔴 CRITICAL │ 001-0042 │ 10:30 AM today │
│ Resting heart rate elevated to 88 bpm (baseline: 72±5 bpm). │
│ Z-score: 3.2. Trend: +2.3 bpm/day over 7 days. │
│ Patient also reported Grade 3 nausea this morning. │
│ [Acknowledge] [Contact Patient] [Escalate to PI] [Dismiss] │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 🟡 MEDIUM │ 001-0017 │ Yesterday 6:15 PM │
│ Missed scheduled check-in (2nd consecutive). │
│ Last completed check-in: 48 hours ago. │
│ Last wearable sync: 12 hours ago (device still active). │
│ [Acknowledge] [Contact Patient] [Dismiss] │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 🟡 MEDIUM │ 001-0091 │ Yesterday 2:00 PM │
│ SpO2 dropped to 91% (baseline: 97±1%). Single reading. │
│ No symptoms reported. Wearable data quality: good. │
│ [Acknowledge] [Monitor] [Contact Patient] [Dismiss] │
│ │
└─────────────────────────────────────────────────────────────────┘
The dashboard maintains a persistent WebSocket connection via native FastAPI WebSockets for live updates.
# app/ws/manager.py
import asyncio
import json
from fastapi import WebSocket
from typing import Dict, Set
class WebSocketManager:
"""Manages WebSocket connections for real-time dashboard updates."""
def __init__(self):
self._connections: Dict[str, Set[WebSocket]] = {} # trial_id -> set of websockets
async def connect(self, websocket: WebSocket, trial_id: str):
await websocket.accept()
if trial_id not in self._connections:
self._connections[trial_id] = set()
self._connections[trial_id].add(websocket)
def disconnect(self, websocket: WebSocket, trial_id: str):
if trial_id in self._connections:
self._connections[trial_id].discard(websocket)
async def broadcast(self, trial_id: str, event_type: str, payload: dict):
"""Broadcast an event to all dashboard clients watching a trial."""
message = json.dumps({"type": event_type, "payload": payload})
if trial_id in self._connections:
dead = set()
for ws in self._connections[trial_id]:
try:
await ws.send_text(message)
except Exception:
dead.add(ws)
self._connections[trial_id] -= dead
# Events that trigger real-time pushes:
WS_EVENTS = {
"alert:new": "Add to alert queue, update patient list risk indicator, show toast notification",
"checkin:completed": "Update patient list 'Last Check-In' column, refresh patient detail if open",
"anomaly:detected": "Update patient wearable indicators, may trigger alert:new subsequently",
"risk_score:updated": "Update patient list sorting, animate score change",
"alert:updated": "Update alert queue to prevent duplicate work",
}When a patient reports symptoms via the AI journal (text or voice), the CRC must review the AI's classification:
AI classifies symptom ──▶ Stored with crc_reviewed = false
│
▼
Dashboard shows yellow
"Pending Review" badge
on patient row
│
▼
CRC opens Patient Detail
──▶ Symptoms tab
│
▼
┌───────────────────────────────┐
│ Review Panel │
│ │
│ Patient said: "I've had a │
│ really bad headache behind │
│ my eyes for two days" │
│ (via: voice check-in) │
│ │
│ AI Classification: │
│ Term: Headache (10019211) │
│ Grade: 2 (Moderate) │
│ Confidence: 94% │
│ │
│ [✅ Confirm] [✏️ Override] │
│ │
│ Override options: │
│ Term: [searchable MedDRA │
│ dropdown] │
│ Severity: [1] [2] [3] [4] │
│ Onset: [date picker] │
│ Notes: [free text] │
│ │
└───────────────────────────────┘
│
▼
crc_reviewed = true
crc_reviewed_at = NOW()
crc_reviewed_by = staff_id
(crc_override fields if edited)
┌─────────────────────────────────────────────────────────────────┐
│ AI/ML LAYER (LangChain + LangGraph) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ LangChain Provider Abstraction │ │
│ │ │ │
│ │ Configured via env: LLM_PROVIDER / LLM_MODEL │ │
│ │ Supported: Anthropic, OpenAI, local (Ollama) │ │
│ │ Zero code changes to switch providers │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Check-In Conversation Agent (LangGraph) │ │
│ │ │ │
│ │ Stateful multi-step graph: │ │
│ │ greeting → feeling → screening → deep dive → │ │
│ │ protocol Qs → summary → closing │ │
│ │ │ │
│ │ Same graph serves both text and voice modalities │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Symptom Classifier (LangGraph) │ │
│ │ │ │
│ │ Input: Full check-in conversation + protocol context │ │
│ │ Steps: extract → classify → validate │ │
│ │ Output: JSON array of classified symptoms │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Anomaly Detection Engine │ │
│ │ │ │
│ │ Algorithms: │ │
│ │ 1. Z-score point anomaly detection │ │
│ │ 2. Sliding-window linear regression trends │ │
│ │ 3. Contextual filtering (suppress false positives) │ │
│ │ │ │
│ │ Libraries: numpy, scikit-learn, scipy │ │
│ │ Runs: On every wearable data batch + scheduled cron │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Risk Scoring Engine │ │
│ │ │ │
│ │ Inputs: │ │
│ │ - Latest symptom entries (severity, count, trend) │ │
│ │ - Wearable anomaly flags (count, magnitude) │ │
│ │ - Engagement metrics (check-in compliance, trends) │ │
│ │ - Protocol compliance (visit attendance, timing) │ │
│ │ │ │
│ │ Algorithm: Weighted composite score (0-100) │ │
│ │ Runs: After every new data point + daily recalc │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
# app/modules/alert/risk_scoring.py
async def calculate_risk_score(patient_id: str, lookback_days: int = 7) -> dict:
"""
Calculate a composite risk score (0-100) for a patient.
Called after every new symptom entry, wearable anomaly, or missed check-in.
Also recalculated daily via APScheduler cron job.
"""
# ─── COMPONENT 1: Symptom Score (0-40) ───
recent_symptoms = await get_symptoms(patient_id, days=lookback_days)
symptom_score = 0
if recent_symptoms:
max_severity = max(s["severity_grade"] for s in recent_symptoms)
symptom_count = len(recent_symptoms)
severity_map = {1: 3, 2: 8, 3: 15, 4: 22, 5: 25}
symptom_score += severity_map.get(max_severity, 0)
symptom_score += min(symptom_count * 2, 10)
if await is_symptom_worsening(recent_symptoms):
symptom_score += 5
# ─── COMPONENT 2: Wearable Score (0-30) ───
recent_anomalies = await get_anomalies(patient_id, days=lookback_days)
wearable_score = 0
if recent_anomalies:
severity_weights = {"low": 3, "medium": 8, "high": 15}
anomaly_sum = sum(severity_weights.get(a["severity"], 0) for a in recent_anomalies)
wearable_score += min(anomaly_sum, 20)
unique_metrics = len(set(a["metric"] for a in recent_anomalies))
if unique_metrics >= 2:
wearable_score += min(unique_metrics * 3, 10)
# ─── COMPONENT 3: Engagement Score (0-15) ───
checkin_compliance = await get_checkin_compliance(patient_id, days=lookback_days)
engagement_score = 0
if checkin_compliance < 0.5:
engagement_score = 15
elif checkin_compliance < 0.7:
engagement_score = 10
elif checkin_compliance < 0.85:
engagement_score = 5
consecutive_misses = await get_consecutive_missed_checkins(patient_id)
if consecutive_misses >= 3:
engagement_score = min(engagement_score + 5, 15)
# ─── COMPONENT 4: Compliance Score (0-15) ───
compliance_score = 0
missed_visits = await get_missed_visits(patient_id, days=30)
if missed_visits > 0:
compliance_score += min(missed_visits * 5, 10)
out_of_window = await get_out_of_window_submissions(patient_id, days=lookback_days)
if out_of_window > 2:
compliance_score += 5
compliance_score = min(compliance_score, 15)
# ─── COMPOSITE ───
total_score = min(
symptom_score + wearable_score + engagement_score + compliance_score, 100
)
tier = "low" if total_score <= 30 else ("medium" if total_score <= 70 else "high")
return {
"score": total_score,
"tier": tier,
"symptom_component": symptom_score,
"wearable_component": wearable_score,
"engagement_component": engagement_score,
"compliance_component": compliance_score,
"contributing_factors": await build_factor_list(
recent_symptoms, recent_anomalies, checkin_compliance, missed_visits
),
}The Alert Engine evaluates incoming events against a configurable set of rules. It runs as an in-process subscriber to the Redis event bus.
# app/modules/alert/rules.py
ALERT_RULES = [
{
"id": "severe_symptom",
"trigger": "symptom.reported",
"condition": lambda event: event["severity_grade"] >= 3,
"severity": lambda event: "critical" if event["severity_grade"] >= 4 else "medium",
"title": lambda event: f"Grade {event['severity_grade']} {event['meddra_pt_term']} reported",
"description": lambda event: (
f"Patient reported {event['meddra_pt_term']} "
f"(Grade {event['severity_grade']}) during check-in. "
f"Onset: {event.get('onset_date', 'not specified')}. "
f"AI confidence: {event.get('ai_confidence', 'N/A')}"
),
},
{
"id": "sae_keywords",
"trigger": "symptom.reported",
"condition": lambda event: any(
kw in event.get("symptom_text", "").lower()
for kw in ["chest pain", "can't breathe", "seizure", "passed out",
"allergic reaction", "swelling of face", "suicidal"]
),
"severity": lambda _: "critical",
"title": lambda _: "Potential serious adverse event detected",
"description": lambda event: (
f"Patient's symptom description contains SAE-suggestive keywords. "
f"Original text: \"{event['symptom_text'][:200]}\". "
f"Immediate clinical review recommended."
),
},
{
"id": "wearable_anomaly",
"trigger": "anomaly.detected",
"condition": lambda event: event["severity"] in ("medium", "high"),
"severity": lambda event: "high" if event["severity"] == "high" else "medium",
"title": lambda event: (
f"{event['metric'].replace('_', ' ').title()} "
f"{'anomaly' if event['anomaly_type'] == 'point_anomaly' else 'trend'} detected"
),
"description": lambda event: (
f"{event['metric'].replace('_', ' ').title()} value: {event['value']} "
f"(baseline: {event['baseline_mean']}±{event.get('baseline_stddev', '?')}). "
),
},
{
"id": "missed_checkin",
"trigger": "checkin.missed",
"condition": lambda event: True,
"severity": lambda event: "medium" if event.get("consecutive_misses", 1) >= 2 else "low",
"title": lambda event: (
f"Missed check-in"
f"{' (' + str(event['consecutive_misses']) + ' consecutive)' if event.get('consecutive_misses', 1) > 1 else ''}"
),
"description": lambda event: (
f"Patient did not complete their scheduled check-in. "
f"Last completed check-in: {event.get('last_checkin', 'unknown')}. "
f"Last wearable sync: {event.get('last_sync', 'unknown')}."
),
},
{
"id": "risk_score_elevated",
"trigger": "risk_score.updated",
"condition": lambda event: (
event["new_tier"] == "high" and event.get("old_tier") != "high"
),
"severity": lambda _: "high",
"title": lambda _: "Patient risk score elevated to HIGH",
"description": lambda event: (
f"Risk score increased from {event.get('old_score', '?')} to {event['new_score']}. "
f"Contributing factors: {', '.join(f['factor'] for f in event.get('contributing_factors', [])[:3])}."
),
},
]# app/modules/alert/deduplication.py
from sqlalchemy import select, and_
from datetime import datetime, timedelta
async def should_create_alert(
db_session, rule_id: str, patient_id: str, new_alert: dict
) -> bool:
"""Check if we should create a new alert or suppress it as a duplicate."""
cutoff = datetime.utcnow() - timedelta(hours=24)
result = await db_session.execute(
select(Alert).where(
and_(
Alert.patient_id == patient_id,
Alert.alert_type == rule_id,
Alert.status.in_(["open", "acknowledged"]),
Alert.created_at > cutoff,
)
)
)
existing = result.scalars().first()
if existing:
await update_existing_alert(db_session, existing.id, new_alert)
return False
return TrueHackathon note: For the prototype, authentication is bypassed using hardcoded demo accounts. The JWT flow is implemented but simplified — no MFA, no biometric setup. Tokens are long-lived for demo convenience.
Patient Onboarding (simplified for hackathon):
1. Patient opens app, selects from pre-loaded demo accounts
2. JWT issued with claims: { patient_id, site_id, trial_id, role: "patient" }
3. No email/password required for demo
Session Management:
- Access tokens: 24-hour expiry (extended for demo)
- No refresh token rotation for hackathon
Staff Login (simplified for hackathon):
1. Select demo account from dropdown (CRC, PI, Medical Monitor)
2. JWT issued immediately — no password or MFA
RBAC Matrix (enforced even in hackathon):
┌────────────────────┬─────┬─────┬─────────────────┬───────────────┐
│ Permission │ CRC │ PI │ Medical Monitor │ Study Manager │
├────────────────────┼─────┼─────┼─────────────────┼───────────────┤
│ View patient data │ Own │ Own │ All sites │ All sites │
│ (own site only) │ site│ site│ │ │
├────────────────────┼─────┼─────┼─────────────────┼───────────────┤
│ Review symptoms │ ✅ │ ✅ │ ✅ (read-only) │ ❌ │
├────────────────────┼─────┼─────┼─────────────────┼───────────────┤
│ Manage alerts │ ✅ │ ✅ │ ✅ │ ❌ │
├────────────────────┼─────┼─────┼─────────────────┼───────────────┤
│ View cohort │ Own │ Own │ ✅ │ ✅ │
│ analytics │ site│ site│ │ │
├────────────────────┼─────┼─────┼─────────────────┼───────────────┤
│ Configure protocol │ ❌ │ ❌ │ ✅ │ ✅ │
├────────────────────┼─────┼─────┼─────────────────┼───────────────┤
│ Export data │ ❌ │ ✅ │ ✅ │ ✅ │
└────────────────────┴─────┴─────┴─────────────────┴───────────────┘
Hackathon note: Full HIPAA/GDPR/21 CFR Part 11 compliance is out of scope for the prototype. All data is mock data on a local machine. The following section documents the production-intent design for reference.
| Safeguard | Production Implementation |
|---|---|
| Access Control | RBAC with per-site, per-trial permissions; JWT with scoped claims; automatic session timeout |
| Audit Controls | Immutable audit_log table records all data access and modifications |
| Integrity Controls | Database-level constraints; application-level input validation |
| Transmission Security | TLS 1.3 for all API traffic; certificate pinning in mobile app |
| Encryption | AES-256 at rest; application-level field encryption for PII |
- No TLS (all traffic is localhost HTTP)
- No encryption at rest
- No audit logging
- No PII — all patient data is synthetic
- Hardcoded demo accounts, no real authentication
- No consent management
All endpoints are served by the single FastAPI monolith. The base URL for local development is http://localhost:8000/api/v1.
BASE URL: http://localhost:8000/api/v1
AUTHENTICATION (hackathon):
Demo mode: Authorization: Bearer <hardcoded-demo-jwt>
Patient endpoints: JWT with role=patient
Staff endpoints: JWT with role in (crc, pi, medical_monitor, study_manager)
───────────────────────────────────────────────────────────────
PATIENT ENDPOINTS
───────────────────────────────────────────────────────────────
POST /auth/patient/demo-login
Body: { patient_id: "uuid" }
Response: { patient_id, access_token }
GET /patient/profile
Response: { patient_id, subject_id, trial_name, site_name,
enrollment_date, checkin_frequency, wearable_connected }
POST /checkins/start
Body: { session_type: "scheduled" | "ad_hoc", modality: "text" | "voice" }
Response: { session_id, first_message: { role: "ai", content: "..." },
voice_room_token: "..." (if modality=voice) }
POST /checkins/{session_id}/message
Body: { content: "patient's message text",
message_type: "text" | "quick_reply" | "scale_rating",
selected_reply: "option text" (if quick_reply) }
Response: { ai_response: { role: "ai", content: "...",
quick_replies: ["option1", "option2"] | null },
session_status: "in_progress" | "completed" }
GET /checkins/history?limit=20&offset=0
Response: { sessions: [{ session_id, started_at, completed_at,
modality, symptoms_count, overall_feeling }], total: 45 }
GET /symptoms/history?limit=20&offset=0
Response: { symptoms: [{ id, symptom_text, meddra_pt_term,
severity_grade, onset_date, is_ongoing, created_at }] }
POST /wearable/sync
Body: { readings: [{ metric, value, timestamp, source }] }
Response: { accepted: 150, rejected: 2, anomalies_detected: 0 }
GET /wearable/summary?days=7
Response: { metrics: { heart_rate: { latest, avg, trend },
steps: { today, avg_7d }, sleep: { last_night, avg_7d },
spo2: { latest, avg } }, anomalies: [...] }
───────────────────────────────────────────────────────────────
VOICE CHECK-IN ENDPOINTS
───────────────────────────────────────────────────────────────
POST /voice/create-room
Body: { patient_id, session_id }
Response: { room_name, participant_token (LiveKit JWT) }
GET /voice/transcript/{session_id}
Response: { messages: [{ role, content, timestamp }] }
───────────────────────────────────────────────────────────────
STAFF / DASHBOARD ENDPOINTS
───────────────────────────────────────────────────────────────
POST /auth/staff/demo-login
Body: { staff_id: "uuid" }
Response: { access_token, staff: { id, role, sites } }
GET /dashboard/patients?trial_id=X&site_id=Y&sort=risk_score&order=desc
&page=1&per_page=25&risk_tier=high&arm=A
Response: { patients: [{ patient_id, subject_id, treatment_arm,
risk_score, risk_tier, last_checkin_at, open_alerts,
latest_symptom, wearable_status }], total: 142, page: 1 }
GET /dashboard/patients/{patient_id}/timeline?days=30
Response: { events: [{ type, timestamp, title, details, severity }] }
GET /dashboard/patients/{patient_id}/symptoms?status=pending_review
Response: { symptoms: [{ id, symptom_text, meddra_pt_term,
severity_grade, ai_confidence, crc_reviewed, ... }] }
PUT /dashboard/patients/{patient_id}/symptoms/{symptom_id}/review
Body: { action: "confirm" | "override",
override_term, override_grade, notes }
Response: { symptom_id, crc_reviewed: true, reviewed_at, reviewed_by }
GET /dashboard/patients/{patient_id}/wearable?metric=heart_rate&days=14
Response: { data_points: [{ timestamp, value }],
baseline: { mean, stddev }, anomalies: [...] }
GET /dashboard/alerts?status=open&severity=critical,high&page=1
Response: { alerts: [...], total: 10 }
PUT /dashboard/alerts/{alert_id}
Body: { action: "acknowledge" | "resolve" | "dismiss" | "escalate",
note: "resolution note text" }
Response: { alert_id, status, updated_at }
GET /dashboard/cohort/ae-incidence?trial_id=X&days=30
Response: { arms: { A: { n: 71, aes: { ... } }, B: { ... } } }
GET /dashboard/cohort/wearable-distributions?trial_id=X&metric=resting_heart_rate
Response: { arms: { A: { median, q1, q3, min, max }, B: { ... } } }
───────────────────────────────────────────────────────────────
WEBSOCKET ENDPOINT
───────────────────────────────────────────────────────────────
WS /ws/dashboard?trial_id=X&token=<jwt>
Events pushed: alert:new, checkin:completed, anomaly:detected,
risk_score:updated, alert:updated
# docker-compose.yml
version: '3.8'
services:
# ─── Databases ───
postgres:
image: postgres:16
environment:
POSTGRES_DB: trialpulse
POSTGRES_USER: tp_admin
POSTGRES_PASSWORD: tp_hackathon_2026
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./db/seed.sql:/docker-entrypoint-initdb.d/02-seed.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tp_admin -d trialpulse"]
interval: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 5
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: trialpulse
MINIO_ROOT_PASSWORD: tp_hackathon_2026
volumes:
- minio_data:/data
ports:
- "9000:9000"
- "9001:9001" # MinIO console
# ─── LiveKit Server (self-hosted) ───
livekit:
image: livekit/livekit-server:latest
command: --config /etc/livekit.yaml
volumes:
- ./infra/livekit.yaml:/etc/livekit.yaml
ports:
- "7880:7880" # HTTP
- "7881:7881" # WebRTC TCP
- "7882:7882/udp" # WebRTC UDP
depends_on:
- redis
# ─── Python FastAPI Backend (monolith) ───
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
DATABASE_URL: postgresql+asyncpg://tp_admin:tp_hackathon_2026@postgres:5432/trialpulse
REDIS_URL: redis://redis:6379
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: trialpulse
MINIO_SECRET_KEY: tp_hackathon_2026
LLM_PROVIDER: anthropic
LLM_MODEL: claude-sonnet-4-20250514
LLM_API_KEY: ${LLM_API_KEY}
LIVEKIT_URL: ws://livekit:7880
LIVEKIT_API_KEY: devkey
LIVEKIT_API_SECRET: devsecret
DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY:-}
ENVIRONMENT: development
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
livekit:
condition: service_started
volumes:
- ./backend:/app # hot reload in dev
# ─── React Web Dashboard ───
dashboard:
build: ./apps/dashboard
ports:
- "3000:3000"
environment:
VITE_API_URL: http://localhost:8000/api/v1
VITE_WS_URL: ws://localhost:8000/ws
# ─── Nginx Reverse Proxy ───
nginx:
image: nginx:alpine
volumes:
- ./infra/nginx.dev.conf:/etc/nginx/nginx.conf
ports:
- "8080:8080"
depends_on:
- backend
- dashboard
volumes:
pgdata:
minio_data:LiveKit Configuration:
# infra/livekit.yaml
port: 7880
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 60000
use_external_ip: false
redis:
address: redis:6379
keys:
devkey: devsecret
logging:
level: info# backend/Dockerfile
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Run with uvicorn (auto-reload in dev)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]requirements.txt:
# Web framework
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pydantic>=2.9.0
pydantic-settings>=2.5.0
# Database
sqlalchemy[asyncio]>=2.0.35
asyncpg>=0.30.0
alembic>=1.13.0
# Redis
redis[hiredis]>=5.2.0
# LLM Orchestration (vendor-agnostic)
langchain>=0.3.0
langchain-core>=0.3.0
langchain-anthropic>=0.3.0
langchain-openai>=0.2.0
langgraph>=0.2.0
# LiveKit
livekit>=0.17.0
livekit-agents>=0.11.0
livekit-plugins-deepgram>=0.7.0
livekit-plugins-silero>=0.7.0
# ML / Data
numpy>=1.26.0
scikit-learn>=1.5.0
scipy>=1.14.0
# Auth
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
# Object storage
minio>=7.2.0
# Scheduling
apscheduler>=3.10.0
# Utilities
httpx>=0.27.0
python-multipart>=0.0.9
trialpulse/
├── apps/
│ ├── mobile/ # React Native patient app (Expo)
│ │ ├── src/
│ │ │ ├── screens/
│ │ │ │ ├── HomeScreen.tsx
│ │ │ │ ├── ChatScreen.tsx
│ │ │ │ ├── VoiceCheckInScreen.tsx
│ │ │ │ ├── TimelineScreen.tsx
│ │ │ │ ├── WearableScreen.tsx
│ │ │ │ └── ProfileScreen.tsx
│ │ │ ├── components/
│ │ │ │ ├── ChatBubble.tsx
│ │ │ │ ├── QuickReplyBar.tsx
│ │ │ │ ├── SeveritySlider.tsx
│ │ │ │ ├── MetricCard.tsx
│ │ │ │ ├── TimelineEvent.tsx
│ │ │ │ ├── VoiceAgentAvatar.tsx
│ │ │ │ └── LiveTranscript.tsx
│ │ │ ├── services/
│ │ │ │ ├── api.ts
│ │ │ │ ├── healthkit.ts
│ │ │ │ ├── healthconnect.ts
│ │ │ │ └── livekit.ts
│ │ │ ├── stores/
│ │ │ │ ├── auth.store.ts
│ │ │ │ ├── checkin.store.ts
│ │ │ │ └── wearable.store.ts
│ │ │ └── App.tsx
│ │ ├── package.json
│ │ └── app.json
│ │
│ └── dashboard/ # React web dashboard
│ ├── src/
│ │ ├── pages/
│ │ │ ├── PatientListPage.tsx
│ │ │ ├── PatientDetailPage.tsx
│ │ │ ├── CohortAnalyticsPage.tsx
│ │ │ ├── AlertQueuePage.tsx
│ │ │ └── LoginPage.tsx
│ │ ├── components/
│ │ │ ├── PatientTable.tsx
│ │ │ ├── RiskScoreBadge.tsx
│ │ │ ├── PatientTimeline.tsx
│ │ │ ├── WearableChart.tsx
│ │ │ ├── SymptomReviewPanel.tsx
│ │ │ ├── AlertCard.tsx
│ │ │ ├── CohortAETable.tsx
│ │ │ └── BoxPlotChart.tsx
│ │ ├── hooks/
│ │ │ ├── useWebSocket.ts
│ │ │ ├── usePatients.ts
│ │ │ └── useAlerts.ts
│ │ ├── services/
│ │ │ ├── api.ts
│ │ │ └── websocket.ts
│ │ └── App.tsx
│ └── package.json
│
├── backend/ # Python FastAPI monolith
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py # App factory, lifespan, middleware
│ │ ├── config.py # Pydantic Settings
│ │ ├── deps.py # Dependency injection
│ │ │
│ │ ├── modules/
│ │ │ ├── auth/
│ │ │ │ ├── router.py
│ │ │ │ ├── service.py
│ │ │ │ └── jwt.py
│ │ │ ├── checkin/
│ │ │ │ ├── router.py
│ │ │ │ └── service.py
│ │ │ ├── wearable/
│ │ │ │ ├── router.py
│ │ │ │ ├── service.py
│ │ │ │ ├── normalization.py
│ │ │ │ └── anomaly_detection.py
│ │ │ ├── alert/
│ │ │ │ ├── router.py
│ │ │ │ ├── service.py
│ │ │ │ ├── rules.py
│ │ │ │ ├── deduplication.py
│ │ │ │ └── risk_scoring.py
│ │ │ ├── analytics/
│ │ │ │ ├── router.py
│ │ │ │ └── service.py
│ │ │ ├── dashboard/
│ │ │ │ ├── router.py
│ │ │ │ └── service.py
│ │ │ └── voice/
│ │ │ ├── router.py
│ │ │ ├── agent.py
│ │ │ └── service.py
│ │ │
│ │ ├── ai/
│ │ │ ├── llm.py # Vendor-agnostic LLM factory
│ │ │ ├── chains/
│ │ │ │ ├── conversation.py
│ │ │ │ └── classifier.py
│ │ │ ├── graphs/
│ │ │ │ ├── checkin_graph.py
│ │ │ │ └── classifier_graph.py
│ │ │ ├── prompts/
│ │ │ │ ├── checkin_system.py
│ │ │ │ └── classifier_system.py
│ │ │ └── tools/
│ │ │ └── meddra_lookup.py
│ │ │
│ │ ├── models/ # SQLAlchemy ORM models
│ │ │ ├── __init__.py
│ │ │ ├── trial.py
│ │ │ ├── patient.py
│ │ │ ├── checkin.py
│ │ │ ├── symptom.py
│ │ │ ├── wearable.py
│ │ │ ├── alert.py
│ │ │ └── staff.py
│ │ │
│ │ ├── schemas/ # Pydantic schemas
│ │ │ ├── checkin.py
│ │ │ ├── symptom.py
│ │ │ ├── wearable.py
│ │ │ ├── alert.py
│ │ │ └── dashboard.py
│ │ │
│ │ ├── events/
│ │ │ └── bus.py # Redis pub/sub event bus
│ │ │
│ │ └── ws/
│ │ └── manager.py # WebSocket connection manager
│ │
│ ├── Dockerfile
│ ├── requirements.txt
│ └── alembic/
│ ├── alembic.ini
│ └── versions/
│
├── db/
│ ├── init.sql # Schema from Section 4
│ └── seed.sql # Mock data for demo (Section 16)
│
├── infra/
│ ├── docker-compose.yml
│ ├── nginx.dev.conf
│ └── livekit.yaml
│
├── scripts/
│ ├── seed_demo_data.py # Python script to generate mock data
│ ├── generate_wearable_data.py # Generate 30 days of wearable readings
│ └── run_demo_scenario.py # Automated demo scenario runner
│
├── docs/
│ ├── DESIGN.md # This document
│ └── API.md # OpenAPI spec (auto-generated by FastAPI)
│
├── .env.example # Environment variable template
└── README.md
| Level | Scope | Tools | Coverage Target |
|---|---|---|---|
| Unit Tests | Individual functions, utilities, data transformations | pytest + pytest-asyncio | >80% line coverage |
| Integration Tests | API endpoints, database interactions, event bus | httpx + pytest + Testcontainers | All API endpoints |
| E2E Tests | Full user flows through the mobile app and dashboard | Detox (mobile), Playwright (dashboard) | Critical paths: check-in, alert triage |
| AI/ML Tests | Symptom classifier accuracy, anomaly detection precision/recall | Custom test harness with labeled datasets | >90% classification accuracy, <15% false positive rate |
PATIENT APP:
✓ Patient can log in with demo account
✓ Text check-in conversation flows from greeting to completion
✓ Voice check-in connects to LiveKit room and produces transcript
✓ AI correctly follows up on reported symptoms
✓ Quick replies are selectable and sent correctly
✓ Wearable data syncs from HealthKit/Health Connect (or mock)
DASHBOARD:
✓ Patient list loads sorted by risk score
✓ Real-time WebSocket updates reflect new alerts
✓ CRC can review and confirm AI symptom classification
✓ CRC can override AI classification with different MedDRA term
✓ Alert acknowledgment updates status for all viewers
✓ Cohort analytics correctly compare treatment arms
ALERT ENGINE:
✓ Grade 3+ symptom triggers medium/critical alert
✓ SAE keyword detection triggers critical alert
✓ Wearable anomaly above threshold triggers alert
✓ 2+ consecutive missed check-ins triggers medium alert
✓ Risk score crossing into "high" tier triggers alert
✓ Duplicate alerts within 24 hours are merged, not duplicated
AI/ML (LangGraph):
✓ Symptom classifier maps "bad headache" → Headache (10019211)
✓ Classifier assigns appropriate CTCAE grade based on severity description
✓ LangGraph check-in flow progresses through all states correctly
✓ Voice transcript is correctly fed into the same LangGraph pipeline
✓ Anomaly detector flags HR of 95 when baseline is 70±5
✓ Anomaly detector does NOT flag HR of 120 during reported exercise
✓ Risk score increases when multiple signals co-occur
-
Patient Chat Interface (React Native) — A working conversational symptom check-in for oncology. Built as a React Native Expo app, demoed on a real mobile device. Uses LangChain + LangGraph for conversation orchestration.
-
Voice Check-In (LiveKit) — Real-time voice mode where the patient speaks to the AI agent. LiveKit server self-hosted in Docker. STT → LangGraph → TTS pipeline. Live transcript displayed on screen.
-
Simulated Wearable Dashboard — Patient-facing view showing mock wearable data (heart rate, steps, sleep) with sparkline charts. Data is pre-generated. One visible anomaly flag on the heart rate chart.
-
Researcher Dashboard (Basic) — Web page showing patient list with risk scores, color-coded by tier. Patient detail with timeline. Alert queue with acknowledge/dismiss buttons. Real-time WebSocket updates.
-
End-to-End Flow Demo — Patient completes check-in (text or voice) → AI classifies symptoms → risk score updates → alert appears on researcher dashboard in real time. Split-screen or two-device demo.
- CRC Symptom Review Panel — Confirm/override workflow for AI-classified symptoms.
- Cohort Analytics View — Bar chart comparing AE rates between two mock treatment arms.
- HIPAA compliance, encryption, audit logging
- Multi-language support
- EDC/CTMS integrations
- Real user registration and authentication (use hardcoded demo accounts)
- Offline support
- Push notifications
- CI/CD, monitoring, log aggregation, secrets management, CDN
- Cloud deployment of any kind
| Component | Hackathon Choice | Why |
|---|---|---|
| Patient App | React Native (Expo) | Demo on a real mobile device; cross-platform |
| Dashboard | React + Tailwind + Recharts | Fast styling; Recharts for quick charts |
| Backend | Python FastAPI monolith (async) | Single service; no microservice coordination overhead; hot reload |
| LLM Orchestration | LangChain + LangGraph | Vendor-agnostic; swap providers via env var; stateful check-in graphs |
| Voice | LiveKit (self-hosted Docker) + Deepgram STT | Low-latency real-time voice; self-contained in Docker |
| Database | PostgreSQL 16 (Docker) | Full-featured; stores everything including wearable time-series |
| Cache/Events | Redis 7 (Docker) | Pub/sub event bus + caching |
| Object Storage | MinIO (Docker) | S3-compatible; local; no cloud account needed |
| Wearable Data | Pre-generated Python seed script | Simulated 30-day history for 5 mock patients with anomaly patterns |
| Deployment | Docker Compose (localhost) | Single docker compose up to start everything |
Create a seed script that generates 5 mock patients with realistic data:
Patient 001: "Healthy" — no symptoms, normal wearables, risk score 8
Patient 002: "Mild symptoms" — Grade 1 fatigue, normal wearables, risk score 22
Patient 003: "Concerning trend" — Grade 2 nausea + rising resting HR (the demo patient), risk score 67
Patient 004: "Missed check-ins" — No symptoms but 3 missed check-ins, risk score 45
Patient 005: "High risk" — Grade 3 nausea + Grade 2 headache + HR anomaly + declining sleep, risk score 85
This gives the demo a realistic spread of scenarios to showcase during the presentation.
This section provides step-by-step instructions for setting up the complete demo environment with mock data, preparing the system for a live demo in front of a judging panel.
Before starting, ensure the following are installed on the demo machine:
- Docker Desktop (v4.25+) with Docker Compose v2
- Node.js (v20+) and npm/yarn
- Python (3.12+) — only needed if running seed scripts outside Docker
- Expo CLI (
npm install -g expo-cli) for the React Native mobile app - Expo Go app installed on the demo phone (iOS or Android)
- API keys (stored in
.envfile):LLM_API_KEY— Anthropic API key (or OpenAI, depending onLLM_PROVIDER)DEEPGRAM_API_KEY— Deepgram API key for voice STT (optional; voice demo requires this)
Step 1: Clone and configure environment
git clone <repo-url> trialpulse
cd trialpulse
cp .env.example .env
# Edit .env to add your API keys:
# LLM_API_KEY=sk-ant-...
# LLM_PROVIDER=anthropic
# LLM_MODEL=claude-sonnet-4-20250514
# DEEPGRAM_API_KEY=... (for voice demo)Step 2: Start all services
docker compose up -d --buildThis starts: PostgreSQL, Redis, MinIO, LiveKit, the FastAPI backend, the React dashboard, and Nginx. The database schema (init.sql) and seed data (seed.sql) are automatically loaded on first boot.
Step 3: Verify services are running
docker compose ps
# All services should show "running" / "healthy"
# Test backend health
curl http://localhost:8000/api/v1/health
# Expected: {"status": "ok", "services": {"postgres": "connected", "redis": "connected", "livekit": "connected"}}
# Test dashboard
open http://localhost:3000Step 4: Start the mobile app
cd apps/mobile
npm install
npx expo start
# Scan the QR code with Expo Go on the demo phone
# Ensure demo phone and laptop are on the same WiFi networkThe seed script (scripts/seed_demo_data.py) generates the following mock data. Run it if the Docker init scripts didn't auto-seed:
cd scripts
python seed_demo_data.pyTrial: "ONCO-2026-TP1"
Sponsor: "Meridian Therapeutics"
Title: "Phase II Study of MT-401 in Advanced Non-Small Cell Lung Cancer"
Therapeutic Area: Oncology
Phase: II
Status: Active
Protocol Config:
Check-in Frequency: Daily
Check-in Window: 24 hours
Expected Side Effects: Nausea, Fatigue, Headache, Rash, Diarrhoea, Alopecia
Wearable Metrics: heart_rate, resting_heart_rate, steps, sleep_minutes, spo2
Alert Thresholds:
heart_rate_resting_max: 100
spo2_min: 92
anomaly_z_threshold: 2.5
Site: "001 — Memorial City Cancer Center"
Country: US
Timezone: America/Chicago
Staff Accounts (all use password "demo2026" — or no password in demo mode):
Dr. Sarah Chen | PI | staff_id: <uuid-pi>
James Smith | CRC | staff_id: <uuid-crc>
Dr. Rachel Torres | Medical Monitor | staff_id: <uuid-mm>
Patient 001 — "Maria Gonzalez" (Healthy Baseline)
Subject ID: 001-0089
Treatment Arm: A (MT-401)
Enrollment Date: 2026-02-26
Risk Score: 8 (LOW 🟢)
Check-In History: 28/30 days completed (93% compliance)
Symptoms: None reported
Wearable Data:
Resting HR: 68±3 bpm (stable, no anomalies)
Steps: 7,200±1,500/day
Sleep: 7.5±0.8 hrs/night
SpO2: 97±1%
Alerts: None
Patient 002 — "Robert Kim" (Mild Symptoms)
Subject ID: 001-0017
Treatment Arm: B (Placebo)
Enrollment Date: 2026-02-20
Risk Score: 22 (LOW 🟢)
Check-In History: 25/30 days completed (83% compliance)
Symptoms:
- Fatigue (Grade 1), ongoing since Day 10, AI confidence: 0.91
- Headache (Grade 1), resolved after 3 days, AI confidence: 0.89
Wearable Data:
Resting HR: 72±4 bpm (normal)
Steps: 5,800±2,000/day (slightly below average — matches fatigue)
Sleep: 6.8±1.0 hrs/night
SpO2: 98±1%
Alerts: None (Grade 1 symptoms don't trigger alerts)
Patient 003 — "David Thompson" (Concerning Trend — THE DEMO PATIENT)
Subject ID: 001-0042
Treatment Arm: A (MT-401)
Enrollment Date: 2026-02-15
Risk Score: 67 (MEDIUM 🟡) → will escalate to 82 (HIGH 🔴) during live demo
Check-In History: 26/30 days completed (87% compliance)
Symptoms (escalating pattern over last 2 weeks):
Day 16: Fatigue (Grade 1) — AI confidence: 0.88
Day 20: Nausea (Grade 1) — AI confidence: 0.92
Day 24: Nausea (Grade 2) + Fatigue (Grade 1) — AI confidence: 0.94, 0.87
Day 27: Nausea (Grade 2) + Headache (Grade 1) — AI confidence: 0.91, 0.85
Day 28: [LIVE DEMO — patient will check in with Grade 3 nausea + Grade 2 headache]
Wearable Data (anomaly pattern baked in):
Resting HR: Started at 72±5 bpm, gradually rising:
Days 1-14: 70-74 bpm (normal baseline)
Days 15-21: 74-78 bpm (subtle increase)
Days 22-28: 78-88 bpm (accelerating — trend anomaly detected on Day 25)
Day 28: 88 bpm (point anomaly: z-score 3.2)
Steps: Declining from 6,500 to 3,200/day over last 10 days
Sleep: Declining from 7.2 hrs to 5.2 hrs over last 7 days
SpO2: 96±1% (normal)
Alerts (pre-seeded):
Day 25: 🟡 MEDIUM — Resting HR trend detected (+2.3 bpm/day over 7 days)
Day 27: 🟡 MEDIUM — Sleep duration declining (22% below baseline)
[LIVE DEMO will generate: 🔴 CRITICAL — Resting HR point anomaly + Grade 3 symptom]
Patient 004 — "Jennifer Walsh" (Missed Check-Ins)
Subject ID: 001-0055
Treatment Arm: B (Placebo)
Enrollment Date: 2026-02-22
Risk Score: 45 (MEDIUM 🟡)
Check-In History: 18/30 days completed (60% compliance)
Last 5 days: MISSED, MISSED, MISSED, completed, MISSED
Symptoms: None reported (but low engagement is concerning)
Wearable Data:
Resting HR: 75±6 bpm (normal but higher variance)
Steps: 4,100±2,500/day (irregular)
Sleep: 6.0±1.5 hrs/night (irregular)
SpO2: 97±1%
Last wearable sync: 12 hours ago (device still active)
Alerts:
Day 26: 🟡 MEDIUM — Missed check-in (2nd consecutive)
Day 28: 🟡 MEDIUM — Missed check-in (3rd consecutive)
Patient 005 — "Thomas Okafor" (High Risk)
Subject ID: 001-0033
Treatment Arm: A (MT-401)
Enrollment Date: 2026-02-18
Risk Score: 85 (HIGH 🔴)
Check-In History: 27/30 days completed (90% compliance)
Symptoms (multiple concurrent):
Day 18: Nausea (Grade 2) — AI confidence: 0.93
Day 22: Nausea (Grade 3) + Headache (Grade 2) — AI confidence: 0.95, 0.90
Day 25: Nausea (Grade 3) + Headache (Grade 2) + Fatigue (Grade 2) — AI confidence: 0.96, 0.92, 0.88
Day 27: Nausea (Grade 3, ongoing) + Headache (Grade 2, ongoing) — already reviewed by CRC
Wearable Data:
Resting HR: 82±6 bpm (elevated; baseline was 70±4)
Steps: 2,100/day (severely reduced from baseline of 8,000)
Sleep: 4.5 hrs/night (severely reduced from baseline of 7.8)
SpO2: 94±2% (borderline low)
Alerts:
Day 22: 🔴 CRITICAL — Grade 3 nausea reported
Day 23: 🔴 HIGH — Resting HR elevated (z-score: 3.0)
Day 25: 🔴 HIGH — Risk score elevated to HIGH (was MEDIUM)
Day 25: 🟡 MEDIUM — SpO2 dropped to 91% (single reading)
Follow this script during the live demo. It's designed to show the end-to-end flow in approximately 8-10 minutes.
Setup before going on stage:
- Open the researcher dashboard on a laptop browser at
http://localhost:3000 - Log in as CRC "James Smith" — the patient list should show all 5 patients
- Have the React Native app open on the demo phone with Patient 003 (David Thompson) logged in
- Have two browser tabs ready: one for the dashboard, one for the patient detail view of Patient 003
- Optionally, run the automated demo scenario to pre-trigger the voice check-in background state:
python scripts/run_demo_scenario.py --prepare
Demo Script:
Act 1: "The Problem" (1 minute)
- Show the dashboard with 5 patients. Point out the risk score distribution.
- Highlight Patient 005 (high risk, 🔴) — "This patient has been flagged by our system for multiple co-occurring symptoms and wearable anomalies."
- Highlight Patient 003 (medium, 🟡) — "This patient looks okay, but there's a subtle trend we're about to catch."
Act 2: "Patient Check-In via Text" (3 minutes)
- Switch to the mobile phone with Patient 003 logged in.
- Tap "Start Text Check-In."
- Walk through the conversation with the AI:
- AI greets David, asks how he's feeling → type or quick-reply "Not great" (or 2/5)
- AI asks about symptoms → type "I've been really nauseous, worse than last time. I also have a bad headache behind my eyes"
- AI follows up on nausea severity → select "7/10" on the severity slider
- AI asks about onset → "It started getting bad yesterday"
- AI asks about headache → type "It's a constant pressure, maybe 5/10"
- AI summarizes: "You reported nausea (severe) and headache (moderate). I'll share this with your care team."
- The check-in completes.
Act 3: "Real-Time Dashboard Update" (2 minutes)
- Switch to the laptop dashboard — the audience sees:
- A toast notification: "🔴 CRITICAL: Grade 3 nausea reported for 001-0042"
- Patient 003's risk score animates from 67 → 82, the indicator turns RED
- The alert queue updates with a new critical alert
- Click into Patient 003's detail view:
- Show the timeline with the new check-in entry
- Show the wearable chart with the rising heart rate trend and the new anomaly flag
- Point out: "The AI classified the nausea as Grade 3 based on the 7/10 severity rating. This was corroborated by rising resting heart rate and declining sleep — the system connected these signals."
Act 4: "Voice Check-In Demo" (2 minutes — if voice is configured)
- Go back to the mobile phone
- Tap "Start Voice Check-In"
- The AI greets the patient via voice — the avatar pulses as it speaks
- Speak naturally: "I'm still not feeling great, the nausea hasn't gone away"
- Show the live transcript updating in real time on the phone screen
- The AI responds via voice, asking follow-up questions
- After 2-3 exchanges, end the session
- Point out: "The same LangGraph agent drives both the text and voice experience. Patients choose the modality they're comfortable with."
Act 5: "CRC Workflow" (1 minute)
- On the dashboard, show the "Pending Review" badge on Patient 003
- Click into the Symptoms tab → open the review panel
- Show the AI's classification: Nausea → MedDRA 10028813, Grade 3, Confidence 94%
- Click "Confirm" — the badge clears, the review is recorded
- Point out: "The CRC always has the final say. The AI accelerates the process, but a human confirms every classification."
Act 6: "Cohort Analytics" (1 minute, if built)
- Navigate to Cohort Analytics
- Show the AE incidence comparison between Arm A and Arm B
- Point out: "Nausea is significantly higher in the treatment arm (42% vs 18%, p=0.003). This is the kind of safety signal that normally takes weeks to surface — TrialPulse makes it visible in real time."
| Issue | Fix |
|---|---|
| Backend won't start | Check docker compose logs backend — likely missing API key in .env |
| Mobile app can't connect | Ensure phone and laptop are on same WiFi; check VITE_API_URL points to laptop's local IP, not localhost |
| Voice check-in fails | Verify DEEPGRAM_API_KEY is set; check docker compose logs livekit |
| WebSocket updates not arriving | Check that the dashboard is connected to ws://localhost:8000/ws; inspect browser DevTools Network tab |
| Risk score not updating | The alert engine runs asynchronously — wait 2-3 seconds after check-in completes; check docker compose logs backend for event bus errors |
| Database is empty | Run python scripts/seed_demo_data.py or restart postgres to re-run init scripts: docker compose restart postgres |
| LiveKit rooms not connecting | Ensure ports 7880-7882 are not blocked; check docker compose logs livekit |
To reset the database to a clean state with fresh mock data:
# Stop all services
docker compose down
# Remove database volume (this wipes all data)
docker volume rm trialpulse_pgdata
# Restart — init.sql and seed.sql will re-run automatically
docker compose up -d --build
# Wait for postgres to initialize (~10 seconds)
sleep 10
# Verify
curl http://localhost:8000/api/v1/dashboard/patients?trial_id=<trial-uuid>To reset only the "live demo" portion (keep base data, remove live check-in data):
python scripts/run_demo_scenario.py --resetCommon adverse event terms used in the prototype:
| MedDRA Code | Preferred Term | System Organ Class |
|---|---|---|
| 10019211 | Headache | Nervous system disorders |
| 10028813 | Nausea | Gastrointestinal disorders |
| 10047700 | Vomiting | Gastrointestinal disorders |
| 10016256 | Fatigue | General disorders |
| 10012735 | Diarrhoea | Gastrointestinal disorders |
| 10003246 | Arthralgia | Musculoskeletal disorders |
| 10037087 | Pyrexia | General disorders |
| 10040785 | Rash | Skin disorders |
| 10002272 | Alopecia | Skin disorders |
| 10022437 | Insomnia | Psychiatric disorders |
| Grade | Severity | General Description |
|---|---|---|
| 1 | Mild | Asymptomatic or mild symptoms; observation only; no intervention needed |
| 2 | Moderate | Minimal, local, or noninvasive intervention indicated; limiting age-appropriate activities |
| 3 | Severe | Medically significant but not immediately life-threatening; hospitalization or prolongation of existing hospitalization indicated |
| 4 | Life-threatening | Urgent intervention indicated |
| 5 | Death | Death related to adverse event |
This document is a living specification. Update it as design decisions are made and implementation progresses.