Skip to content
Sumit Parmar
CASE STUDY 012026checking…

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

reports in FDA's FAERS database (March 2026), growing by about 2.1 million a yearFDA figures collected in the project plansource ↗

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

Every box is a real part of the system. Select one to see what it does and why it was built that way.

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

A pharmacist sees a side effect at the counter. Here is everything that happens until a regulator acknowledges it.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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

  1. 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 record ↗ for Transactional outbox, not dual writes
  2. 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 record ↗ for Kafka inside, JMS at the edge
  3. 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 record ↗ for Models inside Java with ONNX Runtime
  4. 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 record ↗ for Oracle first, proven portable
  5. 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.
    decision record ↗ for A mapped subset of E2B(R3), checked against the real schemas

06 / The hardest bug

Symptom to regression test

The same data gave two different answers

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

the full write-up ↗

07 / Results

Measured, with the file that proves each number

Each number holds only for the scope written under it.

CI on the main branch, live from GitHub:PharmaVigil CI status

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.