|
| 1 | +// /cpic — CPIC pharmacogenomics cockpit (gene-first), additive alongside /fma-body. |
| 2 | +// |
| 3 | +// A scenario {gene, diplotype|phenotype, drug} is posted to POST /api/cpic/reason, which runs |
| 4 | +// the standalone `cpic` crate's reason() over the REAL published CPIC tables (allele, |
| 5 | +// gene_result, drug, pair, guideline, recommendation). It resolves a phenotype and chains |
| 6 | +// diplotype → phenotype → recommendation by 2-hop NARS deduction with CPIC-authoritative |
| 7 | +// confidence (classification → f, pair cpiclevel → c). Each chain node carries its routable |
| 8 | +// (part_of:is_a) GUID prefix — the same canonical 16-byte NodeGuid the rest of the stack uses. |
| 9 | +// |
| 10 | +// POC over published CPIC rules — NOT clinical decision support. |
| 11 | +import { useEffect, useMemo, useState } from 'react'; |
| 12 | + |
| 13 | +interface ChainNode { |
| 14 | + role: string; // "diplotype" | "phenotype" | "recommendation" |
| 15 | + label: string; |
| 16 | + guid: string; |
| 17 | +} |
| 18 | + |
| 19 | +interface Outcome { |
| 20 | + gene: string; |
| 21 | + input: string; |
| 22 | + drug: string; |
| 23 | + resolved: boolean; |
| 24 | + phenotype: string | null; |
| 25 | + how: string | null; |
| 26 | + chain: ChainNode[]; |
| 27 | + classification: string | null; |
| 28 | + cpic_level: string | null; |
| 29 | + truth_f: number; |
| 30 | + truth_c: number; |
| 31 | + truth_exp: number; |
| 32 | + recommendation: string | null; |
| 33 | + flags: string[]; |
| 34 | + disclaimer: string; |
| 35 | +} |
| 36 | + |
| 37 | +interface Catalog { |
| 38 | + genes: string[]; |
| 39 | + drugs: string[]; |
| 40 | +} |
| 41 | + |
| 42 | +interface Scenario { |
| 43 | + gene: string; |
| 44 | + input: string; |
| 45 | + drug: string; |
| 46 | +} |
| 47 | + |
| 48 | +// the four `reason` CLI demos: clean 2-hop, direct 1-hop, multi-gene flag, complex-guideline flag. |
| 49 | +const EXAMPLES: { label: string; sc: Scenario }[] = [ |
| 50 | + { label: 'CYP2C19 *2/*2 · clopidogrel', sc: { gene: 'CYP2C19', input: '*2/*2', drug: 'clopidogrel' } }, |
| 51 | + { label: 'TPMT *3A/*3A · azathioprine', sc: { gene: 'TPMT', input: '*3A/*3A', drug: 'azathioprine' } }, |
| 52 | + { label: 'HLA-B *57:01 positive · abacavir', sc: { gene: 'HLA-B', input: '*57:01 positive', drug: 'abacavir' } }, |
| 53 | + { label: 'CYP2C9 *1/*1 · warfarin (complex)', sc: { gene: 'CYP2C9', input: '*1/*1', drug: 'warfarin' } }, |
| 54 | +]; |
| 55 | + |
| 56 | +const ROLE_COLOR: Record<string, string> = { |
| 57 | + diplotype: '#6db3ff', |
| 58 | + phenotype: '#7fd9a8', |
| 59 | + recommendation: '#ffb86b', |
| 60 | +}; |
| 61 | + |
| 62 | +function levelColor(level: string | null): string { |
| 63 | + switch (level) { |
| 64 | + case 'A': return '#35d07f'; |
| 65 | + case 'B': return '#9ad07f'; |
| 66 | + case 'C': return '#ffb547'; |
| 67 | + case 'D': return '#ff8c63'; |
| 68 | + default: return '#93a9bf'; |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +export function CpicCockpit() { |
| 73 | + const [catalog, setCatalog] = useState<Catalog>({ genes: [], drugs: [] }); |
| 74 | + const [gene, setGene] = useState('CYP2C19'); |
| 75 | + const [input, setInput] = useState('*2/*2'); |
| 76 | + const [drug, setDrug] = useState('clopidogrel'); |
| 77 | + const [outcome, setOutcome] = useState<Outcome | null>(null); |
| 78 | + const [error, setError] = useState<string | null>(null); |
| 79 | + const [busy, setBusy] = useState(false); |
| 80 | + |
| 81 | + // pull the gene + drug pick-lists once (used as <datalist> autocomplete; free text still allowed). |
| 82 | + useEffect(() => { |
| 83 | + let cancelled = false; |
| 84 | + fetch('/api/cpic/catalog') |
| 85 | + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) |
| 86 | + .then((c: Catalog) => { if (!cancelled) setCatalog(c); }) |
| 87 | + .catch(() => { /* catalog is optional — the inputs accept free text regardless */ }); |
| 88 | + return () => { cancelled = true; }; |
| 89 | + }, []); |
| 90 | + |
| 91 | + async function runReason(sc: Scenario) { |
| 92 | + setBusy(true); |
| 93 | + setError(null); |
| 94 | + setOutcome(null); |
| 95 | + try { |
| 96 | + const r = await fetch('/api/cpic/reason', { |
| 97 | + method: 'POST', |
| 98 | + headers: { 'content-type': 'application/json' }, |
| 99 | + body: JSON.stringify(sc), |
| 100 | + }); |
| 101 | + if (!r.ok) throw new Error(`HTTP ${r.status}`); |
| 102 | + setOutcome((await r.json()) as Outcome); |
| 103 | + } catch (e) { |
| 104 | + setError(`reasoning endpoint unavailable (${e}) — needs the cockpit-server backend deployed`); |
| 105 | + } finally { |
| 106 | + setBusy(false); |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + const canReason = useMemo( |
| 111 | + () => gene.trim() !== '' && input.trim() !== '' && drug.trim() !== '' && !busy, |
| 112 | + [gene, input, drug, busy], |
| 113 | + ); |
| 114 | + |
| 115 | + function pickExample(sc: Scenario) { |
| 116 | + setGene(sc.gene); |
| 117 | + setInput(sc.input); |
| 118 | + setDrug(sc.drug); |
| 119 | + void runReason(sc); |
| 120 | + } |
| 121 | + |
| 122 | + const fieldStyle: React.CSSProperties = { |
| 123 | + boxSizing: 'border-box', padding: '8px 10px', borderRadius: 6, |
| 124 | + border: '1px solid #2a3242', background: '#0e1219', color: '#cdd9e5', |
| 125 | + font: '13px ui-monospace, monospace', |
| 126 | + }; |
| 127 | + const chip: React.CSSProperties = { |
| 128 | + padding: '5px 11px', borderRadius: 6, border: '1px solid #2a3242', |
| 129 | + background: '#0e1219', color: '#9fb1c4', font: '12px ui-monospace, monospace', cursor: 'pointer', |
| 130 | + }; |
| 131 | + const badge = (bg: string, fg: string): React.CSSProperties => ({ |
| 132 | + display: 'inline-block', padding: '2px 9px', borderRadius: 999, |
| 133 | + background: bg, color: fg, font: '11px ui-monospace, monospace', marginRight: 8, |
| 134 | + }); |
| 135 | + |
| 136 | + return ( |
| 137 | + <div style={{ position: 'fixed', inset: 0, background: '#0a0e17', overflow: 'auto', color: '#cdd9e5' }}> |
| 138 | + <div style={{ maxWidth: 760, margin: '0 auto', padding: '28px 20px 60px', font: '13px ui-monospace, monospace' }}> |
| 139 | + <div style={{ fontSize: 20, color: '#fff', marginBottom: 4 }}> |
| 140 | + CPIC pharmacogenomics |
| 141 | + </div> |
| 142 | + <div style={{ opacity: 0.65, marginBottom: 2 }}> |
| 143 | + gene → phenotype → recommendation, chained by NARS deduction over the real CPIC tables. |
| 144 | + </div> |
| 145 | + <div style={{ opacity: 0.5, fontSize: 11, marginBottom: 18 }}> |
| 146 | + confidence is CPIC-authoritative (classification → f, pair cpiclevel → c). Each node shows its |
| 147 | + routable (part_of:is_a) GUID. POC over published CPIC rules — <b>not</b> clinical decision support. |
| 148 | + </div> |
| 149 | + |
| 150 | + {/* input row */} |
| 151 | + <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}> |
| 152 | + <label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 130px' }}> |
| 153 | + <span style={{ opacity: 0.6, fontSize: 11 }}>gene</span> |
| 154 | + <input list="cpic-genes" value={gene} onChange={(e) => setGene(e.target.value)} |
| 155 | + placeholder="CYP2C19" style={fieldStyle} /> |
| 156 | + </label> |
| 157 | + <label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 150px' }}> |
| 158 | + <span style={{ opacity: 0.6, fontSize: 11 }}>diplotype / phenotype</span> |
| 159 | + <input value={input} onChange={(e) => setInput(e.target.value)} |
| 160 | + placeholder="*2/*2" style={fieldStyle} /> |
| 161 | + </label> |
| 162 | + <label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 150px' }}> |
| 163 | + <span style={{ opacity: 0.6, fontSize: 11 }}>drug</span> |
| 164 | + <input list="cpic-drugs" value={drug} onChange={(e) => setDrug(e.target.value)} |
| 165 | + placeholder="clopidogrel" style={fieldStyle} /> |
| 166 | + </label> |
| 167 | + <button |
| 168 | + disabled={!canReason} |
| 169 | + onClick={() => runReason({ gene, input, drug })} |
| 170 | + style={{ |
| 171 | + ...fieldStyle, cursor: canReason ? 'pointer' : 'not-allowed', |
| 172 | + border: '1px solid #3a5f88', background: canReason ? '#16202e' : '#0c1017', |
| 173 | + color: canReason ? '#cdd9e5' : '#566', padding: '9px 18px', |
| 174 | + }} |
| 175 | + > |
| 176 | + {busy ? 'reasoning…' : 'reason'} |
| 177 | + </button> |
| 178 | + <datalist id="cpic-genes">{catalog.genes.map((g) => <option key={g} value={g} />)}</datalist> |
| 179 | + <datalist id="cpic-drugs">{catalog.drugs.map((d) => <option key={d} value={d} />)}</datalist> |
| 180 | + </div> |
| 181 | + |
| 182 | + {/* example chips */} |
| 183 | + <div style={{ display: 'flex', gap: 7, flexWrap: 'wrap', marginTop: 12 }}> |
| 184 | + <span style={{ opacity: 0.45, fontSize: 11, alignSelf: 'center' }}>examples:</span> |
| 185 | + {EXAMPLES.map((ex) => ( |
| 186 | + <button key={ex.label} style={chip} disabled={busy} onClick={() => pickExample(ex.sc)}>{ex.label}</button> |
| 187 | + ))} |
| 188 | + </div> |
| 189 | + |
| 190 | + {error && ( |
| 191 | + <div style={{ marginTop: 18, padding: 12, borderRadius: 8, border: '1px solid #ff637d44', background: '#160e12', color: '#ff8095' }}> |
| 192 | + {error} |
| 193 | + </div> |
| 194 | + )} |
| 195 | + |
| 196 | + {outcome && ( |
| 197 | + <div style={{ marginTop: 20, background: '#0e1219', border: '1px solid #1c2530', borderRadius: 10, padding: 16 }}> |
| 198 | + <div style={{ color: '#fff', fontSize: 15, marginBottom: 2 }}> |
| 199 | + {outcome.gene} {outcome.input} <span style={{ opacity: 0.5 }}>+</span> {outcome.drug} |
| 200 | + </div> |
| 201 | + {outcome.how && <div style={{ opacity: 0.55, fontSize: 11, marginBottom: 12 }}>resolved via {outcome.how}</div>} |
| 202 | + |
| 203 | + {/* the reasoned chain: diplotype → phenotype → recommendation, each with its GUID */} |
| 204 | + {outcome.chain.length > 0 && ( |
| 205 | + <div style={{ display: 'flex', flexDirection: 'column', gap: 0, marginBottom: 14 }}> |
| 206 | + {outcome.chain.map((n, i) => ( |
| 207 | + <div key={`${n.role}-${i}`}> |
| 208 | + <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '6px 0' }}> |
| 209 | + <span style={{ ...badge('#11161f', ROLE_COLOR[n.role] ?? '#9fb1c4'), minWidth: 96, textAlign: 'center' }}>{n.role}</span> |
| 210 | + <span style={{ color: '#dbe6f0', flex: '1 1 auto' }}>{n.label}</span> |
| 211 | + <span style={{ opacity: 0.5, fontSize: 11 }}>{n.guid}</span> |
| 212 | + </div> |
| 213 | + {i < outcome.chain.length - 1 && ( |
| 214 | + <div style={{ marginLeft: 44, color: '#33414f', fontSize: 13, lineHeight: '8px' }}>↓</div> |
| 215 | + )} |
| 216 | + </div> |
| 217 | + ))} |
| 218 | + </div> |
| 219 | + )} |
| 220 | + |
| 221 | + {outcome.resolved ? ( |
| 222 | + <> |
| 223 | + <div style={{ marginBottom: 10 }}> |
| 224 | + {outcome.classification && ( |
| 225 | + <span style={badge('#16202e', '#cdd9e5')}>class: {outcome.classification}</span> |
| 226 | + )} |
| 227 | + {outcome.cpic_level && ( |
| 228 | + <span style={badge('#11161f', levelColor(outcome.cpic_level))}>CPIC level {outcome.cpic_level}</span> |
| 229 | + )} |
| 230 | + <span style={badge('#11161f', '#6db3ff')}> |
| 231 | + f={outcome.truth_f.toFixed(3)} c={outcome.truth_c.toFixed(3)} · exp {outcome.truth_exp.toFixed(3)} |
| 232 | + </span> |
| 233 | + </div> |
| 234 | + {outcome.recommendation && ( |
| 235 | + <div style={{ marginTop: 4, padding: '10px 12px', borderRadius: 8, background: '#101a14', border: '1px solid #1d3326', color: '#bfe9cf' }}> |
| 236 | + <span style={{ opacity: 0.6, fontSize: 11 }}>CPIC recommendation</span> |
| 237 | + <div style={{ marginTop: 4 }}>{outcome.recommendation}</div> |
| 238 | + </div> |
| 239 | + )} |
| 240 | + </> |
| 241 | + ) : ( |
| 242 | + <div style={{ padding: '10px 12px', borderRadius: 8, background: '#1a1410', border: '1px solid #3a2c1c', color: '#ffce96' }}> |
| 243 | + no simple phenotype → recommendation — surfaced, not fabricated. |
| 244 | + </div> |
| 245 | + )} |
| 246 | + |
| 247 | + {/* flags: complexity / multi-gene / unknown-drug warnings CPIC itself raises */} |
| 248 | + {outcome.flags.length > 0 && ( |
| 249 | + <div style={{ marginTop: 12 }}> |
| 250 | + {outcome.flags.map((f, i) => ( |
| 251 | + <div key={i} style={{ color: '#ffb86b', fontSize: 12, marginBottom: 4 }}>⚠ {f}</div> |
| 252 | + ))} |
| 253 | + </div> |
| 254 | + )} |
| 255 | + |
| 256 | + <div style={{ opacity: 0.4, fontSize: 10, marginTop: 14, borderTop: '1px solid #1c2530', paddingTop: 8 }}> |
| 257 | + {outcome.disclaimer} |
| 258 | + </div> |
| 259 | + </div> |
| 260 | + )} |
| 261 | + |
| 262 | + <div style={{ display: 'flex', gap: 16, marginTop: 22, opacity: 0.7 }}> |
| 263 | + <a href="/fma-body" style={{ color: '#7fa6c4', textDecoration: 'none' }}>/fma-body →</a> |
| 264 | + <a href="/fma" style={{ color: '#7fa6c4', textDecoration: 'none' }}>/fma graph →</a> |
| 265 | + <a href="/" style={{ color: '#7fa6c4', textDecoration: 'none' }}>/ cockpit →</a> |
| 266 | + </div> |
| 267 | + </div> |
| 268 | + </div> |
| 269 | + ); |
| 270 | +} |
0 commit comments