medOS ultra

Medication Safety Agent

Premium add-on: combined YOLO + LLM medication-safety pipeline.

8 min read diagramsUpdated 2026-05-25docs/architecture/medication-safety-agent.md

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_ENABLED env 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. When provider=openai, the prompt contains de-identified clinical data only (no patient name, HN, or date of birth). The patientContextFetcher strips PII before prompt assembly.
  • All verdicts are persisted with a frozen patient_context snapshot 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.
Ask Anything