Medication Safety Agent
Premium add-on: combined YOLO + LLM medication-safety pipeline.
Premium add-on. Feature-flagged behind
medos_premium_medication_safety_agent. Most hospitals use the existing 7-rights checklist; this is for facilities that want AI-powered clinical reasoning on top of camera-based drug identification.
Problem Statement
Medication errors are the #1 preventable harm in hospitals. The existing safeguards (barcode scan, 7-rights checklist) catch labeling mistakes but miss clinical context errors:
- Look-Alike Sound-Alike (LASA) — Metoprolol vs Metformin, Hydroxyzine vs Hydralazine. OCR reads the label correctly, but the drug is wrong for this patient.
- Dose-for-weight — 1000mg Vancomycin prescribed for a 42kg patient exceeds the weight-based maximum (15mg/kg = 630mg).
- Allergy cross-reactivity — Cephalosporin prescribed, patient has documented Penicillin allergy (10% cross-reactivity).
- Drug-drug interaction — OCR sees “Warfarin 5mg” which is correct per prescription, but the patient was started on Fluconazole yesterday (potent CYP2C9 inhibitor → INR spike risk).
- Renal/hepatic adjustment — Dose is standard but patient’s latest eGFR is 28 (CKD Stage 4) and the drug is renally cleared.
The fix: chain YOLO object detection (what’s physically in the nurse’s hand) with an LLM that has access to the patient’s clinical context and reasons about safety before the medication is administered.
Architecture
Nurse holds vial/pill in front of camera
│
▼
┌──────────────────────────────────────────────────────────┐
│ Stage 1 — YOLO / OCR (services/vision/) │
│ │
│ Input: camera frame (base64) │
│ Output: detected_drug { name, dose, unit, route, │
│ expiry, lotNumber, barcode, pillShape, │
│ pillColor, pillImprint, confidence, bboxes[] } │
│ │
│ Backend: stub → ONNX (YOLOv8-nano + PaddleOCR) → │
│ remote (Triton/dedicated GPU server) │
└──────────────────┬───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Stage 2 — LLM Clinical Reasoning (services/vision/) │
│ │
│ Input: │
│ • detected_drug (from Stage 1) │
│ • expected_drug (from MedicationRequest) │
│ • patient_context: │
│ ├─ allergies[] (from encounter cache) │
│ ├─ active_meds[] (from medication service) │
│ ├─ weight_kg (from vitals) │
│ ├─ renal: { eGFR, creatinine } │
│ ├─ hepatic: { alt, ast, bilirubin } │
│ ├─ age, sex │
│ └─ diagnoses[] │
│ │
│ Processing: │
│ 1. Identity match — is detected drug == expected? │
│ 2. LASA check — is detected drug a known LASA │
│ confusable for the expected drug? │
│ 3. Allergy check — cross-reactivity screening │
│ 4. DDI check — against active_meds[] │
│ 5. Dose check — weight-based + renal/hepatic adj │
│ 6. Expiry check — is the detected expiry past? │
│ 7. Route check — does detected route match Rx? │
│ │
│ Output: MedicationSafetyVerdict │
│ { safe: boolean, │
│ severity: 'safe'|'caution'|'warning'|'critical', │
│ checks: [ │
│ { check, passed, detail, suggestion? } │
│ ], │
│ llmReasoning: string, │
│ llmModel: string, │
│ llmLatencyMs: number } │
│ │
│ Backend: OpenAI gpt-4o-mini (default) or Ollama local │
│ (configurable via VISION_LLM_PROVIDER) │
└──────────────────┬───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Stage 3 — Gate + Surface │
│ │
│ Critical → BLOCK (red modal, requires MD override) │
│ Warning → WARN (amber banner, nurse acknowledges) │
│ Caution → INFO (teal chip, logged for audit) │
│ Safe → PASS (green chip, auto-checks rights) │
│ │
│ Persisted to: medication_safety_verdicts (Supabase) │
│ Emitted as: manifest.medication.safety_verdict │
│ Surfaces via: MedicationSafetyAgent component │
│ (inline in e-MAR task dialog) │
│ │
│ Policy gate: administer_medication (existing trigger) │
│ blocks when severity=critical and │
│ no MD override recorded │
└──────────────────────────────────────────────────────────┘
Data Model
Supabase table: medication_safety_verdicts
create table medication_safety_verdicts (
id uuid primary key default gen_random_uuid(),
scan_uid text unique not null, -- from medication_scan_verifications
encounter_id text,
patient_id text,
medication_request_id text,
administration_id text,
-- Stage 1 output (what YOLO/OCR saw)
detected_drug jsonb not null,
expected_drug jsonb not null,
-- Patient context snapshot (frozen at verdict time for audit)
patient_context jsonb not null default '{}'::jsonb,
-- Stage 2 output (LLM reasoning)
severity text not null check (severity in ('safe','caution','warning','critical')),
safe boolean not null default true,
checks jsonb not null default '[]'::jsonb,
llm_reasoning text,
llm_model text,
llm_latency_ms int,
llm_prompt_tokens int,
llm_completion_tokens int,
-- Human resolution
resolved_by text,
resolved_at timestamptz,
resolution text check (resolution in ('accepted','overridden','cancelled')),
override_reason text,
override_role text,
tenant_id uuid,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
LLM Prompt Template
You are a clinical pharmacist AI assistant performing a 7-point medication
safety check. Analyze the following and return a structured JSON verdict.
## Prescription (what was ordered)
Drug: {{expected.name}} {{expected.dose}} {{expected.unit}}
Route: {{expected.route}}
Frequency: {{expected.frequency}}
## Detected (what the camera sees in the nurse's hand)
Drug: {{detected.name}} {{detected.dose}} {{detected.unit}}
Route: {{detected.route}}
Expiry: {{detected.expiry}}
Lot: {{detected.lotNumber}}
Pill: {{detected.pillShape}} {{detected.pillColor}} imprint={{detected.pillImprint}}
## Patient Context
Age: {{patient.age}} Sex: {{patient.sex}} Weight: {{patient.weight_kg}}kg
Allergies: {{patient.allergies | join(', ')}}
Active medications: {{patient.active_meds | join(', ')}}
eGFR: {{patient.renal.eGFR}} Creatinine: {{patient.renal.creatinine}}
ALT: {{patient.hepatic.alt}} Bilirubin: {{patient.hepatic.bilirubin}}
Diagnoses: {{patient.diagnoses | join(', ')}}
## Perform these 7 checks:
1. IDENTITY — Does detected drug match the prescription?
2. LASA — Is the detected drug a known look-alike/sound-alike for the expected?
3. ALLERGY — Any allergy or cross-reactivity risk?
4. DDI — Any significant drug-drug interactions with active_meds?
5. DOSE — Is the dose appropriate for this patient's weight + organ function?
6. EXPIRY — Is the detected expiry date in the future?
7. ROUTE — Does detected route match prescription route?
Return JSON:
{
"safe": boolean,
"severity": "safe" | "caution" | "warning" | "critical",
"checks": [
{ "check": "identity", "passed": boolean, "detail": "...", "suggestion": "..." },
...7 checks
],
"reasoning": "One paragraph summary of your clinical assessment."
}
Implementation Plan
Phase 1 — Backend pipeline (services/vision/)
| File | Purpose |
|---|---|
modules/medicationSafety/medicationSafety.controller.mixin.ts |
Moleculer action vision.medicationSafety.evaluate — chains Stage 1 + 2 |
modules/medicationSafety/llmReasoningEngine.ts |
Builds prompt from template, calls LLM (OpenAI/Ollama), parses response |
modules/medicationSafety/patientContextFetcher.ts |
Fetches allergies, active meds, vitals, labs from encounter cache + MongoDB |
modules/medicationSafety/lasaDictionary.ts |
Static LASA drug pairs (FDA ISMP list) for fast pre-LLM screening |
modules/_shared/llmClient.ts |
Shared OpenAI/Ollama HTTP client (reuses services/llm/ pattern if available, or standalone) |
Phase 2 — Supabase + orchestrator
| File | Purpose |
|---|---|
migrations/20260524c_medication_safety_verdicts.sql |
Table + RLS + indexes |
inpatient-handlers/handleMedicationSafetyVerdict.ts |
On manifest.medication.safety_verdict: if critical, insert ActiveAlert with requiresAcknowledgement=true |
Phase 3 — Frontend component
| File | Purpose |
|---|---|
e-mar/components/MedicationSafetyAgent.tsx |
Combined UI: camera → YOLO → LLM verdict → 7-check display → gate/override |
vision-ai.service.ts |
evaluateMedicationSafety() client function |
surgicalCountGate.ts (extend) |
evaluateMedicationGate() for the administer_medication trigger |
Phase 4 — Integration
- Mount `` in
MedicationTaskDialog(conditional on feature flag) - On “safe” verdict → auto-check Drug + Dose + Route rights in
SevenRightsVerification - On “critical” verdict → block the Administer button via policy gate
Seven Checks → Seven Rights Mapping
| Check | Right | Auto-action on pass |
|---|---|---|
| IDENTITY | Right Drug | ✅ check |
| LASA | Right Drug | ✅ check (+ info chip if LASA pair detected but correct) |
| ALLERGY | (safety) | ⛔ block if failed |
| DDI | (safety) | ⚠ warn if moderate, ⛔ block if severe |
| DOSE | Right Dose | ✅ check if appropriate |
| EXPIRY | (safety) | ⛔ block if expired |
| ROUTE | Right Route | ✅ check |
Right Patient and Right Time are handled by the existing wristband scan + e-MAR schedule timeline — this agent doesn’t duplicate those.
Configuration
| Env var | Default | Purpose |
|---|---|---|
VISION_LLM_PROVIDER |
openai |
openai or ollama |
VISION_LLM_MODEL |
gpt-4o-mini |
Model for clinical reasoning |
VISION_LLM_API_KEY |
(required for openai) | OpenAI API key |
VISION_LLM_BASE_URL |
https://api.openai.com/v1 |
OpenAI-compatible endpoint |
VISION_OLLAMA_URL |
http://localhost:11434 |
Ollama endpoint (when provider=ollama) |
VISION_OLLAMA_MODEL |
llama3.1:8b |
Ollama model name |
VISION_SAFETY_AGENT_ENABLED |
false |
Kill switch |
Feature Flag
- Backend:
VISION_SAFETY_AGENT_ENABLEDenv var (service level) - Frontend:
localStorage.getItem('medos_premium_medication_safety_agent') === 'true' - Policy gate: seeded as
draft— admin enables at/admin/policy-gates
Cost Model (for pricing the add-on)
| Component | Cost per scan |
|---|---|
| YOLO inference (stub/ONNX) | ~$0 (runs on-device or on-prem) |
| LLM call (gpt-4o-mini) | ~$0.002 (avg 800 input + 400 output tokens) |
| LLM call (Ollama local) | ~$0 (on-prem GPU, one-time hardware cost) |
At 200 medication administrations/day → ~$12/month with OpenAI, $0 with Ollama.
LASA Dictionary (starter set)
Top 20 ISMP-designated LASA pairs to seed:
Metformin ↔ Metoprolol
Hydroxyzine ↔ Hydralazine
Prednisolone ↔ Prednisone
Clonidine ↔ Klonopin (Clonazepam)
Celebrex (Celecoxib) ↔ Celexa (Citalopram)
Lamictal (Lamotrigine) ↔ Lamisil (Terbinafine)
Zantac (Ranitidine) ↔ Zyrtec (Cetirizine)
Vincristine ↔ Vinblastine
Humalog ↔ Humulin
Novolog ↔ Novolin
Tramadol ↔ Trazodone
Bupropion ↔ Buspirone
Clonazepam ↔ Lorazepam
Oxycodone ↔ OxyContin (extended-release)
Acetazolamide ↔ Acetohexamide
Daunorubicin ↔ Doxorubicin
Glipizide ↔ Glyburide
Risperidone ↔ Ropinirole
Sulfadiazine ↔ Sulfasalazine
Chlorpromazine ↔ Chlorpropamide
Security Considerations
- Patient context is never sent to external LLM APIs when
provider=ollama. Whenprovider=openai, the prompt contains de-identified clinical data only (no patient name, HN, or date of birth). ThepatientContextFetcherstrips PII before prompt assembly. - All verdicts are persisted with a frozen
patient_contextsnapshot for audit (what the LLM saw at decision time). - LLM output is treated as advisory only — never auto-administered. A human must always click “Administer” or “Override”.
- The kill switch (
VISION_SAFETY_AGENT_ENABLED=false) disables the entire pipeline at the service level.