medOS ultra

Smart Diagnosis Unified Pipeline

Unified Notes to Diagnosis to Orders AI pipeline.

24 min read diagramsUpdated 2026-05-25docs/architecture/smart-diagnosis-unified-pipeline.md

Status: Proposal
Date: 2026-05-25
Depends on: voice-order runner (proven), diagnosis-ai miniapp (existing), LLM platform (deployed), clinical-note-ai (existing), terminology-server edge function (deployed)


1. Problem Statement

Today the doctor’s workflow has three disconnected silos:

┌─────────────────┐      ┌─────────────────┐      ┌─────────────────┐
│  Clinical Notes  │  ✗   │   Diagnosis      │  ✗   │   Orders         │
│  (AI extracts    │──────│   Dialog         │──────│   (voice-order   │
│   dx strings)    │      │   (manual SNOMED/ │      │   has no dx      │
│                  │      │    ICD-10 search) │      │   context)       │
└─────────────────┘      └─────────────────┘      └─────────────────┘
  • Notes → Diagnosis gap: clinical-note-ai.service.ts extracts diagnoses: string[] from STT/typed text, but those strings are never auto-linked to SNOMED CT / ICD-10 codes and never pushed to the diagnosis dialog.
  • Diagnosis → Orders gap: The voice-order runner has 7 search tools but zero awareness of the patient’s diagnoses. It can’t say “patient has pneumonia → suggest chest X-ray + amoxicillin.”
  • diagnosis-ai miniapp: Has the right UX (accept/reject differential panel, SNOMED→ICD-10 auto-map via Snowstorm) but uses a hardcoded 30-entry keyword-matching knowledge base instead of an LLM.

2. Goal

One unified pipeline where the doctor can:

  1. Dictate or type a note → AI structures it (SOAP) and extracts candidate diagnoses
  2. See ranked differential diagnoses with SNOMED + ICD-10 codes, confidence scores, and reasoning — accept/reject with one click
  3. Order with diagnosis context → voice-order (or manual order dialog) knows the patient’s confirmed diagnoses and can suggest relevant orders
Doctor speaks/types
        │
        ▼
┌──────────────────────────────────────────────────────────────┐
│                    Smart Diagnosis Pipeline                    │
│                                                                │
│  ┌────────────┐   ┌─────────────────┐   ┌─────────────────┐  │
│  │ Note AI    │──▶│ Diagnosis AI    │──▶│ Order AI        │  │
│  │ (structure)│   │ (code + rank)   │   │ (dx-aware)      │  │
│  └────────────┘   └─────────────────┘   └─────────────────┘  │
│        │                   │                     │             │
│   SOAP sections    SNOMED + ICD-10        Relevant orders     │
│   + vitals         + confidence           pre-suggested       │
│   + dx strings     + reasoning            with rationale      │
└──────────────────────────────────────────────────────────────┘
        │                   │                     │
        ▼                   ▼                     ▼
   Clinical Note      Diagnosis Dialog       Order Dialog
   (auto-saved)       (accept/reject)        (review/submit)

3. Architecture

3.1 Three Agents, Shared Runner Pattern

Each agent follows the voice-order runner blueprint — an LLM tool-calling loop with catalog grounding, confidence honesty, and seenMatches validation. They share the same runner.ts loop engine but have different tool sets and system prompts.

web/src/services/ai/
├── voice-order/              ← existing (orders only)
│   ├── runner.ts             ← generic tool-calling loop engine
│   ├── tools.ts              ← 8 search tools + proposeOrder
│   ├── prompts.ts            ← order-specific system prompt
│   ├── client.ts             ← OpenAI-compatible LLM client (shared)
│   └── rest-catalog.ts       ← order catalog adapter
│
├── smart-diagnosis/          ← NEW
│   ├── runner.ts             ← re-export from shared runner (or thin wrapper)
│   ├── tools.ts              ← diagnosis-specific tools (§3.2)
│   ├── prompts.ts            ← diagnosis system prompt (§3.3)
│   ├── types.ts              ← SmartDiagnosisPayload, ProposedDiagnosis
│   ├── diagnosis-catalog.ts  ← ICD-10/SNOMED search adapter
│   ├── note-bridge.ts        ← bridges clinical-note-ai output → diagnosis context
│   └── index.ts
│
├── shared/                   ← NEW (extract from voice-order)
│   ├── runner-engine.ts      ← generic LLM tool loop (was voice-order/runner.ts)
│   ├── client.ts             ← OpenAI-compatible client (was voice-order/client.ts)
│   ├── types.ts              ← RunnerStep, ToolDispatch, SeenMatches
│   └── validation.ts         ← seenMatches guardrail, confidence bands
│
├── clinical-note-ai.service.ts   ← existing (upgrade: output → diagnosis bridge)
├── medication-ai-alerts.service.ts
└── nurse-note-ai.service.ts

3.2 Diagnosis AI Tools

Seven tools the LLM can call during diagnosis reasoning:

// ── Search tools (populate seenMatches) ──────────────────────

searchIcd10(query: string, filters?: {
  diseaseGroupRef?: string;
  organRef?: string;
}): CatalogMatch[]
// Calls GET /v2/diagnostic/icd10s
// Returns: { catalogId, code, name, nameTH, snomed[], diseaseGroup, organ }

searchSnomedCt(query: string, filters?: {
  semanticTag?: 'disorder' | 'finding' | 'situation';
  refsetId?: string;
}): CatalogMatch[]
// Calls Snowstorm FHIR: GET /fhir/ValueSet/$expand?filter=query
// Returns: { catalogId, code, display, semanticTag }

mapSnomedToIcd10(snomedCode: string): IcdMapping[]
// Calls Snowstorm: GET /fhir/ConceptMap/$translate?code=snomedCode
// Returns: { icd10Code, icd10Display, equivalence }
// (existing pattern from useDiagnosisModule)

// ── Context tools ────────────────────────────────────────────

resolvePatientContext(): PatientDxContext
// Returns: age, sex, allergies, currentMedications[],
//          existingDiagnoses[], recentVitals, recentLabs[],
//          chiefComplaint, presentIllness, encounterType

resolveNoteContext(): NoteContext
// Bridges clinical-note-ai output into the loop
// Returns: soapSections, extractedDxStrings[], extractedVitals,
//          extractedMedications[], fhirTags[]

// ── Terminal action ──────────────────────────────────────────

proposeDifferentials(payload: {
  differentials: ProposedDiagnosis[];
  clinicalReasoning: string;
  confidence: number;           // overall 0..1
}): void
// TERMINAL — opens diff-review in diagnosis dialog

// ProposedDiagnosis shape:
{
  snomedCode: string;           // MUST exist in seenMatches
  snomedDisplay: string;
  icd10Code?: string;           // from mapSnomedToIcd10 result
  icd10Display?: string;
  type: 'principal' | 'co-morbidity' | 'complication' | 'external-cause';
  severity: 'low' | 'moderate' | 'high' | 'critical';
  confidence: number;           // per-item 0..1
  reasoning: string;            // why this diagnosis
  unverifiedFields: string[];   // e.g. ['laterality', 'severity', 'onset']
  suggestedOrders?: string[];   // hint for order agent: ["CBC", "Chest X-ray"]
}

3.3 System Prompt (Diagnosis Agent)

You are a clinical diagnosis assistant for a hospital information system.
You help doctors code diagnoses using SNOMED CT and ICD-10 based on clinical notes,
chief complaints, and patient context.

HARD RULES:
1. Every snomedCode in proposeDifferentials MUST come from a searchSnomedCt
   or searchIcd10 result you received earlier. Never invent codes.
2. Call resolvePatientContext FIRST to understand the patient before searching.
3. Call resolveNoteContext to get structured note content if available.
4. Use mapSnomedToIcd10 for every SNOMED code before proposing — ICD-10 is
   required for billing.
5. Confidence honesty:
   - 0.9–1.0: Diagnosis explicitly stated in notes AND confirmed by findings
   - 0.7–0.9: Strong clinical evidence but some inference
   - 0.5–0.7: Possible — needs doctor confirmation
   - <0.5: DO NOT PROPOSE — ask for more information instead
6. Mark unverifiedFields for any field you inferred (laterality, severity,
   onset, chronicity) without explicit evidence in the transcript.
7. Always propose a PRINCIPAL diagnosis. If multiple candidates compete,
   rank by clinical severity and evidence strength.
8. Type assignment rules:
   - First/strongest → 'principal'
   - Pre-existing conditions → 'co-morbidity'
   - Arising during treatment → 'complication'
   - V/W/X/Y ICD-10 codes → 'external-cause'
9. suggestedOrders is OPTIONAL — only include if clinically obvious
   (e.g., pneumonia → "Chest X-ray", "CBC", "Blood culture").
   These are hints for the order agent, not prescriptions.
10. Do NOT reason about drug interactions or prescribe treatment.
    That is the order agent's job.

WORKFLOW:
1. resolvePatientContext → understand the patient
2. resolveNoteContext → read structured note (if available)
3. searchSnomedCt for each candidate diagnosis
4. mapSnomedToIcd10 for each SNOMED match
5. searchIcd10 to verify/find additional codes if needed
6. proposeDifferentials with ranked list + reasoning

3.4 Connecting Notes → Diagnosis (the Bridge)

// smart-diagnosis/note-bridge.ts

import { AIProcessedNote } from '../clinical-note-ai.service';
import { NoteContext } from './types';

/**
 * Transforms clinical-note-ai output into the format
 * the diagnosis agent's resolveNoteContext tool returns.
 *
 * Called when the doctor has an active note in the encounter.
 * The diagnosis agent calls resolveNoteContext → this bridge
 * pulls the latest AIProcessedNote from the note store.
 */
export function bridgeNoteToContext(note: AIProcessedNote): NoteContext {
  return {
    soapSections: {
      subjective:  note.sections?.subjective,
      objective:   note.sections?.objective,
      assessment:  note.sections?.assessment,
      plan:        note.sections?.plan,
    },
    extractedDxStrings:    note.diagnoses,       // string[] from note AI
    extractedVitals:       note.vitals,
    extractedMedications:  note.medications,
    extractedProcedures:   note.procedures,
    fhirTags:              note.fhir_tags,
    noteConfidence:        note.confidence,
    noteModel:             note.model,
  };
}

When the diagnosis agent calls resolveNoteContext, the tool dispatcher:

  1. Reads the current encounter’s latest clinical note from Supabase (clinical_notes table)
  2. If the note has ai_processed_data, calls bridgeNoteToContext() and returns it
  3. If no AI-processed note exists, returns null (agent will rely on chief complaint + patient context only)

3.5 Connecting Diagnosis → Orders (Context Injection)

The voice-order system gets a new tool:

// Addition to voice-order/tools.ts

getPatientDiagnoses(): PatientDiagnosis[]
// Returns confirmed diagnoses for the current encounter:
// [{ snomedCode, snomedDisplay, icd10Code, type, severity }]

And a system prompt addition:

DIAGNOSIS-AWARE ORDERING:
- Call getPatientDiagnoses to see the patient's confirmed diagnoses.
- When suggesting orders, prefer those clinically relevant to the diagnoses.
- Example: if patient has "Pneumonia (J18.9)", prioritize:
  searchLab("CBC"), searchLab("blood culture"),
  searchImaging("chest", modality="XR"),
  searchMedication("amoxicillin") or searchMedication("ceftriaxone")
- If no diagnoses are confirmed yet, proceed based on transcript only (current behavior).
- Do NOT refuse to order if diagnoses are absent — the doctor may be ordering
  before diagnosis is finalized.

3.6 Redux State Machine

// store/diagnosis/smartDiagnosisSlice.ts

interface SmartDiagnosisState {
  phase: 'idle' | 'capturing' | 'running' | 'review' | 'applying';

  // Input
  transcript: string | null;
  interimTranscript: string | null;
  noteContext: NoteContext | null;        // from bridgeNoteToContext

  // Runner state
  steps: RunnerStep[];                    // live timeline
  currentStepIndex: number;

  // Output
  payload: SmartDiagnosisPayload | null;  // from proposeDifferentials
  clarification: string | null;           // if agent needs more info

  // Session
  sessionId: string | null;
  encounterId: string | null;
  patientId: string | null;
}

// Phase transitions:
// idle → capturing:  doctor opens diagnosis dialog with AI toggle ON
// capturing → running:  note context loaded OR doctor submits chief complaint
// running → review:  agent calls proposeDifferentials (terminal)
// running → idle:  agent returns clarification (no proposal)
// review → applying:  doctor accepts ≥1 differential
// applying → idle:  diagnosis records created, state cleared

3.7 Prefill Hook (Diagnosis → Dialog)

// smart-diagnosis/useSmartDiagnosisPrefill.ts

export function useSmartDiagnosisPrefill() {
  const { payload, phase } = useSelector(selectSmartDiagnosis);
  const dispatch = useDispatch();

  const prefillDiagnoses = useMemo(() => {
    if (phase !== 'applying' || !payload) return [];
    return payload.differentials
      .filter(d => d.status === 'accepted')
      .map(d => ({
        snomedCode: d.snomedCode,
        snomedDisplay: d.snomedDisplay,
        icd10Code: d.icd10Code,
        icd10Display: d.icd10Display,
        type: d.type,
        severity: d.severity,
        __ai: {
          confidence: d.confidence,
          reasoning: d.reasoning,
          unverifiedFields: d.unverifiedFields,
          sessionId: payload.sessionId,
        },
      }));
  }, [phase, payload]);

  return {
    prefillDiagnoses,
    hadPrefill: phase === 'applying' && prefillDiagnoses.length > 0,
    suggestedOrders: payload?.differentials
      .filter(d => d.status === 'accepted')
      .flatMap(d => d.suggestedOrders ?? []) ?? [],
    clearSession: () => dispatch(clearSmartDiagnosisSession()),
  };
}

4. UI Integration

4.1 Upgrade Path for diagnosis-ai Miniapp

The existing DiagnosisModuleAI component already has the right layout:

┌─────────────────────────────────────────────────────────┐
│ Voice Input (STT)                                       │
├───────────────────────────────┬──────────────────────────┤
│ Diagnosis Table (8 cols)      │ AI Differential Panel    │
│                               │ (4 cols)                 │
│ • SNOMED code + text          │ • Rank badge             │
│ • ICD-10 code + text          │ • Confidence %           │
│ • Type (principal/co-morb)    │ • Severity color         │
│ • ✨ chip if AI-applied       │ • Reasoning              │
│                               │ • Accept / Reject        │
│                               │ • suggestedOrders chips  │
├───────────────────────────────┴──────────────────────────┤
│ Clinical Reasoning (collapsible)                         │
└─────────────────────────────────────────────────────────┘

What changes:

Component Current Upgraded
useAIDiagnosis 30-entry keyword KB, scoreContext() LLM runner with searchSnomedCt + searchIcd10 + mapSnomedToIcd10 tools
AIDifferentialPanel Static list Live runner timeline (steps streaming) + differential list
Confidence Keyword match ratio LLM-assessed with unverifiedFields
Reasoning Template string LLM natural language
suggestedOrders Not present New chips linking to order dialog
Note integration Not present resolveNoteContext tool reads current note

What stays the same:

  • Accept/reject UX flow
  • SNOMED → ICD-10 auto-map via Snowstorm
  • Diagnosis table with drag-drop categorization
  • Type smart-assignment (V/W/X/Y → external-cause)
  • Backend CRUD via createDiagnosisMutation / updateDiagnosisMutation

4.2 Runner Timeline in the Differential Panel

While the agent is running, the panel shows a live timeline (same pattern as voice-order):

┌─ AI Analysis ────────────────────────────────┐
│                                               │
│  ● Reading patient context...          0.2s   │
│  ● Reading clinical note...            0.3s   │
│  ● Searching SNOMED: "pneumonia"       0.8s   │
│    └ 5 matches found                          │
│  ● Mapping J18.9 → ICD-10             0.2s   │
│  ● Searching SNOMED: "COPD"           0.7s   │
│    └ 3 matches found                          │
│  ● Proposing 4 differentials...        0.1s   │
│                                               │
│  ──────────────────────────────────────────   │
│                                               │
│  1. Community-acquired pneumonia  92%  🔴     │
│     J18.9 — Pneumonia, unspecified             │
│     "Fever + productive cough + bilateral      │
│      crackles + elevated WBC"                  │
│     Suggested: CBC, CXR, Blood culture         │
│     [✓ Accept]  [✗ Reject]                     │
│                                               │
│  2. COPD acute exacerbation       71%  🟠     │
│     J44.1 — COPD with acute exacerbation       │
│     "20-year smoking history, baseline dyspnea" │
│     [✓ Accept]  [✗ Reject]                     │
│                                               │
│  3. ...                                       │
└───────────────────────────────────────────────┘

4.3 Suggested Orders → Order Dialog Handoff

When the doctor accepts differentials with suggestedOrders, a chip bar appears:

┌─ Suggested Orders ─────────────────────────────┐
│  Based on accepted diagnoses:                   │
│  [CBC] [Chest X-ray] [Blood culture] [Amox...]  │
│                                                  │
│  [Open Order Dialog with these →]                │
└──────────────────────────────────────────────────┘

Clicking “Open Order Dialog” dispatches to the voice-order system:

  1. Sets voiceOrderSlice.phase = 'running' with a synthetic transcript built from the suggested orders
  2. The voice-order runner searches catalogs and proposes matching items
  3. Opens DialogOrder in diff-review mode (existing flow)

Alternatively, the suggested orders can be passed as initial search queries to the manual order dialog tabs.

5. Terminology Tier Strategy (Multi-Region)

5.1 The Problem with Direct Snowstorm

Today 35+ frontend files call https://snowstorm-fhir.snomedtools.org directly — an external public server with no SLA. Most calls have no try-catch. This works for Thailand (SNOMED member) but fails for Philippines (non-member), and crashes the app when the server is unreachable.

The terminology-server Deno edge function already solves this with a cache-first, pluggable provider chain:

Request → terminology_cache (Supabase, 30-day TTL)
              ↓ miss
          Local JSON valuesets (bundled, offline)
              ↓ miss
          Snowstorm provider (if configured + available)
              ↓ miss
          404 — code not found

The fix: All diagnosis AI tools route through terminology-server.service.ts, never directly to Snowstorm. The terminology server handles tier-based resolution.

5.2 Three Tiers by SNOMED Membership

┌──────────────────────────────────────────────────────────────────┐
│ Tier 1: Full SNOMED CT (member countries — free license)         │
│   Countries: Thailand, Singapore, Malaysia, Brunei               │
│   Source: Self-hosted Snowstorm, 350k+ concepts                  │
│   Fallback: terminology_cache → bundled snomed-common.json       │
│   Market pack config: terminologyTier = "snomed-full"            │
├──────────────────────────────────────────────────────────────────┤
│ Tier 2: SNOMED GPS + ICD-10 (non-members — free GPS license)    │
│   Countries: Philippines, Indonesia, Vietnam                     │
│   Source: GPS refset (6,600 concepts) as bundled JSON valueset   │
│   ICD-10: Full local backend (5,737 codes) + per-country overlay │
│   Fallback: snomed-gps.json → icd10-{country}.json              │
│   Market pack config: terminologyTier = "gps-icd10"             │
├──────────────────────────────────────────────────────────────────┤
│ Tier 3: ICD-10 only (minimal digital infrastructure)            │
│   Countries: Myanmar, Cambodia, Laos                             │
│   Source: Local ICD-10 backend only, works fully offline          │
│   No SNOMED dependency whatsoever                                │
│   Market pack config: terminologyTier = "icd10-only"            │
├──────────────────────────────────────────────────────────────────┤
│ Cross-cutting (all tiers, free, no license):                     │
│   LOINC (labs) — bundled loinc-common.json                       │
│   ATC (drugs) — bundled atc-common.json                          │
│   TMT (Thai drugs) — bundled tmt-nlem-2024.json (TH only)       │
│   NHSO categories — bundled nhso-19-categories.json (TH only)   │
└──────────────────────────────────────────────────────────────────┘

5.3 Market Pack Manifest Extension

Add terminology block to each manifest.json:

// infrastructure/market-packs/medos-philippines/manifest.json
{
  "region": "philippines",
  "locale": "fil",
  "timezone": "Asia/Manila",
  "currency": "PHP",
  "terminology": {
    "tier": "gps-icd10",
    "primaryCoding": "ICD10",           // what billing requires
    "snomedEdition": "gps",             // "international" | "gps" | null
    "icd10Variant": "ICD10",            // "ICD10" | "ICD10-TM" | "ICD10-AM"
    "procedureCoding": "RVS",           // "ICD9CM" | "RVS" | "ACHI"
    "drugCoding": null,                 // "TMT" | "MIMS" | null
    "valuesets": [
      "icd10-ph-common.json",          // PhilHealth-specific ICD-10 subset
      "snomed-gps.json"                // GPS 6,600 concepts
    ],
    "snowstormUrl": null                // no self-hosted Snowstorm
  }
}

// infrastructure/market-packs/medos-thailand/manifest.json
{
  "region": "thailand",
  "locale": "th",
  "terminology": {
    "tier": "snomed-full",
    "primaryCoding": "SNOMED",
    "snomedEdition": "international",
    "icd10Variant": "ICD10-TM",
    "procedureCoding": "ICD9CM",
    "drugCoding": "TMT",
    "valuesets": [
      "icd10-th-common.json",
      "icd9cm-th-procedures.json",
      "snomed-common.json",
      "tmt-nlem-2024.json",
      "nhso-19-categories.json"
    ],
    "snowstormUrl": "${SNOWSTORM_URL}"  // self-hosted or VITE_SNOWSTORM_URL
  }
}

5.4 How the Diagnosis AI Adapts by Tier

The diagnosis-catalog.ts adapter reads the market pack’s terminology tier and adjusts tool behavior:

// smart-diagnosis/diagnosis-catalog.ts

class DiagnosisCatalog {
  constructor(private tier: TerminologyTier) {}

  async searchClinicalConcept(query: string): Promise<CatalogMatch[]> {
    switch (this.tier) {
      case 'snomed-full':
        // Search via terminology-server → Snowstorm (full SNOMED)
        // Falls back to bundled snomed-common.json if Snowstorm down
        return this.searchTerminology('SNOMED', query);

      case 'gps-icd10':
        // Search GPS refset first (6,600 concepts via bundled JSON)
        // Then search local ICD-10 backend for billing codes
        const gpsHits = await this.searchTerminology('SNOMED', query); // GPS subset
        const icdHits = await this.searchIcd10(query);
        return this.mergeAndRank(gpsHits, icdHits);

      case 'icd10-only':
        // ICD-10 backend only — no SNOMED at all
        return this.searchIcd10(query);
    }
  }

  async mapToIcd10(snomedCode: string): Promise<IcdMapping[]> {
    switch (this.tier) {
      case 'snomed-full':
        // terminology-server → Snowstorm ConceptMap/$translate
        // Falls back to local snomed[] field on ICD-10 records
        return this.translateViaTerm(snomedCode);

      case 'gps-icd10':
        // GPS JSON has built-in translations.ICD10 per concept
        // Falls back to ICD-10 backend keyword search
        return this.translateViaGps(snomedCode);

      case 'icd10-only':
        // No SNOMED → no mapping needed
        return [];
    }
  }
}

The LLM tool set adapts too:

Tool Tier 1 (SNOMED full) Tier 2 (GPS + ICD-10) Tier 3 (ICD-10 only)
searchSnomedCt Full Snowstorm search GPS 6,600 concepts (bundled) Not available — tool hidden from LLM
searchIcd10 Local backend Local backend + country overlay Local backend
mapSnomedToIcd10 Snowstorm ConceptMap GPS translations.ICD10 field Not available
resolvePatientContext Same Same Same
resolveNoteContext Same Same Same
proposeDifferentials SNOMED + ICD-10 GPS SNOMED + ICD-10 ICD-10 only

The system prompt adjusts per tier:

  • Tier 1: “Search SNOMED CT first, then map to ICD-10 for billing.”
  • Tier 2: “Search the GPS clinical concept set first. Always include ICD-10 for PhilHealth billing.”
  • Tier 3: “Search ICD-10 directly. SNOMED is not available in this deployment.”

5.5 New Valueset Files Needed

infrastructure/medbase/functions/terminology-server/valuesets/
├── icd10-th-common.json          ← EXISTS (20 codes, Thai demo)
├── icd9cm-th-procedures.json     ← EXISTS (10 codes)
├── snomed-common.json            ← EXISTS (8 codes)
├── snomed-gps.json               ← NEW: GPS 6,600 concepts (from MLDS download)
├── icd10-ph-common.json          ← NEW: PhilHealth high-frequency ICD-10 codes
├── icd10-jp-common.json          ← NEW: Japan DPC-relevant ICD-10 codes
├── icd10-id-common.json          ← FUTURE: Indonesia INA-CBG codes
├── icd10-vn-common.json          ← FUTURE: Vietnam BHXH codes
├── loinc-common.json             ← EXISTS (10 codes)
├── atc-common.json               ← EXISTS (10 codes)
├── tmt-nlem-2024.json            ← EXISTS (13 drugs)
├── nhso-19-categories.json       ← EXISTS (19 categories)
└── allergens-common.json         ← EXISTS (26 allergens)

5.6 Migrating 35+ Hardcoded Snowstorm URLs

All files currently calling Snowstorm directly must be migrated to use terminology-server.service.ts:

// BEFORE (crashes when Snowstorm is down):
const response = await axios.get(
  `https://snowstorm-fhir.snomedtools.org/fhir/ConceptMap/$translate?code=${code}&system=http://snomed.info/sct`
);

// AFTER (resilient, tier-aware):
import { searchTerminology, lookupTerminology } from '@/services/terminology-server.service';
const results = await searchTerminology('SNOMED', query, { semanticTag: 'disorder' });
// terminology-server handles: cache → local JSON → Snowstorm → graceful 404

Files to migrate (by priority):

Priority File pattern Count Impact if Snowstorm down
P0 Dialogdiagnosis.tsx 1 Main diagnosis entry crashes
P0 RenderIcdTab.tsx 1 Surgery/procedure coding crashes
P1 ListDetailsOperativeNote.tsx 1 Operative note coding crashes
P1 BloodBankRequest.tsx 1 Blood bank coding crashes
P2 perform-surgery/** ~5 Surgery workflow crashes
P2 perform-labourroom/** ~5 Labour room coding crashes
P3 web-legacy/** ~15 Legacy components (lower priority)

5.7 Backfilling SNOMED→ICD-10 Mappings Locally

The ICD-10 schema has a snomed: string[] field but seed data leaves it empty. For offline SNOMED→ICD-10 mapping (critical for Tier 2 GPS):

Option A: Enrich the GPS valueset — each GPS concept’s translations.ICD10 already contains the mapping. The snomed-gps.json valueset carries its own cross-references. No need to touch the ICD-10 backend seed.

Option B: Backfill ICD-10 seed — WHO publishes the official SNOMED CT → ICD-10 map. Run a one-time script to populate snomed[] on the 5,737 ICD-10 records. Enables snomedIcdMap.service.ts to resolve locally without Snowstorm.

Recommendation: Option A for MVP (GPS carries its own mappings), Option B as a follow-up (enriches ICD-10 for all tiers).

6. LLM Platform Integration

6.1 Use-Case Registration

{
  "code": "smart-diagnosis",
  "displayName": "Smart Diagnosis Assistant",
  "description": "LLM-powered differential diagnosis from clinical notes with SNOMED/ICD-10 grounding",
  "primaryModel": "<ollama-qwen2.5:14b-instruct>",
  "fallbackModel": "<ollama-llama3.1:8b-instruct>",
  "embeddingModel": "<ollama-nomic-embed-text>",
  "systemPrompt": "<<see §3.3>>",
  "rag": {
    "corpusIds": ["clinical-guidelines-corpus"],
    "topK": 3,
    "minScore": 0.5,
    "mmrLambda": 0.6
  },
  "generation": {
    "temperature": 0.2,
    "maxTokens": 2048,
    "topP": 0.9
  },
  "tools": ["searchIcd10", "searchSnomedCt", "mapSnomedToIcd10",
            "resolvePatientContext", "resolveNoteContext", "proposeDifferentials"],
  "rateLimitPerMin": 30,
  "active": true
}

6.2 RAG Corpus: Clinical Guidelines

A new corpus for grounding diagnosis reasoning:

{
  "code": "clinical-guidelines",
  "displayName": "Clinical Practice Guidelines",
  "sourceType": "supabase-table",
  "sourceConfig": {
    "table": "clinical_guidelines",
    "textTemplate": "{{title}}\n\n{{content}}\n\nDiagnosis criteria: {{criteria}}\nICD-10: {{icd10_codes}}"
  },
  "chunkStrategy": "sentence",
  "chunkSize": 512,
  "chunkOverlap": 64
}

This is optional for MVP — the agent works without RAG by relying on its training knowledge + catalog search grounding. RAG adds evidence-based reasoning citations.

6.3 Audit Trail

Every diagnosis AI session is logged to llm_audit_log:

{
  "action": "smart-diagnosis",
  "use_case": "smart-diagnosis",
  "patient_id": "<<redacted-hash>>",
  "encounter_id": "enc_123",
  "input_summary": "CC: fever, cough 3 days",
  "output_summary": "Proposed 4 differentials, principal: J18.9 Pneumonia (92%)",
  "model": "qwen2.5:14b-instruct",
  "steps": 6,
  "tokens_in": 1847,
  "tokens_out": 523,
  "duration_ms": 3200,
  "accepted_codes": ["J18.9", "J44.1"],
  "rejected_codes": ["R50.9"]
}

7. Data Flow: End-to-End

┌─────────────────────────────────────────────────────────────────────┐
│ ENCOUNTER VIEW                                                       │
│                                                                       │
│  ┌──────────────┐    ┌─────────────────┐    ┌────────────────────┐   │
│  │ Clinical Note │    │ Diagnosis Dialog │    │ Order Dialog       │   │
│  │ Editor        │    │ + AI Panel       │    │ + Voice Order      │   │
│  └──────┬───────┘    └────────┬────────┘    └─────────┬──────────┘   │
│         │                     │                        │              │
└─────────┼─────────────────────┼────────────────────────┼──────────────┘
          │                     │                        │
          ▼                     ▼                        ▼
┌──────────────────┐  ┌──────────────────┐  ┌──────────────────────┐
│ clinical-note-ai │  │ smart-diagnosis  │  │ voice-order          │
│ .service.ts      │  │ runner           │  │ runner               │
│                  │  │                  │  │                      │
│ STT/text → SOAP  │  │ LLM + tools:    │  │ LLM + tools:         │
│ + vitals         │  │ searchSnomedCt   │  │ searchMedication     │
│ + dx strings     │  │ searchIcd10      │  │ searchLab            │
│ + medications    │  │ mapSnomedToIcd10 │  │ searchImaging        │
│ + fhir_tags      │  │ resolvePatient   │  │ + getPatientDiagnoses│
│                  │  │ resolveNote ◀────│──│   (NEW tool)         │
│                  │  │ proposeDx        │  │ proposeOrder         │
└────────┬─────────┘  └────────┬─────────┘  └──────────┬───────────┘
         │                     │                        │
         │    note-bridge.ts   │                        │
         └─────────────────────┘   suggestedOrders[]    │
                                   ─────────────────────┘

Stores:                    Writes:                    Writes:
clinical_notes (Supabase)  diagnosis (MongoDB)        orderRequest (MongoDB)
                           encounter_journey_cache    encounter_journey_cache
                           hospital_events            hospital_events

8. Implementation Phases

Phase 0: Terminology Tier Foundation (2 days)

Migrate hardcoded Snowstorm URLs to terminology-server.service.ts. Add terminology block to market pack manifests. Create snomed-gps.json and icd10-ph-common.json valuesets.

Files:

  • infrastructure/market-packs/*/manifest.json — add terminology config
  • infrastructure/medbase/functions/terminology-server/valuesets/snomed-gps.json — GPS 6,600 concepts
  • infrastructure/medbase/functions/terminology-server/valuesets/icd10-ph-common.json — PhilHealth codes
  • P0 migrations: Dialogdiagnosis.tsx, RenderIcdTab.tsx — replace direct Snowstorm → searchTerminology()

Phase 1: Extract Shared Runner Engine (1 day)

Extract the generic tool-calling loop from voice-order/runner.ts into shared/runner-engine.ts. Voice-order becomes a thin wrapper.

Files:

  • web/src/services/ai/shared/runner-engine.ts — generic loop
  • web/src/services/ai/shared/client.ts — OpenAI-compatible client (move from voice-order)
  • web/src/services/ai/shared/types.ts — RunnerStep, SeenMatches, ToolDispatch
  • web/src/services/ai/shared/validation.ts — seenMatches guardrail
  • Update voice-order/runner.ts → import from shared

Phase 2: Diagnosis Catalog Adapter (1 day)

Build the catalog adapter that wraps ICD-10/SNOMED search APIs.

Files:

  • web/src/services/ai/smart-diagnosis/diagnosis-catalog.ts
  • web/src/services/ai/smart-diagnosis/types.ts

Tests: Sandbox mock catalog with 20 ICD-10 + 20 SNOMED entries.

Phase 3: Diagnosis Runner + Tools (2 days)

Build the diagnosis-specific runner with 6 tools.

Files:

  • web/src/services/ai/smart-diagnosis/tools.ts
  • web/src/services/ai/smart-diagnosis/prompts.ts
  • web/src/services/ai/smart-diagnosis/runner.ts
  • web/src/services/ai/smart-diagnosis/note-bridge.ts

Tests: Sandbox mock LLM with scripted diagnosis flows.

Phase 4: Upgrade diagnosis-ai Miniapp (2 days)

Replace useAIDiagnosis keyword engine with the LLM runner. Add runner timeline to AIDifferentialPanel. Add suggestedOrders chips.

Files:

  • web/packages/miniapps/diagnosis-ai/hooks/useAIDiagnosis.ts — swap engine
  • web/packages/miniapps/diagnosis-ai/components/AIDifferentialPanel.tsx — add timeline
  • web/packages/miniapps/diagnosis-ai/components/SuggestedOrdersBar.tsx — new

Phase 5: Note → Diagnosis Bridge (1 day)

Wire clinical-note-ai output into the diagnosis runner via resolveNoteContext.

Files:

  • web/src/services/ai/smart-diagnosis/note-bridge.ts
  • Redux wiring in smartDiagnosisSlice.ts

Phase 6: Diagnosis → Orders Context (1 day)

Add getPatientDiagnoses tool to voice-order runner. Update system prompt.

Files:

  • web/src/services/ai/voice-order/tools.ts — add tool
  • web/src/services/ai/voice-order/prompts.ts — add diagnosis-aware section
  • web/src/services/ai/voice-order/runner.ts — add dispatcher

Phase 7: LLM Platform Use-Case Registration (0.5 day)

Register smart-diagnosis use-case in super-admin. Optional: set up clinical guidelines RAG corpus.

Phase 8: Sandbox E2E (1 day)

Playwright sandbox smoke test covering:

  • Note → extract diagnoses → AI suggests differentials → accept → save
  • Accepted diagnoses visible in order dialog context
  • Voice order with diagnosis awareness

Total estimated: ~11.5 days (was 9.5 + 2 days for Phase 0 terminology foundation)

9. Fallback Strategy

The system degrades gracefully across multiple failure modes:

Condition Tier 1 (TH/SG/MY) Tier 2 (PH/ID/VN) Tier 3 (MM/KH/LA)
Everything works Full Snowstorm SNOMED + ICD-10 GPS 6,600 concepts + ICD-10 ICD-10 only (by design)
LLM unavailable Keyword KB fallback (30 entries) Keyword KB fallback Keyword KB fallback
Snowstorm down Cache → bundled snomed-common.json → ICD-10 only No impact (GPS is bundled JSON) No impact (no SNOMED)
terminology-server down Direct ICD-10 backend (/v2/diagnostic/icd10s) Direct ICD-10 backend Direct ICD-10 backend
No clinical note Agent uses chief complaint + patient context Same Same
No diagnoses confirmed Voice-order works transcript-only Same Same
RAG corpus empty LLM training knowledge + catalog grounding Same Same

Key insight: ICD-10 backend (/v2/diagnostic/icd10s) is the universal fallback across all tiers. It’s local MongoDB, always available, never depends on external services. Every tier can code diagnoses using ICD-10 alone — SNOMED is an enhancement, not a requirement.

10. Privacy & Safety

Concern Mitigation
PHI in LLM prompts Local Ollama only — no external API calls. Patient data stays on-premise / VPC.
Audit trail Every AI session logged to llm_audit_log with redacted patient hash
Hallucinated codes seenMatches validation — every SNOMED/ICD-10 code must come from a catalog search result
Over-reliance on AI Confidence bands + unverifiedFields + mandatory accept/reject UX — doctor always confirms
suggestedOrders liability Orders are hints only — still go through full order dialog review + pharmacist verification

11. Key Design Decisions

Q: Unified agent vs. separate agents?

Answer: Separate agents, shared runner.

  • Note AI stays as-is (GPT-4o-mini / regex fallback for structuring)
  • Diagnosis AI is a new agent (SNOMED/ICD-10 grounding focus)
  • Order AI is the existing voice-order (gains getPatientDiagnoses tool)

They communicate via Redux state and bridges, not nested LLM calls. This means:

  • Each agent can be tested and deployed independently
  • Failure in one doesn’t cascade (graceful degradation)
  • Different models can power different agents (e.g., larger model for diagnosis, smaller for orders)
  • Latency is additive but parallelizable (notes + diagnosis can run concurrently)

Q: When does the doctor interact with diagnosis AI?

Two trigger points:

  1. Passive (auto-analyze): When the doctor opens the diagnosis dialog and has an active clinical note for this encounter, the agent auto-runs using note context. Differentials appear in the panel within 2-4 seconds.

  2. Active (voice/text): The doctor can dictate or type a chief complaint / present illness directly in the diagnosis dialog’s voice input section. The agent runs on this input.

Both flows end at the same accept/reject panel.

No. The manual SNOMED search dialog (DiagnosisSearchDialog) remains available. The AI panel is an addition — it suggests, the doctor can still manually search and add. The AI toggle (aiEnabled) lets the doctor disable AI entirely.

Q: Where does suggestedOrders come from?

The diagnosis LLM generates these as an optional hint — not from a separate order search. They’re free-text strings like “CBC”, “Chest X-ray” that the order system interprets. The diagnosis agent is NOT searching order catalogs — that’s the order agent’s job. The handoff is:

Diagnosis agent → suggestedOrders: ["CBC", "Chest X-ray"]
                        ↓
Order dialog opens → synthetic transcript: "Order CBC and Chest X-ray"
                        ↓
Voice-order runner → searchLab("CBC") → searchImaging("chest", "XR") → proposeOrder

12. File Inventory (New + Modified)

New Files

File Purpose
infrastructure/medbase/functions/terminology-server/valuesets/snomed-gps.json GPS 6,600 SNOMED concepts (Tier 2)
infrastructure/medbase/functions/terminology-server/valuesets/icd10-ph-common.json PhilHealth high-frequency ICD-10 codes
web/src/services/ai/shared/runner-engine.ts Generic LLM tool-calling loop
web/src/services/ai/shared/client.ts OpenAI-compatible client (extracted)
web/src/services/ai/shared/types.ts Shared types (RunnerStep, SeenMatches)
web/src/services/ai/shared/validation.ts seenMatches guardrail
web/src/services/ai/smart-diagnosis/tools.ts 6 diagnosis tools
web/src/services/ai/smart-diagnosis/prompts.ts Diagnosis system prompt
web/src/services/ai/smart-diagnosis/runner.ts Diagnosis runner (wraps shared engine)
web/src/services/ai/smart-diagnosis/types.ts ProposedDiagnosis, SmartDiagnosisPayload
web/src/services/ai/smart-diagnosis/diagnosis-catalog.ts ICD-10 + SNOMED catalog adapter
web/src/services/ai/smart-diagnosis/note-bridge.ts clinical-note-ai → diagnosis context
web/src/services/ai/smart-diagnosis/index.ts Barrel exports
web/src/store/diagnosis/smartDiagnosisSlice.ts Redux state machine
web/src/services/ai/smart-diagnosis/useSmartDiagnosisPrefill.ts Prefill hook
web/packages/miniapps/diagnosis-ai/components/SuggestedOrdersBar.tsx Order hint chips
web/sandbox/mocks/smart-diagnosis-llm.ts Sandbox mock LLM
web/sandbox/mocks/smart-diagnosis-catalog.ts Sandbox mock catalog

Modified Files

File Change
infrastructure/market-packs/*/manifest.json Add terminology config block
web/packages/medical-kit/src/diagnostics/dialog-diagnosis/Dialogdiagnosis.tsx Replace direct Snowstorm → searchTerminology()
web/packages/medical-kit/src/encounter-tools/timeline/dialogs/*/RenderIcdTab.tsx Replace direct Snowstorm → searchTerminology()
web/src/services/ai/voice-order/runner.ts Import from shared/runner-engine
web/src/services/ai/voice-order/tools.ts Add getPatientDiagnoses tool
web/src/services/ai/voice-order/prompts.ts Add diagnosis-aware section
web/packages/miniapps/diagnosis-ai/hooks/useAIDiagnosis.ts Swap keyword engine → LLM runner
web/packages/miniapps/diagnosis-ai/components/AIDifferentialPanel.tsx Add runner timeline + suggestedOrders
web/packages/miniapps/diagnosis-ai/components/DiagnosisModuleAI.tsx Wire smartDiagnosisSlice
web/packages/miniapps/diagnosis-ai/types.ts Add suggestedOrders to AIDifferential
web/src/store/index.ts Register smartDiagnosisSlice
Ask Anything