Inspiration

What it does

How we built it inspiration

Only about 6% of adverse drug reactions ever reach a reporting system. Picture an older patient on a long-term drug who feels dizzy on Tuesday and has forgotten it by their appointment three weeks later. Their doctor sees them for fifteen minutes. The drug company finds out months later, if at all, from a thin report written in free text.

Each of them fails quietly in a different way, and the obvious fix makes things worse. "Just collect more patient data" means patients give up control of their health data, and many of them sensibly refuse. We wanted to know whether one daily check-in could give the doctor a real timeline and the drug company a real signal, without the patient's words ever leaving their phone.

What it does

SafeSignal is a daily side-effect check-in by voice or text:

  • Patient: speaks or types how they feel. The phone turns speech into text, removes personal details, codes the symptoms with a local model, and shows a summary. Nothing is sent until the patient approves it, and every send comes with a receipt.
  • Doctor: gets a symptom timeline before the visit. It is sealed on the device to the care team's key, so our server stores only ciphertext. The patient can revoke access at any time.
  • Drug company: gets coded side-effect trends by drug version, cohort and ISO week. There is no free text, and counts for fewer than 5 patients are hidden.
  • Caregiver: gets an alert after 1, 2 or 3 missed days. Alerts carry IDs and a time, never symptoms.

How we built it

We started by writing the contract: SQL tables, JSON Schemas, enums, a coded list of side-effect terms, and an OpenAPI spec. Every app builds against it. Each payload schema sets additionalProperties: false and has no free-text fields, so the server rejects a stray notes field with a 422.

On the device (Python, apps/patient-runtime):

  1. Speech-to-text with faster-whisper, run locally. Audio is decoded in memory and never written to disk.
  2. Personal-data filter using Presidio, spaCy and custom patterns to remove names, addresses, phone numbers, dates of birth and places.
  3. Extraction with llama3.2:3b through Ollama, locked to loopback hosts. Output is forced into the schema enums. A term with no supporting cue becomes SE_OTHER_REVIEW, and the model never guesses severity.
  4. Egress gate: one module is the only one allowed to import an HTTP client, and a test fails if any other module does.

Receipts the patient can check. Before sending, the device stores the payload in an outbox along with its hash:

$$h = \mathrm{SHA\text{-}256}(\mathrm{canonical_json}(p))$$

The service sends $h$ back, and the patient's screen compares the two. We implemented the canonical hashing twice, in Python and in TypeScript (Web Crypto), and checked that both give byte-identical results on shared test vectors.

The doctor's channel uses libsodium sealed boxes (X25519). The organization's private key is generated in the doctor's browser and never touches our server.

The company's view applies a suppression rule at the database layer:

$$\text{show}(c) = \begin{cases} c & \text{if } \lvert {\text{distinct patients}}\rvert \ge 5 \ \texttt{NULL}\ (\text{"<5"}) & \text{otherwise} \end{cases}$$

The service is FastAPI on SQLite with a missed-check-in scheduler. The patient, doctor and company screens are separate React apps. We generated synthetic data for a fictional drug company, Halvaren Therapeutics: 1,000 patients across 4 drug versions of 250 each, with side-effect rates modelled on public trial arms.

How we know it works

We measure symptom extraction with the usual F1 score:

$$F_1 = \frac{2PR}{P + R}$$

Our target was $0.75$. We scored 0.882 on the tuned set and 0.814 on a held-out set of 40 transcripts. The held-out set was written by an agent that never saw the filter or extraction code, and we never tune against it.

The leakage eval is the one that matters most: 0 of 244 labelled identifiers appeared in any outbound record. An end-to-end check runs one check-in through every persona on a throwaway service, and CI runs it on every pull request.

Challenges we faced

  • Being honest with our own numbers. On the set we tuned against, the filter missed $0/59$ identifiers. On the held-out set it missed $19/185 \approx 10.3%$, mostly names. We kept that number in our docs instead of quietly tuning it away. Because of the layered design, those misses reach only the local model on the device, never the outbound record. That result convinced us that defense in depth is necessary and not just nice to have.
  • A leakage test that cried wolf. Our first leakage check flagged "911" as a leak. It was actually a substring of a hex hash. We changed the check to look at stored values instead of raw strings.
  • Encrypting for a team, not one person. Sealing data to "the care team" meant building org-key distribution in the browser. Revocation is still imperfect: a revoked doctor who kept the key can still read envelopes they already received. Key rotation is on the v1 list.
  • Three people, five apps, one weekend. We kept that manageable with a frozen contract, strict folder ownership (CODEOWNERS plus a script that checks it), and a pull request for every change.
  • A 3B model on a laptop is slow and sometimes misreads symptoms. We chose to constrain it, by locking output to enums, defaulting to "review" when unsure and never guessing severity, rather than rely on its judgment.

What we learned

  • Privacy is something you have to prove. You can't just claim it. Judges, patients and regulators all ask the same question: what leaves the device, and how do you know? Hashes, egress logs and failing tests answer it far better than a privacy policy.
  • A schema with no free-text fields is a privacy tool. If the field doesn't exist, it can't leak.
  • Held-out evaluation applies to privacy filters too. A filter tested only on the data it was tuned against will always look perfect.
  • Write the contract first. A day spent on the schema saved us from many integration bugs.

What's next

Encryption at rest, real authentication to replace demo headers, org-key rotation, formal re-identification testing for rare symptoms in small cohorts, and pilots with a drug-safety team and a clinic.

Challenges we ran into

Accomplishments that we're proud of

What we learned

What's next for SafeSignal

Built With

  • fastapi
  • faster-whisper
  • github-actions
  • httpx
  • json-schema
  • libsodium
  • llama-3.2
  • ollama
  • openapi
  • presidio
  • pynacl
  • pytest
  • python
  • react
  • recharts
  • spacy
  • sqlite
  • tailwindcss
  • typescript
  • uvicorn
  • vite
  • vitest
  • whisper
Share this project:

Updates

Submission history