PharmaVigil
AI drug-safety (pharmacovigilance) platform
A side effect seen at a pharmacy counter should become a reviewed, regulator-ready report, and the evidence from millions of past reports should come back to the counter.
Designed and built solo
Not for clinical or regulatory use. Spontaneous reports do not prove that a drug caused a reaction.
01 / The problem
What needed solving
Drug-safety teams drown in reports: FDA's adverse-event database holds 32.8 million of them, and processing each case (de-duplicating, coding symptoms to MedDRA, judging seriousness, writing the regulator's file) is still largely manual.
Pharmacists see side effects first, but reporting is voluntary and slow, and the statistics computed from public data rarely make it back to the person handing over the drug.
Since October 2026 FDA accepts postmarketing safety reports only as ICH E2B(R3) XML, so a report that can't be exported in that format can't be submitted at all.
PharmaVigil closes that loop: one click at the counter, models that suggest, a person who decides, a schema-valid file, and an acknowledgement.
32.8 million
02 / Constraints
What the design had to respect
No lost reports
A crash or a broker outage between saving a report and announcing it must not drop it. Safety data is the worst thing to lose.
A person decides
Models suggest codes, seriousness and duplicates; a reviewer accepts or corrects every one. Every suggestion is stored next to the final value.
The regulator's format
Only a file that validates against the official E2B(R3) schemas may be queued for submission.
A free 12 GB server
The live demo, with all five services, Kafka, Artemis, Oracle and the trained models, has to fit an Always Free cloud VM (measured peak 6,055 MiB).
Keep moving without a model
If a model is missing or failing, the case goes to manual coding or 'seriousness unknown' instead of stopping the pipeline.
03 / Architecture
How it fits together
The architecture as text
Parts
- React UI (React 19 · TypeScript): Three screens behind nginx: the pharmacy counter (dispense, report a side effect), the reviewer console, and the signals page. nginx routes each /api/… path to its service.
- Pharmacy service (Spring Boot · JPA): Dispenses stock first-expiry-first-out and records adverse-event reports from synthetic patients. Forwards a de-identified copy (age, sex, drug, text) to intake.
- Intake gateway (REST · SOAP · E2B): Accepts reports over REST, E2B(R3) file upload and a SOAP partner endpoint, then announces each one as report.received.
- Kafka (topics + .dlq twins): The replayable backbone between PharmaVigil's own services: report.received → report.deduped → report.coded → case.triaged, then case.ready and submission.status.
- Signal service (read-only snapshot API): Serves drug–event signal scores (PRR, ROR, IC, EBGM, SafetyFormer) from a snapshot exported by the Python pipeline, and returns evidence for a drug at the counter.
- Oracle (one schema per service): One schema per service in Oracle Database Free, including each service's outbox_event and processed_event tables.
- Case service (stages · review · E2B): Runs the pipeline stages, drafts the narrative, hosts the reviewer API, and exports an approved case as ICH E2B(R3) XML validated against the official schemas.
- ONNX models (ONNX Runtime in Java): Three models run inside case-service: duplicate detection and seriousness triage (LightGBM) and a zero-shot MedDRA coder (biomedical encoder + retrieval).
- FAERS pipeline (Spark + Delta · Python): Bronze → silver → gold over FDA's quarterly FAERS files: de-duplicates cases, maps drug names to RxNorm, computes the signal statistics and trains the models.
- Submission gateway (Spring Boot · JMS): Picks up case.ready, records the message, sends the E2B(R3) XML to the regulator queue, and turns the answer into submission.status.
- Artemis JMS (outbound / ack queues): Point-to-point queues between the submission gateway and the (mock) regulator.
- FDA FAERS (public quarterly files): FDA's public adverse-event reporting data, loaded for 2019Q1–2026Q2.
- Mock regulator (XSD check → ACK): Stands in for a regulator's gateway: validates the E2B(R3) file and answers ACK, or NACK with errors.
Connections
- React UI → Pharmacy service: dispense and report a side effect
- Pharmacy service → Intake gateway: de-identified report (outbox relay, retries while intake is down)
- Intake gateway → Kafka: report.received (outbox, same transaction as the receipt)
- Kafka ↔ Case service: pipeline stages in, case.ready out
- Case service → ONNX models: duplicate check, MedDRA coding, seriousness triage
- React UI → Case service: reviewer accepts or corrects, approves
- Kafka → Submission gateway: case.ready
- Submission gateway → Artemis JMS: E2B(R3) XML on regulatory.outbound
- Artemis JMS → Mock regulator: delivered to the regulator
- Mock regulator → Submission gateway: ACK or NACK on regulatory.ack
- FDA FAERS → FAERS pipeline: quarterly zip files
- FAERS pipeline → Signal service: signal snapshot export
- Signal service → Pharmacy service: evidence at the next dispense, never a verdict
- Pharmacy service → Oracle: own schema
- Case service → Oracle: own schema
04 / Follow one request
Follow one report
STEP 01
A pharmacist reports a side effect
While dispensing, the pharmacist sees the drug's signal evidence from public FDA data (shown as evidence, never a verdict) and reports the reaction in one click. The pharmacy service saves it with the patient's synthetic record.
STEP 02
The report leaves through an outbox
A de-identified copy (age in years, sex, drug, text) is written to an outbox in the same transaction. A relay forwards it to intake and keeps retrying if intake is down, so the counter never waits.
STEP 03
Intake commits the receipt and the event together
Intake stores the receipt and an outbox row in one database transaction; the relay publishes report.received to Kafka. If Kafka is down, rows wait; nothing is lost and nothing is announced twice without being caught.
STEP 04
Is it a duplicate?
Case-service consumes the event, records it in its processed-event table (so a replay changes nothing), and asks the duplicate model to compare it with earlier cases for the same drug. Result: report.deduped.
STEP 05
Coded and triaged by models
The MedDRA coder suggests terms and the triage model scores seriousness, each stage on its own topic (report.coded, case.triaged). A failing stage retries three times, then dead-letters; a missing model sends the case to manual coding instead of stopping it.
STEP 06
A person decides
A safety reviewer accepts or corrects each code, the seriousness and the duplicate call, then approves the narrative and the case. Every model suggestion is stored next to the reviewer's final value.
STEP 07
Exported as E2B(R3), validated, queued
Case-service writes the ICH E2B(R3) XML and validates it against the official schemas. Only a schema-valid file is announced as case.ready.
STEP 08
Handed to the regulator over JMS
The submission gateway records the message before sending it on regulatory.outbound in a transacted session. The mock regulator checks the file against the schema.
STEP 09
Acknowledged
An ACK comes back on regulatory.ack and becomes submission.status on Kafka: the case is ACKNOWLEDGED. A NACK, with its errors, sends the case back to review.
05 / Key decisions
The choices, and what they cost
DECISION 01
Transactional outbox, not dual writes
- CHOSE
- writing every event into an outbox table in the same transaction as the change, with a relay that publishes it
- OVER
- saving the row and then calling Kafka (dual write), Kafka transactions alone, or change data capture
- BECAUSE
- the row and its event commit or roll back together, so Kafka can be down for hours without losing a report.
- TRADE-OFF
- delivery becomes at-least-once, so every consumer has to be idempotent (a processed-event table), and there is a relay to maintain and tune.
DECISION 02
Kafka inside, JMS at the edge
- CHOSE
- Kafka for the internal pipeline and JMS queues (ActiveMQ Artemis) for the regulator hand-over
- OVER
- one messaging system for everything
- BECAUSE
- the pipeline needs a replayable log with a consumer group per stage, while a regulator exchange is one message, one answer, with per-message redelivery, which JMS models well.
- TRADE-OFF
- two brokers to run, monitor and fit into the memory budget.
DECISION 03
Models inside Java with ONNX Runtime
- CHOSE
- running the duplicate, coder and triage models in-process in case-service
- OVER
- a Python model server called over HTTP
- BECAUSE
- no extra service, network hop or failure mode for every report on its way to a reviewer.
- TRADE-OFF
- pre-processing is written twice, in Python and Java, so a parity test checks identical features and scores within 1e-5.
DECISION 04
Oracle first, proven portable
- CHOSE
- per-vendor Flyway migrations, Hibernate validate, and the same tests on Oracle and SQL Server in CI
- OVER
- Oracle only, or a generic schema with ddl-auto=update
- BECAUSE
- drug-safety systems commonly run on Oracle, and portability claimed without a test is just a hope.
- TRADE-OFF
- every schema change is written twice; a missed mirror fails that engine's CI job.
DECISION 05
A mapped subset of E2B(R3), checked against the real schemas
- CHOSE
- a small DOM writer and a hardened reader for the fields the system uses, validated against EMA's official schemas in CI
- OVER
- JAXB classes generated from the full HL7 v3 schemas
- BECAUSE
- the schemas can't be committed or downloaded in every build environment, and the schema check in CI keeps the subset honest.
- TRADE-OFF
- structure isn't guaranteed by types, so tests must cover it; XSD validation proves structure, not business rules.
06 / The hardest bug
Symptom to regression test
The same data gave two different answers
SYMPTOM
Two runs of the signal pipeline on identical FAERS data produced different EBGM priors (negative log-likelihoods 4,432,544 vs 4,499,742). Every EBGM score downstream depended on which run you trusted.
HOW IT WAS FOUND
By comparing two runs on identical data. Nothing crashed; the numbers just didn't match, which is exactly the kind of bug only real data and a second run reveal.
ROOT CAUSE
The gamma-mixture prior was fitted with L-BFGS-B from a single starting point. The database returned the pairs in a different order each run, and the optimizer stopped in a different local optimum depending on that order.
FIX
Sort the pairs before fitting, try five starting points on a fixed random sample, refine the best on all pairs, and record every start's likelihood. The final fit matches the best optimum seen before.
WHAT GUARDS IT NOW
test_fit_does_not_depend_on_pair_order shuffles the same pairs and requires an identical prior; test_fit_report_keeps_the_best_start checks the best start wins.
07 / Results
Measured, with the file that proves each number
CI on the main branch, live from GitHub:
- 10,848,477
unique cases after de-duplication
subset: FAERS 2019Q1–2026Q2
source ↗ for unique cases after de-duplication (opens GitHub) - 98.5%
of drug mentions mapped to an RxNorm ingredient
subset: FAERS 2019Q1–2026Q2
source ↗ for of drug mentions mapped to an RxNorm ingredient (opens GitHub) - 0.821
AUC, best classic signal method (IC) on OMOP
subset: FAERS 2019Q1–2026Q2; 388 OMOP pairs, primary-suspect drugs
source ↗ for AUC, best classic signal method (IC) on OMOP (opens GitHub) - 0.765
AUC of SafetyFormer on OMOP (below IC)
subset: FAERS 2019Q1–2026Q2; CPU-sized model, same 388 pairs
source ↗ for AUC of SafetyFormer on OMOP (below IC) (opens GitHub) - 0.960
seriousness triage ROC AUC, all reports
subset: FAERS 2019Q1–2026Q2; test set from 2025-01-01
source ↗ for seriousness triage ROC AUC, all reports (opens GitHub) - 0.924
seriousness triage ROC AUC within US reports
subset: FAERS 2019Q1–2026Q2; test set from 2025-01-01
source ↗ for seriousness triage ROC AUC within US reports (opens GitHub) - 0.998
duplicate detection PR-AUC (proxy label)
subset: FAERS 2019Q1–2026Q2; proxy label: follow-up versions of one case
source ↗ for duplicate detection PR-AUC (proxy label) (opens GitHub) - 7.5 s
p95 report-to-coded after both fixes
local docker compose stack, 4 vCPU / 15.7 GiB, synthetic reports, fixture models
source ↗ for p95 report-to-coded after both fixes (opens GitHub) - 2,300
cases coded per minute at peak
local docker compose stack, 4 vCPU / 15.7 GiB, synthetic reports, fixture models
source ↗ for cases coded per minute at peak (opens GitHub) - 42 s
for 200 real reports, intake to ACK
real demo data, build container (x86_64, 4 vCPU)
source ↗ for for 200 real reports, intake to ACK (opens GitHub) - 6,055 MiB
peak memory of the whole demo stack
real demo data; target: a free 12 GB server
source ↗ for peak memory of the whole demo stack (opens GitHub) - 106
Java tests, 0 failures (JUnit 5, Mockito, Testcontainers)
CI, official E2B(R3) schemas present
source ↗ for Java tests, 0 failures (JUnit 5, Mockito, Testcontainers) (opens GitHub)
08 / What I'd do differently
With hindsight
- Train SafetyFormer at full size on a GPU and over the full FAERS history. The CPU-sized model did not beat the classic statistics, and I'd want to know whether that's the method or the budget.
- Evaluate the MedDRA coder properly. It runs zero-shot today and is not evaluated; a fair test needs narratives with human-assigned terms (VAERS), which I'd get before claiming any accuracy.
- Use a better duplicate label. The current one (follow-up versions of the same case) is an easy proxy; real duplicates from different reporters are the hard and useful case.