Smart Diagnosis Unified Pipeline
Unified Notes to Diagnosis to Orders AI pipeline.
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.tsextractsdiagnoses: 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:
- Dictate or type a note → AI structures it (SOAP) and extracts candidate diagnoses
- See ranked differential diagnoses with SNOMED + ICD-10 codes, confidence scores, and reasoning — accept/reject with one click
- 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:
- Reads the current encounter’s latest clinical note from Supabase (
clinical_notestable) - If the note has
ai_processed_data, callsbridgeNoteToContext()and returns it - 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:
- Sets
voiceOrderSlice.phase = 'running'with a synthetic transcript built from the suggested orders - The voice-order runner searches catalogs and proposes matching items
- Opens
DialogOrderin 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 configinfrastructure/medbase/functions/terminology-server/valuesets/snomed-gps.json— GPS 6,600 conceptsinfrastructure/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 loopweb/src/services/ai/shared/client.ts— OpenAI-compatible client (move from voice-order)web/src/services/ai/shared/types.ts— RunnerStep, SeenMatches, ToolDispatchweb/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.tsweb/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.tsweb/src/services/ai/smart-diagnosis/prompts.tsweb/src/services/ai/smart-diagnosis/runner.tsweb/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 engineweb/packages/miniapps/diagnosis-ai/components/AIDifferentialPanel.tsx— add timelineweb/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 toolweb/src/services/ai/voice-order/prompts.ts— add diagnosis-aware sectionweb/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
getPatientDiagnosestool)
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:
-
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.
-
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.
Q: Does diagnosis AI replace the existing SNOMED search?
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 |