medOS ultra

Medication AI Planner

Premium add-on: pattern-learning prescription intelligence.

8 min read diagramsUpdated 2026-05-25docs/architecture/medication-ai-planner.md

Premium add-on. Feature-flagged behind medos_premium_medication_planner. Learns from the hospital’s own prescribing history, then layers LLM reasoning for dose optimization, DDI resolution, and regimen completeness.

Design Philosophy

Learn first, suggest second. Generic drug databases are useful but miss how this hospital’s doctors actually prescribe. A cardiologist at Siriraj prescribes differently than one at Chula — different formulary preferences, different dosing conventions, different insurance constraints. The AI should learn from the patterns already in the data before adding textbook reasoning on top.

Architecture

┌────────────────────────────────────────────────────────────────────────┐
│  Layer 0 — Pattern Mining (Supabase materialized view + RPC)          │
│                                                                        │
│  Source: medication_requests + encounter_journey_cache + order_requests │
│  Aggregation dimensions:                                               │
│    • department (ward_id)                                              │
│    • prescriber (prescriber_doctor_id)                                 │
│    • diagnosis (ICD-10 from encounter or order_request_item)           │
│    • encounter class (OPD / IPD / ER)                                  │
│                                                                        │
│  Output: prescription_patterns (materialized view)                     │
│    { drug_key, drug_name, dose, dose_uom, frequency, route, duration, │
│      department_id, doctor_id, icd10_code, encounter_class,           │
│      rx_count, last_prescribed_at, avg_dose_numeric,                  │
│      rank_in_department, rank_for_doctor }                             │
└────────────────────┬───────────────────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────────────────────────────────────┐
│  Layer 1 — Suggestion Engine (vision service mixin / Supabase RPC)    │
│                                                                        │
│  Input:                                                                │
│    • current_doctor_id                                                 │
│    • current_department_id                                             │
│    • patient diagnoses (ICD-10[])                                      │
│    • patient_context (allergies, active_meds, weight, renal, hepatic) │
│                                                                        │
│  Processing:                                                           │
│    1. Query prescription_patterns for matching (dept, doctor, ICD)     │
│    2. Rank by: doctor's own history > department average > hospital    │
│    3. Filter out drugs the patient is allergic to                      │
│    4. Merge with existing order_favorite_items for the doctor          │
│    5. Return top-N suggestions with provenance labels                 │
│                                                                        │
│  Output: RankedSuggestion[]                                            │
│    { drug_name, dose, frequency, route, duration,                     │
│      source: 'your_history'|'department_pattern'|'hospital_pattern',  │
│      rx_count, confidence, provenance_detail }                        │
└────────────────────┬───────────────────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────────────────────────────────────┐
│  Layer 2 — LLM Clinical Enrichment (existing llmClient)               │
│                                                                        │
│  Input:                                                                │
│    • top suggestions from Layer 1                                     │
│    • patient_context (full clinical snapshot)                          │
│    • active_medications (for DDI check)                                │
│                                                                        │
│  Processing (one LLM call for the batch):                              │
│    1. Dose optimization — adjust for weight/renal/hepatic/age         │
│    2. DDI screening — check each suggestion against active_meds       │
│    3. If DDI detected → suggest alternatives from Layer 1 pool        │
│    4. Regimen completeness — flag missing prophylaxis, monitoring      │
│    5. Duration reasonableness — flag unusually short/long courses      │
│                                                                        │
│  Output: EnrichedSuggestion[]                                          │
│    { ...RankedSuggestion,                                             │
│      adjustedDose?, adjustedFrequency?,                               │
│      ddiWarnings[], alternatives[],                                   │
│      missingItems[], durationNote?,                                   │
│      llmReasoning }                                                   │
└────────────────────┬───────────────────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────────────────────────────────────┐
│  Layer 3 — Frontend Surface                                           │
│                                                                        │
│  MedicationAiPlanner component mounted in the prescription dialog:    │
│                                                                        │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │  🧠 AI Suggestions for J18.9 (Pneumonia)                       │   │
│  │                                                                 │   │
│  │  YOUR HISTORY (Dr. Somchai)                                    │   │
│  │  ┌──────────────────────────────────────────────────────────┐  │   │
│  │  │ Amoxicillin/Clav 1g BID × 7d (PO) — 23 times         + │  │   │
│  │  │ Azithromycin 500mg OD × 3d (PO) — 8 times             + │  │   │
│  │  └──────────────────────────────────────────────────────────┘  │   │
│  │                                                                 │   │
│  │  DEPARTMENT PATTERN (Internal Medicine)                        │   │
│  │  ┌──────────────────────────────────────────────────────────┐  │   │
│  │  │ Levofloxacin 750mg OD × 5d (PO) — 67 dept rxs         + │  │   │
│  │  │   ⚠ DDI: Warfarin (active) — ↑ INR risk               │  │   │
│  │  │   → Alt: Moxifloxacin 400mg OD (no warfarin DDI)      + │  │   │
│  │  └──────────────────────────────────────────────────────────┘  │   │
│  │                                                                 │   │
│  │  AI ADJUSTMENTS                                                │   │
│  │  ┌──────────────────────────────────────────────────────────┐  │   │
│  │  │ 💊 Dose: Amox/Clav adjusted 875mg → 500mg (eGFR 35)    │  │   │
│  │  │ ⚕ Missing: consider PPI prophylaxis with fluoroquinolone│  │   │
│  │  └──────────────────────────────────────────────────────────┘  │   │
│  │                                                                 │   │
│  │  [+ Add to prescription]  [Dismiss]                            │   │
│  └─────────────────────────────────────────────────────────────────┘   │
└────────────────────────────────────────────────────────────────────────┘

Data Model

Supabase: prescription_patterns (materialized view)

CREATE MATERIALIZED VIEW prescription_patterns AS
SELECT
  md5(concat_ws('|',
    mr.ward_id,
    mr.prescriber_doctor_id,
    COALESCE(ejc.icd10_primary, ''),
    mr.medication_name,
    mr.dose,
    mr.dose_uom,
    mr.frequency,
    mr.route
  )) AS pattern_id,

  -- Dimensions
  mr.ward_id                    AS department_id,
  mr.prescriber_doctor_id       AS doctor_id,
  mr.prescriber_name            AS doctor_name,
  COALESCE(ejc.icd10_primary, 'unspecified') AS icd10_code,
  mr.encounter_class,

  -- Drug details
  mr.medication_name            AS drug_name,
  mr.dose,
  mr.dose_uom,
  mr.frequency,
  mr.route,

  -- Aggregates
  COUNT(*)                      AS rx_count,
  MAX(mr.created_at)            AS last_prescribed_at,
  AVG(NULLIF(regexp_replace(mr.dose, '[^0-9.]', '', 'g'), '')::numeric)
                                AS avg_dose_numeric,

  -- Ranking
  ROW_NUMBER() OVER (
    PARTITION BY mr.ward_id, COALESCE(ejc.icd10_primary, 'unspecified')
    ORDER BY COUNT(*) DESC
  )                             AS rank_in_department,
  ROW_NUMBER() OVER (
    PARTITION BY mr.prescriber_doctor_id, COALESCE(ejc.icd10_primary, 'unspecified')
    ORDER BY COUNT(*) DESC
  )                             AS rank_for_doctor

FROM medication_requests mr
LEFT JOIN encounter_journey_cache ejc
  ON ejc.encounter_ref = mr.encounter_id
WHERE mr.status NOT IN ('cancelled', 'entered-in-error')
  AND mr.created_at > NOW() - INTERVAL '12 months'
GROUP BY
  mr.ward_id, mr.prescriber_doctor_id, mr.prescriber_name,
  COALESCE(ejc.icd10_primary, 'unspecified'),
  mr.encounter_class,
  mr.medication_name, mr.dose, mr.dose_uom, mr.frequency, mr.route;

CREATE UNIQUE INDEX ON prescription_patterns (pattern_id);
CREATE INDEX ON prescription_patterns (doctor_id, icd10_code);
CREATE INDEX ON prescription_patterns (department_id, icd10_code);

Refresh schedule: SELECT cron.schedule('refresh-rx-patterns', '0 3 * * *', 'REFRESH MATERIALIZED VIEW CONCURRENTLY prescription_patterns');

Supabase: medication_ai_suggestions_log (audit)

CREATE TABLE medication_ai_suggestions_log (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  encounter_id        TEXT,
  patient_id          TEXT,
  doctor_id           TEXT,
  department_id       TEXT,
  icd10_codes         JSONB DEFAULT '[]',
  patient_context     JSONB DEFAULT '{}',

  -- What the AI suggested
  suggestions         JSONB NOT NULL,       -- EnrichedSuggestion[]
  llm_reasoning       TEXT,
  llm_model           TEXT,
  llm_latency_ms      INT,

  -- What the doctor actually did
  accepted_items      JSONB DEFAULT '[]',   -- which suggestions were added
  rejected_items      JSONB DEFAULT '[]',   -- explicitly dismissed
  modified_items      JSONB DEFAULT '[]',   -- accepted but changed dose/freq

  tenant_id           UUID,
  created_at          TIMESTAMPTZ DEFAULT NOW(),
  resolved_at         TIMESTAMPTZ
);

This table is the learning feedback loop — by tracking which suggestions doctors accept vs reject, future iterations can weight the ranking: confidence = rx_count * (1 + accept_rate) * recency_decay

Supabase RPC: get_prescription_suggestions

CREATE OR REPLACE FUNCTION get_prescription_suggestions(
  p_doctor_id       TEXT,
  p_department_id   TEXT,
  p_icd10_codes     TEXT[],
  p_encounter_class TEXT DEFAULT 'AMB',
  p_limit           INT DEFAULT 20
)
RETURNS TABLE (
  drug_name          TEXT,
  dose               TEXT,
  dose_uom           TEXT,
  frequency          TEXT,
  route              TEXT,
  source             TEXT,        -- 'your_history', 'department_pattern', 'hospital_pattern'
  rx_count           BIGINT,
  last_prescribed_at TIMESTAMPTZ,
  avg_dose_numeric   NUMERIC,
  rank_score         INT
) LANGUAGE plpgsql STABLE AS $$
BEGIN
  RETURN QUERY
  WITH patterns AS (
    SELECT
      pp.*,
      CASE
        WHEN pp.doctor_id = p_doctor_id THEN 'your_history'
        WHEN pp.department_id = p_department_id THEN 'department_pattern'
        ELSE 'hospital_pattern'
      END AS source_label,
      CASE
        WHEN pp.doctor_id = p_doctor_id THEN 3
        WHEN pp.department_id = p_department_id THEN 2
        ELSE 1
      END AS source_weight
    FROM prescription_patterns pp
    WHERE pp.icd10_code = ANY(p_icd10_codes)
      AND (pp.encounter_class = p_encounter_class OR pp.encounter_class IS NULL)
      AND (pp.doctor_id = p_doctor_id
           OR pp.department_id = p_department_id
           OR TRUE)  -- hospital-wide fallback
  )
  SELECT
    p.drug_name,
    p.dose,
    p.dose_uom,
    p.frequency,
    p.route,
    p.source_label AS source,
    p.rx_count,
    p.last_prescribed_at,
    p.avg_dose_numeric,
    (p.source_weight * 1000 + p.rx_count::int) AS rank_score
  FROM patterns p
  ORDER BY rank_score DESC, p.last_prescribed_at DESC
  LIMIT p_limit;
END;
$$;

Implementation Plan

Phase 1 — Pattern Mining Infrastructure

File Purpose
infrastructure/medbase/migrations/20260524d_prescription_patterns.sql Materialized view + RPC + audit table + cron refresh

Phase 2 — Backend Suggestion Engine

File Purpose
services/vision/modules/medicationPlanner/medicationPlanner.controller.mixin.ts vision.medicationPlanner.suggest — fetches patterns via RPC, applies allergy filter, calls LLM for enrichment
services/vision/modules/medicationPlanner/llmPlannerPrompt.ts Prompt template for dose optimization + DDI resolution + completeness check

Phase 3 — Frontend Component

File Purpose
web/packages/miniapps/e-mar/components/MedicationAiPlanner.tsx Suggestion panel for the prescription dialog — 3-tier display (your history / dept pattern / AI adjustments), “+ Add” per suggestion
web/src/services/vision-ai.service.ts getMedicationSuggestions() client function

Phase 4 — Feedback Loop

File Purpose
Accept/reject tracking in the audit log When doctor adds a suggested item → log accepted_items; when dismissed → log rejected_items
Recency + acceptance weighting in the RPC confidence = rx_count * (1 + historical_accept_rate) * exp(-days_since_last/90)

LLM Prompt Template

You are a clinical pharmacist AI. A doctor is prescribing medications for a
patient. Based on the hospital's prescribing patterns and the patient's
clinical context, enrich the suggestions below.

## Prescribing Suggestions (from hospital pattern data)
{{suggestions | json}}

## Patient Context
Age: {{patient.age}}  Sex: {{patient.sex}}  Weight: {{patient.weight_kg}}kg
eGFR: {{patient.renal.eGFR}}  Creatinine: {{patient.renal.creatinine}}
ALT: {{patient.hepatic.alt}}  Bilirubin: {{patient.hepatic.bilirubin}}
Allergies: {{patient.allergies | join(', ')}}
Active medications: {{patient.active_meds | join(', ')}}
Diagnoses: {{patient.diagnoses | join(', ')}}

## For each suggestion, return:
1. adjustedDose — if the dose should change for this patient (renal/hepatic/weight), provide the adjusted value + reason
2. ddiWarnings — if this drug interacts with any active_med, describe the interaction + severity
3. alternatives — if a DDI is moderate/severe, suggest 1-2 alternatives from the suggestion pool
4. missingItems — prophylaxis or monitoring the doctor might want to add (e.g. PPI with NSAID, INR monitoring with warfarin)
5. durationNote — if the typical duration seems too short/long for this diagnosis

Return JSON array matching the input suggestions order:
[
  {
    "drugName": "...",
    "adjustedDose": { "value": "500mg", "reason": "eGFR 35 — reduce from 875mg" } | null,
    "ddiWarnings": [{ "interactsWith": "Warfarin", "severity": "major", "detail": "..." }],
    "alternatives": [{ "drugName": "...", "dose": "...", "reason": "..." }],
    "missingItems": ["Consider PPI prophylaxis"],
    "durationNote": "7 days is standard for uncomplicated CAP" | null
  }
]

Ranking Algorithm

score(suggestion) =
    source_weight                    # 3=your_history, 2=department, 1=hospital
  × rx_count                        # raw prescribing frequency
  × (1 + historical_accept_rate)    # from audit log (0.0–1.0, default 0.5)
  × recency_decay                   # exp(-(days_since_last_rx) / 90)
  × allergy_filter                  # 0 if patient allergic, else 1
  × ddi_penalty                     # 0.3 if major DDI, 0.7 if moderate, 1.0 if none

Suggestions with score < threshold are excluded. Threshold is configurable per department at /admin/medication-planner-config.

Feature Flags

Flag Layer Default Purpose
VISION_PLANNER_ENABLED Backend false Kill switch
medos_premium_medication_planner Frontend (localStorage) not set Feature gate

Integration Points

  • Prescription dialogMedicationAiPlanner mounts below the drug search field. When a diagnosis is selected or already present on the encounter, it auto-fetches suggestions.
  • Order favorites — existing order_favorite_item / order_favorite_order_set are included as “Your Favorites” tier (rank above department patterns).
  • CDS alerts — when the LLM flags a critical DDI, it emits manifest.clinical.alert via the existing CDS alert surface.
  • Audit — every suggestion session is logged. Acceptance/rejection feedback improves future rankings.
Ask Anything