Spaces:
Sleeping
Sleeping
Initial deployment: ClinicalMatch AI v2.0 — FHIR R4 · MCP (9 tools) · A2A workflow · SHARP compliance · 100k synthetic patients · Neo4j graph · GraphRAG chatbot
59abb4f | "use client"; | |
| import { useState, useEffect, useRef } from "react"; | |
| import { startWorkflow, streamWorkflow, screenPatient, getGraphPatients } from "@/lib/api"; | |
| import { ClipboardCheck, CheckCircle, XCircle, AlertCircle, Loader2, ChevronRight, GitBranch } from "lucide-react"; | |
| import { clsx } from "clsx"; | |
| const FALLBACK_PATIENTS = ["P001", "P002", "P003", "P004", "P005"]; | |
| const QUICK_TRIALS = [ | |
| { id: "NCT04889131", label: "NCT04889131 – HER2+ Breast Cancer" }, | |
| { id: "NCT05123456", label: "NCT05123456 – Immunotherapy Combo" }, | |
| { id: "NCT05456789", label: "NCT05456789 – BRCA2 Prostate" }, | |
| { id: "NCT06112233", label: "NCT06112233 – EGFR NSCLC" }, | |
| { id: "NCT05334455", label: "NCT05334455 – MSI-H Colorectal" }, | |
| ]; | |
| const WORKFLOW_STATES = [ | |
| "PENDING", "INGESTING", "PARSING_PROTOCOL", "MATCHING", "SCORING", "RECRUITING", "COMPLETED", | |
| ]; | |
| function StateTracker({ events }: { events: any[] }) { | |
| const stateSet = new Set(events.map((e: any) => e.state)); | |
| return ( | |
| <div className="flex items-center gap-1 flex-wrap mb-6"> | |
| {WORKFLOW_STATES.map((state, i) => { | |
| const done = stateSet.has(state); | |
| const isCurrent = events[events.length - 1]?.state === state; | |
| return ( | |
| <div key={state} className="flex items-center gap-1"> | |
| <span className={clsx( | |
| "text-xs px-2.5 py-1 rounded-full font-medium", | |
| isCurrent ? "bg-indigo-600 text-white" : | |
| done ? "bg-indigo-100 text-indigo-700" : | |
| "bg-slate-100 text-slate-400" | |
| )}> | |
| {state} | |
| </span> | |
| {i < WORKFLOW_STATES.length - 1 && <ChevronRight className="w-3 h-3 text-slate-300" />} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| function ScoreBar({ score }: { score: number }) { | |
| const pct = Math.round(score * 100); | |
| const color = pct >= 80 ? "bg-emerald-500" : pct >= 60 ? "bg-amber-500" : "bg-red-500"; | |
| return ( | |
| <div className="flex items-center gap-3"> | |
| <div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden"> | |
| <div className={clsx("h-full rounded-full transition-all", color)} style={{ width: `${pct}%` }} /> | |
| </div> | |
| <span className={clsx("text-sm font-bold", pct >= 80 ? "text-emerald-600" : pct >= 60 ? "text-amber-600" : "text-red-600")}> | |
| {pct}% | |
| </span> | |
| </div> | |
| ); | |
| } | |
| function CriterionRow({ criterion, met, triggered, confidence, note }: any) { | |
| const pass = met === true || triggered === false; | |
| const fail = met === false || triggered === true; | |
| return ( | |
| <div className={clsx("flex items-start gap-3 px-4 py-2.5 rounded-lg", pass ? "bg-emerald-50" : fail ? "bg-red-50" : "bg-amber-50")}> | |
| {pass ? <CheckCircle className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" /> | |
| : fail ? <XCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" /> | |
| : <AlertCircle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />} | |
| <div className="flex-1 min-w-0"> | |
| <div className="text-sm text-slate-700">{criterion}</div> | |
| {note && <div className="text-xs text-slate-500 mt-0.5">{note}</div>} | |
| </div> | |
| {confidence && ( | |
| <span className="text-xs text-slate-400 shrink-0 mt-0.5">{confidence}</span> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function MatchPath({ path }: { path: any[] }) { | |
| if (!path?.length) return null; | |
| return ( | |
| <div className="mt-3 p-3 bg-slate-50 rounded-lg border border-slate-200"> | |
| <div className="flex items-center gap-1.5 mb-2"> | |
| <GitBranch className="w-3.5 h-3.5 text-indigo-500" /> | |
| <span className="text-xs font-semibold text-slate-600">Graph Match Path</span> | |
| </div> | |
| <div className="flex flex-wrap items-center gap-1 text-xs"> | |
| {path.map((node: any, i: number) => ( | |
| <span key={i} className="flex items-center gap-1"> | |
| <span className="bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-mono"> | |
| {node.from?.split(":")[1] ?? node.from} | |
| </span> | |
| <span className="text-slate-400">—[{node.rel}]→</span> | |
| <span className="bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded font-mono"> | |
| {node.to?.split(":")[1] ?? node.to} | |
| </span> | |
| {node.note && <span className="text-slate-400 italic">({node.note})</span>} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default function ScreeningPage() { | |
| const [patientId, setPatientId] = useState("P001"); | |
| const [nctId, setNctId] = useState(QUICK_TRIALS[0].id); | |
| const [customNct, setCustomNct] = useState(""); | |
| const [useCustom, setUseCustom] = useState(false); | |
| const [loading, setLoading] = useState(false); | |
| const [mode, setMode] = useState<"screen" | "workflow">("screen"); | |
| const [result, setResult] = useState<any>(null); | |
| const [workflowResult, setWorkflowResult] = useState<any>(null); | |
| const [streamEvents, setStreamEvents] = useState<any[]>([]); | |
| const [streamDone, setStreamDone] = useState(false); | |
| const [error, setError] = useState(""); | |
| const [graphPatients, setGraphPatients] = useState<any[]>([]); | |
| const cleanupRef = useRef<(() => void) | null>(null); | |
| useEffect(() => { | |
| getGraphPatients(undefined, 500).then((d) => setGraphPatients(d.patients)).catch(() => {}); | |
| return () => { cleanupRef.current?.(); }; | |
| }, []); | |
| const effectiveNct = useCustom ? customNct : nctId; | |
| const handleScreen = async () => { | |
| if (!patientId.trim()) { setError("Patient ID is required"); return; } | |
| if (!effectiveNct.trim()) { setError("NCT ID is required"); return; } | |
| setLoading(true); | |
| setError(""); | |
| setResult(null); | |
| try { | |
| const data = await screenPatient(patientId.trim(), effectiveNct.trim()); | |
| setResult(data); | |
| } catch (e: any) { | |
| setError(e.message); | |
| } | |
| setLoading(false); | |
| }; | |
| const handleWorkflow = async () => { | |
| if (!patientId.trim()) { setError("Patient ID is required"); return; } | |
| cleanupRef.current?.(); | |
| setLoading(true); | |
| setError(""); | |
| setWorkflowResult(null); | |
| setStreamEvents([]); | |
| setStreamDone(false); | |
| try { | |
| const { workflow_id } = await startWorkflow(patientId.trim()); | |
| const stop = streamWorkflow( | |
| workflow_id, | |
| (evt) => setStreamEvents((prev) => [...prev, evt]), | |
| () => { setStreamDone(true); setLoading(false); }, | |
| ); | |
| cleanupRef.current = stop; | |
| } catch (e: any) { | |
| setError(e.message); | |
| setLoading(false); | |
| } | |
| }; | |
| return ( | |
| <div className="p-6 max-w-4xl mx-auto"> | |
| <div className="mb-6"> | |
| <h1 className="text-2xl font-bold text-slate-900 mb-1">Patient Screening</h1> | |
| <p className="text-slate-500 text-sm">AI-powered eligibility assessment using FHIR R4 patient data and A2A orchestration</p> | |
| </div> | |
| {/* Mode tabs */} | |
| <div className="flex gap-2 mb-6"> | |
| {(["screen", "workflow"] as const).map((m) => ( | |
| <button | |
| key={m} | |
| onClick={() => setMode(m)} | |
| className={clsx("px-4 py-2 rounded-lg text-sm font-medium transition-colors", | |
| mode === m ? "bg-indigo-600 text-white" : "bg-white border border-slate-200 text-slate-600 hover:bg-slate-50" | |
| )} | |
| > | |
| {m === "screen" ? "Single Trial Screen" : "A2A Full Pipeline"} | |
| </button> | |
| ))} | |
| </div> | |
| <div className="bg-white rounded-xl border border-slate-200 p-5 mb-6"> | |
| <div className="grid grid-cols-2 gap-4 mb-4"> | |
| <div> | |
| <label className="block text-xs font-semibold text-slate-600 mb-1.5"> | |
| Patient ID | |
| {graphPatients.length > 0 && <span className="ml-1 text-slate-400 font-normal">({graphPatients.length} loaded)</span>} | |
| </label> | |
| <input | |
| list="patient-list" | |
| value={patientId} | |
| onChange={(e) => setPatientId(e.target.value)} | |
| placeholder="e.g. P_C50_0001 or P001" | |
| className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" | |
| /> | |
| <datalist id="patient-list"> | |
| {(graphPatients.length > 0 ? graphPatients : FALLBACK_PATIENTS.map((id) => ({ id }))).map((p: any) => ( | |
| <option key={p.id} value={p.id}> | |
| {p.name ? `${p.id} — ${p.name}${p.condition ? ` (${p.condition})` : ""}` : p.id} | |
| </option> | |
| ))} | |
| </datalist> | |
| </div> | |
| {mode === "screen" && ( | |
| <div> | |
| <label className="block text-xs font-semibold text-slate-600 mb-1.5">NCT ID</label> | |
| <input | |
| list="trial-list" | |
| value={useCustom ? customNct : nctId} | |
| onChange={(e) => { | |
| const val = e.target.value; | |
| const quick = QUICK_TRIALS.find((t) => t.id === val); | |
| if (quick) { setUseCustom(false); setNctId(val); } | |
| else { setUseCustom(true); setCustomNct(val); } | |
| }} | |
| placeholder="e.g. NCT04889131" | |
| className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" | |
| /> | |
| <datalist id="trial-list"> | |
| {QUICK_TRIALS.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)} | |
| </datalist> | |
| </div> | |
| )} | |
| </div> | |
| <button | |
| onClick={mode === "screen" ? handleScreen : handleWorkflow} | |
| disabled={loading} | |
| className="flex items-center gap-2 bg-indigo-600 text-white px-5 py-2.5 rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 transition-colors" | |
| > | |
| {loading ? <><Loader2 className="w-4 h-4 animate-spin" />Running...</> : <><ClipboardCheck className="w-4 h-4" />{mode === "screen" ? "Screen Patient" : "Run A2A Pipeline"}</>} | |
| </button> | |
| </div> | |
| {error && ( | |
| <div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm mb-4">{error}</div> | |
| )} | |
| {/* Single screen result */} | |
| {result && ( | |
| <div className="space-y-4"> | |
| <div className="bg-white rounded-xl border border-slate-200 p-5"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h2 className="font-semibold text-slate-900">Eligibility Score</h2> | |
| <span className={clsx("text-sm font-bold px-3 py-1 rounded-full", | |
| result.eligible ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-700" | |
| )}> | |
| {result.eligible ? "ELIGIBLE" : "NOT ELIGIBLE"} | |
| </span> | |
| </div> | |
| <ScoreBar score={result.overall_score || 0} /> | |
| {result.summary && ( | |
| <p className="text-sm text-slate-600 mt-3 leading-relaxed">{result.summary}</p> | |
| )} | |
| {result.risk_flags?.length > 0 && ( | |
| <div className="mt-3 flex flex-wrap gap-2"> | |
| {result.risk_flags.map((f: string, i: number) => ( | |
| <span key={i} className="text-xs bg-amber-50 border border-amber-200 text-amber-700 px-2 py-0.5 rounded">{f}</span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {result.inclusion_results?.length > 0 && ( | |
| <div className="bg-white rounded-xl border border-slate-200 p-5"> | |
| <h3 className="font-semibold text-slate-900 text-sm mb-3">Inclusion Criteria</h3> | |
| <div className="space-y-2"> | |
| {result.inclusion_results.map((c: any, i: number) => <CriterionRow key={i} {...c} />)} | |
| </div> | |
| </div> | |
| )} | |
| {result.exclusion_results?.length > 0 && ( | |
| <div className="bg-white rounded-xl border border-slate-200 p-5"> | |
| <h3 className="font-semibold text-slate-900 text-sm mb-3">Exclusion Criteria</h3> | |
| <div className="space-y-2"> | |
| {result.exclusion_results.map((c: any, i: number) => <CriterionRow key={i} {...c} />)} | |
| </div> | |
| </div> | |
| )} | |
| {result.match_path?.length > 0 && ( | |
| <div className="bg-white rounded-xl border border-slate-200 p-5"> | |
| <h3 className="font-semibold text-slate-900 text-sm mb-2">Graph Explainability</h3> | |
| <MatchPath path={result.match_path} /> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Streaming A2A workflow */} | |
| {(streamEvents.length > 0 || loading) && mode === "workflow" && ( | |
| <div className="space-y-4"> | |
| <div className="bg-white rounded-xl border border-slate-200 p-5"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h2 className="font-semibold text-slate-900">A2A Pipeline — Live</h2> | |
| {!streamDone && <Loader2 className="w-4 h-4 animate-spin text-indigo-500" />} | |
| {streamDone && <span className="text-xs text-emerald-600 font-medium">Complete</span>} | |
| </div> | |
| <StateTracker events={streamEvents} /> | |
| <div className="space-y-1.5 mt-2 max-h-48 overflow-y-auto"> | |
| {streamEvents.map((evt: any, i: number) => ( | |
| <div key={i} className="flex gap-3 text-xs items-start"> | |
| <span className="text-indigo-600 font-mono shrink-0 pt-0.5">{evt.state}</span> | |
| <span className="text-slate-600">{evt.message}</span> | |
| {evt.data?.eligible_count !== undefined && ( | |
| <span className="ml-auto text-emerald-600 font-medium shrink-0"> | |
| {evt.data.eligible_count} eligible | |
| </span> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Final summary when complete */} | |
| {streamDone && (() => { | |
| const final = streamEvents[streamEvents.length - 1]; | |
| if (!final || !["COMPLETED", "FAILED"].includes(final.state)) return null; | |
| return ( | |
| <div className={clsx("bg-white rounded-xl border p-5", | |
| final.state === "COMPLETED" ? "border-emerald-200" : "border-red-200" | |
| )}> | |
| <h3 className="font-semibold text-slate-900 text-sm mb-2"> | |
| {final.state === "COMPLETED" ? "Pipeline Complete" : "Pipeline Failed"} | |
| </h3> | |
| {final.state === "COMPLETED" && ( | |
| <div className="grid grid-cols-3 gap-4 text-center"> | |
| <div className="bg-emerald-50 rounded-lg p-3"> | |
| <div className="text-2xl font-bold text-emerald-600">{final.eligible_trials ?? 0}</div> | |
| <div className="text-xs text-slate-500 mt-0.5">Eligible Trials</div> | |
| </div> | |
| <div className="bg-indigo-50 rounded-lg p-3"> | |
| <div className="text-2xl font-bold text-indigo-600">{final.total_evaluated ?? 0}</div> | |
| <div className="text-xs text-slate-500 mt-0.5">Evaluated</div> | |
| </div> | |
| <div className="bg-amber-50 rounded-lg p-3"> | |
| <div className="text-2xl font-bold text-amber-600">{final.recruitment_records ?? 0}</div> | |
| <div className="text-xs text-slate-500 mt-0.5">Outreach Generated</div> | |
| </div> | |
| </div> | |
| )} | |
| {final.error && ( | |
| <p className="text-sm text-red-600 mt-2">{final.error}</p> | |
| )} | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |