AI-powered clinical trial discovery. Patients describe their situation in plain language; TrialMatch extracts a structured patient profile, semantically matches it against a 500-condition corpus, pulls live trials from ClinicalTrials.gov, ranks them for relevance and eligibility, and explains every match in patient-friendly words.
Interface
The agents
The matching logic runs as a LangGraph state machine — six agents, one shared state object. Each agent owns a single decision and writes exactly one part of that state, which is what made every stage testable and swappable instead of buried inside one prompt.
TrialMatchState. The
pulse follows one request through the whole pipeline.
-
01 Normalisation
extract_queryTurns whatever the user typed into a predictable query object. Free text is messy — “sugar problem since 2019” has to become a condition, a location and an age before anything downstream can reason about it.
- Uses
- Groq LLM
- Writes
condition · location · age
-
02 Structured extraction
patient_profilePulls a full clinical picture out of the same sentence: age, sex, location, current medications, lab values and symptoms, each as its own field. Trials filter on these individually, so they cannot stay buried in prose.
- Uses
- Groq LLM
- Writes
patient_profile
-
03 Vocabulary bridging
semantic_matchEmbeds the query and cosine-matches it against a 500-condition corpus with precomputed embeddings. This is what lets “high sugar and frequently urinate” land on Type 2 Diabetes, where a keyword search returns nothing.
- Uses
- all-MiniLM-L6-v2 · condition_embeddings.npz
- Writes
condition_matches
-
04 Live retrieval
fetch_trialsQueries the ClinicalTrials.gov REST API once per matched condition. Trial records are fetched on demand and never persisted, so results reflect what is actually recruiting today rather than a stale snapshot.
- Uses
- ClinicalTrials.gov API
- Writes
raw_trials
-
05 Scoring
filter_rankDeduplicates across conditions, checks hard eligibility against age, sex and stated criteria, then scores on semantic similarity, location proximity, recruitment status and recency. A cross-encoder reranker sharpens the top slice but ships disabled — a second model does not fit in free-tier memory, and the weighted score is the graceful fallback.
- Uses
- weighted scoring · optional cross-encoder
- Writes
ranked_trials
-
06 Plain language
explain_eligibilityRewrites dense inclusion and exclusion criteria into sentences a patient can act on. A ranked list is not much use if you cannot tell why you might not qualify for the trial at the top of it.
- Uses
- Groq LLM
- Writes
final_results
Architecture
/api/* to FastAPI; only the pipeline and the
cache layer reach outside.
-
Frontend — Next.js
- Pages — /, /search, /login, /register
- Auth — JWT held client-side, authFetch wrapper
- Proxy — /api/* rewritten to FastAPI
- Visuals — Framer Motion, Recharts, three.js globe
-
Backend — FastAPI
- API only — no static files served
- Auth — Argon2 hashing, HS256 JWT, OAuth2 Bearer
- Users — search history, saved trials
- Guards — 30 req/min per user, per-user isolation
-
Data layer
- PostgreSQL — users, search_history, saved_trials
- Redis — query-keyed result cache, 1h TTL
- ClinicalTrials.gov — fetched live, never persisted
- Groq LLM — extraction and explanation calls
Engineering decisions
-
Model loading was the real bottleneck, not inference
Requests were timing out and OOM-ing mid-flight on a 512 MB host because the embedding model was being pulled from Hugging Face on the request path. I baked MiniLM into the Docker image, forced offline mode, and loaded it once per process — cold start pays the cost, requests don't.
-
The neural reranker ships disabled
A cross-encoder reranker measurably sharpens the top results, but a second model doesn't fit in free-tier memory. It is feature-flagged off by default and the pipeline falls back to weighted scoring, so the app degrades gracefully instead of crashing.
-
One Redis, clear key ownership
Search results are cached by normalised query hash for an hour; rate counters are namespaced per user and window. Redis failing is a soft error — the pipeline still answers, just slower.
-
Two services, deployed independently
Next.js on Vercel, Dockerised FastAPI on Render, Postgres on Supabase's session pooler, Redis on Upstash. The frontend only ever talks to
/api/*, so the backend URL is one env var away from changing. -
Medical data means strict boundaries
Passwords are Argon2-hashed, tokens expire in 30 minutes, every user-data endpoint is scoped to the authenticated user id, and CORS origins are explicit rather than wildcarded.
Stack
-
Frontend
- Next.js 16
- React 19
- TypeScript
- Tailwind CSS 4
- Framer Motion
- Recharts
- three.js
-
Backend
- FastAPI
- Uvicorn
- Pydantic v2
- SQLAlchemy 2.0
-
AI pipeline
- LangGraph
- sentence-transformers
- Groq LLM
- Cross-encoder rerank
-
Data & infra
- PostgreSQL 16
- Redis 8
- Docker
- Vercel
- Render
-
Auth
- Argon2 (pwdlib)
- JWT (python-jose)
- OAuth2 Bearer
TrialMatch / Live app