Inspiration
Trial matching is a reading problem that gets treated as a search problem. A coordinator has a chart in one window and a protocol in another, and works down thirty eligibility criteria by hand, one patient at a time. Most tools that try to help stop at "here are some trials near you", which is the easy half. The hard half is the part that decides enrollment: does this line in the chart satisfy that line in the protocol, and if the chart is silent, what test would answer it?
The second thing that shaped the project: a patient record is the most sensitive document a person has. Any tool that asks you to upload one is asking for a lot of trust. We wanted the de-identification to happen where the clinician can watch it: in their own browser, before anything is transmitted, with the exact text shown for approval.
What it does
Drop in a medical record. ClinixPath parses the PDF in the tab with pdf.js, runs a HIPAA Safe Harbor scrubber over it locally, and shows you the exact text that would be sent. Nothing transmits from your device until you read it, make sure there's no sensitive info, tick the box, and press the button.
From there it derives the search condition from the record, queries currently-recruiting studies on ClinicalTrials.gov, and scores each trial criterion by criterion. Every verdict resolves to met, blocked, or a gap, and each one quotes the sentence it rests on, from the chart and from the protocol, everything is transparent.
The part we're most attached to is what happens to the gaps. A criterion the chart is silent on is not a rejection, it's a missing test. Those get collected into a worklist ranked by how many trials answering them would unblock, so the most useful lab to order next sits at the top.
Live: https://clinixpath.pages.dev
How we built it
React + Vite for the frontend, FastAPI on the back, no database anywhere. The record lives in RAM for the length of one request and is purged in a finally block.
- De-identification runs entirely client-side: pdf.js for extraction, then a rule set targeting the 18 Safe Harbor identifiers, with a "protected span" vault that pulls lab values, gene notation and staging out of harm's way before any redaction rule runs and puts them back afterward.
- Trial data comes from the ClinicalTrials.gov v2 API — structured eligibility criteria, not scraped page text.
- Scoring goes through a provider chain (OpenAI → Cloudflare Workers AI → Groq). Any configured key becomes a fallback, so one provider running out of quota doesn't end a run. Results stream back over SSE so you watch real progress instead of a spinner.
- Deployed on Cloudflare Pages and Render.
Challenges we ran into
The scrubber was deleting the diagnosis. We added a rule to catch cities written in prose, such as "resides in Cleveland", and triggered it on words like located and transferred. In an oncology note those words are anatomy. "Mass located in Right Upper Lobe" became "Mass located in [GEOGRAPHY REDACTED]". A tumour with no site can't be matched against a trial that asks for one, and nothing in the UI showed anything had gone missing.
Then it leaked a city anyway. Running our own sample record through the real UI and reading the review panel, the address block came back as Address: [REDACTED] / Cleveland, [ZIP REDACTED]. The city+state rule needs a state token to fire, and the record's second address line is Cleveland, 44113 — city straight onto a ZIP, no state. The ZIP got redacted right next to the surviving city, which made the line look handled.
A regex flag that silently changed what a rule meant. Two name rules carried /i, under which [A-Z] and [a-z] both match either case — so "a capitalised word" quietly became "any word". They ate the words around names, and "Patient is stage four" redacted to "Patient is [NAME REDACTED]".
The rate limiter took three tries, and the first two only broke in production. Render sits behind Cloudflare, so a request arrives with two proxy hops:
38.122.182.130, 104.23.160.113, 10.31.175.104
^ the caller ^ CDN edge ^ platform LB
Keying on the last hop bucketed every caller under one private load-balancer address — twenty requests from anyone would have throttled everyone. Keying on the last public hop bucketed them under a CDN edge address that rotates per request, so the limiter stopped limiting anything at all. Both passed every local test, because there is no proxy in front of a laptop.
Fake progress is worse than no progress. Our first loading state was an animated placeholder. Reading up on it, the "labor illusion" is a known anti-pattern for AI tools — inventing work theatre while real work is happening makes the tool feel less trustworthy, not more. We deleted the animation and replaced it with the actual pipeline stages and real counts.
Accomplishments that we're proud of
The de-identification is the piece we'd defend hardest. It's client-side, the human review gate is load-bearing rather than a formality, and the test suite is written around the two ways it can fail — leaking an identifier, and destroying clinical meaning. 74 cases, and every fix was verified by reintroducing the bug and confirming a test catches it.
We're also proud that the app tells the truth when it's broken. A run that found four trials and couldn't score any of them used to say "No recruiting trials matched — try widening the location", which pointed at the one thing that wasn't wrong. Found-but-unscored is now its own state and names the actual cause.
And the gap ranking. Turning "ineligible" into "here is the one test that opens the most doors" is the feature we'd want if we were the one doing this work.
What we learned
Almost every real bug in this project was found by running the thing, not by reading it. The scrubber leak came from uploading our own sample PDF and reading the review panel. The rate-limiter bug came from reading what the deployed service logged about itself. The proxy chain was two reasonable theories and one measurement, and only the measurement was right.
The corollary: a test that passes locally can be describing a world that doesn't exist. We started writing the measured values — the actual forwarding chain, the actual address block — into the test suite, so the assertions carry the evidence.
We also learned how much of "working with free-tier models" is arithmetic rather than tuning. Groq's free tier charges prompt + max_tokens against an 8,000-token-per-minute bucket at request time, so one call reserves nearly all of it. Running two trials in parallel wasn't twice as fast — it was a guaranteed 429 on every second call.
What's next for ClinixPath
- Widen the scrubber's prose coverage. Labelled fields are handled well; a bare first name or an institution inside a sentence still depends on the human review gate. That gate should stay, but it should have less to catch.
- Feed answered gaps back into ranking automatically, so resolving one question reranks every affected trial without a re-run.
- Site-level detail — contact routes and screening availability per location, not just the trial.
- A real compliance posture. Today this is explicitly not HIPAA compliant: no BAA with the model provider, no auth, no audit log. It's built for synthetic records or records already de-identified to an institution's standard. A self-hosted model path is the honest route to using it on real charts.
Not a medical device. Decision support for a clinician to check — never an enrollment decision.
Built With
- clinicaltrials.gov
- cloudflare
- cloudflare-pages
- fastapi
- groq
- lucide-react
- openai
- pdf.js
- python
- react
- render
- tailwindcss
- uvicorn
- vite
Log in or sign up for Devpost to join the conversation.