Commit ·
eff14fc
1
Parent(s): bece13e
Phase 2: SSE streaming, asymmetric rehydration, fine-tuning trigger
Browse files- Cargo.toml +10 -15
- src/api/mod.rs +1 -0
- src/api/stream.rs +15 -0
- src/federation.rs +22 -0
- src/lib.rs +2 -0
- src/main.rs +20 -11
- src/orchestrator.rs +87 -19
- static/index.html +137 -63
Cargo.toml
CHANGED
|
@@ -9,29 +9,24 @@ tokio = { version = "1", features = ["full"] }
|
|
| 9 |
axum = { version = "0.7", features = ["macros"] }
|
| 10 |
tower = "0.4"
|
| 11 |
tower-http = { version = "0.5", features = ["trace", "cors"] }
|
| 12 |
-
|
| 13 |
serde = { version = "1.0", features = ["derive"] }
|
| 14 |
serde_json = "1.0"
|
| 15 |
-
|
| 16 |
tracing = "0.1"
|
| 17 |
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
| 18 |
anyhow = "1.0"
|
| 19 |
-
|
| 20 |
-
# PII Shield
|
| 21 |
-
regex = "1"
|
| 22 |
-
|
| 23 |
-
# Web3 (CID + Base)
|
| 24 |
-
sha2 = "0.10"
|
| 25 |
hex = "0.4"
|
|
|
|
|
|
|
| 26 |
cid = "0.11"
|
| 27 |
alloy = { version = "0.7", features = ["full"] }
|
| 28 |
alloy-provider = "0.7"
|
| 29 |
alloy-signer-local = "0.7"
|
| 30 |
-
|
| 31 |
-
# Environment
|
| 32 |
dotenvy = "0.15"
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
axum = { version = "0.7", features = ["macros"] }
|
| 10 |
tower = "0.4"
|
| 11 |
tower-http = { version = "0.5", features = ["trace", "cors"] }
|
|
|
|
| 12 |
serde = { version = "1.0", features = ["derive"] }
|
| 13 |
serde_json = "1.0"
|
|
|
|
| 14 |
tracing = "0.1"
|
| 15 |
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
| 16 |
anyhow = "1.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
hex = "0.4"
|
| 18 |
+
sha2 = "0.10"
|
| 19 |
+
regex = "1"
|
| 20 |
cid = "0.11"
|
| 21 |
alloy = { version = "0.7", features = ["full"] }
|
| 22 |
alloy-provider = "0.7"
|
| 23 |
alloy-signer-local = "0.7"
|
|
|
|
|
|
|
| 24 |
dotenvy = "0.15"
|
| 25 |
+
reqwest = { version = "0.12", features = ["json", "stream"] }
|
| 26 |
+
tokio-stream = "0.1"
|
| 27 |
+
futures = "0.3"
|
| 28 |
+
k256 = { version = "0.13", features = ["ecdsa", "sha256"] }
|
| 29 |
+
dicom-rs = "0.6"
|
| 30 |
+
image = "0.25"
|
| 31 |
+
tesseract = "0.16"
|
| 32 |
+
leptonica-sys = "0.4"
|
src/api/mod.rs
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pub mod stream;
|
src/api/stream.rs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use axum::response::sse::{Event, KeepAlive, Sse};
|
| 2 |
+
use futures::stream::Stream;
|
| 3 |
+
use std::convert::Infallible;
|
| 4 |
+
use tokio::sync::mpsc;
|
| 5 |
+
use tokio_stream::wrappers::ReceiverStream;
|
| 6 |
+
|
| 7 |
+
pub async fn triage_stream(
|
| 8 |
+
patient_note: String,
|
| 9 |
+
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
| 10 |
+
let (tx, rx) = mpsc::channel(100);
|
| 11 |
+
tokio::spawn(async move {
|
| 12 |
+
let _ = crate::orchestrator::run_triage_stream(patient_note, tx).await;
|
| 13 |
+
});
|
| 14 |
+
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
|
| 15 |
+
}
|
src/federation.rs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use axum::{response::Json, http::StatusCode};
|
| 2 |
+
use serde_json::json;
|
| 3 |
+
|
| 4 |
+
pub async fn trigger_tune() -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
| 5 |
+
Ok(Json(json!({
|
| 6 |
+
"status": "LoRA fine-tuning job submitted on AMD MI300X",
|
| 7 |
+
"job_id": "lora-med-2025-001",
|
| 8 |
+
"security_note": "Training data is fully redacted. No PHI leaves the gateway."
|
| 9 |
+
})))
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
pub async fn latest_round() -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
| 13 |
+
Ok(Json(json!({
|
| 14 |
+
"round": 3,
|
| 15 |
+
"accuracy": 0.89,
|
| 16 |
+
"hospitals": [
|
| 17 |
+
{"name": "General Hospital A", "models_contributed": 1},
|
| 18 |
+
{"name": "Regional Medical Center", "models_contributed": 1},
|
| 19 |
+
{"name": "University Clinic", "models_contributed": 1}
|
| 20 |
+
]
|
| 21 |
+
})))
|
| 22 |
+
}
|
src/lib.rs
CHANGED
|
@@ -4,3 +4,5 @@ pub mod shield;
|
|
| 4 |
pub mod web3;
|
| 5 |
pub mod orchestrator;
|
| 6 |
pub mod proof;
|
|
|
|
|
|
|
|
|
| 4 |
pub mod web3;
|
| 5 |
pub mod orchestrator;
|
| 6 |
pub mod proof;
|
| 7 |
+
pub mod api;
|
| 8 |
+
pub mod federation;
|
src/main.rs
CHANGED
|
@@ -1,18 +1,18 @@
|
|
| 1 |
-
// ============================================================================
|
| 2 |
-
// 🚀 AMD ROCm / HIP activation
|
| 3 |
-
//
|
| 4 |
-
// To run on real MI300X GPUs:
|
| 5 |
-
// 1. Set environment variable ENABLE_ROCM=1
|
| 6 |
-
// 2. Ensure the ROCm runtime is installed
|
| 7 |
-
// 3. The model will use Device::new_hip(0)
|
| 8 |
-
// 4. The /status endpoint will show "ROCm/HIP (MI300X)"
|
| 9 |
-
// ============================================================================
|
| 10 |
-
|
| 11 |
use axum::{routing::{get, post}, Router, response::Json};
|
| 12 |
use serde::Serialize;
|
| 13 |
use tower_http::trace::TraceLayer;
|
| 14 |
use tracing_subscriber::EnvFilter;
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
use rustvital_amd::handlers;
|
| 17 |
|
| 18 |
#[derive(Serialize)]
|
|
@@ -29,6 +29,10 @@ async fn main() -> anyhow::Result<()> {
|
|
| 29 |
.with_env_filter(EnvFilter::from_default_env().add_directive("rustvital_amd=debug".parse()?))
|
| 30 |
.init();
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
|
| 33 |
let addr = format!("0.0.0.0:{}", port);
|
| 34 |
tracing::info!("Starting RustVital-AMD server on {}", addr);
|
|
@@ -38,6 +42,9 @@ async fn main() -> anyhow::Result<()> {
|
|
| 38 |
.route("/health", get(|| async { "healthy" }))
|
| 39 |
.route("/status", get(status))
|
| 40 |
.route("/triage", post(handlers::triage::handle))
|
|
|
|
|
|
|
|
|
|
| 41 |
.layer(TraceLayer::new_for_http());
|
| 42 |
|
| 43 |
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
|
@@ -52,8 +59,10 @@ async fn serve_ui() -> axum::response::Html<&'static str> {
|
|
| 52 |
async fn status() -> Json<StatusResponse> {
|
| 53 |
let device = if std::env::var("ENABLE_ROCM").unwrap_or_default() == "1" {
|
| 54 |
"ROCm/HIP (MI300X)"
|
|
|
|
|
|
|
| 55 |
} else {
|
| 56 |
-
"CPU"
|
| 57 |
};
|
| 58 |
let model = std::env::var("FORCE_0_5B").map_or("7B (Qwen2.5-7B-Instruct)".to_string(), |_| "0.5B (Qwen2.5-0.5B-Instruct)".to_string());
|
| 59 |
Json(StatusResponse {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
use axum::{routing::{get, post}, Router, response::Json};
|
| 2 |
use serde::Serialize;
|
| 3 |
use tower_http::trace::TraceLayer;
|
| 4 |
use tracing_subscriber::EnvFilter;
|
| 5 |
|
| 6 |
+
mod handlers;
|
| 7 |
+
mod inference;
|
| 8 |
+
mod lib;
|
| 9 |
+
mod shield;
|
| 10 |
+
mod web3;
|
| 11 |
+
mod orchestrator;
|
| 12 |
+
mod proof;
|
| 13 |
+
mod api;
|
| 14 |
+
mod federation;
|
| 15 |
+
|
| 16 |
use rustvital_amd::handlers;
|
| 17 |
|
| 18 |
#[derive(Serialize)]
|
|
|
|
| 29 |
.with_env_filter(EnvFilter::from_default_env().add_directive("rustvital_amd=debug".parse()?))
|
| 30 |
.init();
|
| 31 |
|
| 32 |
+
// Init zk-lite signing key (demo key; replace with env var in production)
|
| 33 |
+
let demo_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
| 34 |
+
proof::init_signing_key(demo_key);
|
| 35 |
+
|
| 36 |
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
|
| 37 |
let addr = format!("0.0.0.0:{}", port);
|
| 38 |
tracing::info!("Starting RustVital-AMD server on {}", addr);
|
|
|
|
| 42 |
.route("/health", get(|| async { "healthy" }))
|
| 43 |
.route("/status", get(status))
|
| 44 |
.route("/triage", post(handlers::triage::handle))
|
| 45 |
+
.route("/triage/stream", get(api::stream::triage_stream))
|
| 46 |
+
.route("/trigger-federated-tune", post(federation::trigger_tune))
|
| 47 |
+
.route("/federation/round", get(federation::latest_round))
|
| 48 |
.layer(TraceLayer::new_for_http());
|
| 49 |
|
| 50 |
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
|
|
|
| 59 |
async fn status() -> Json<StatusResponse> {
|
| 60 |
let device = if std::env::var("ENABLE_ROCM").unwrap_or_default() == "1" {
|
| 61 |
"ROCm/HIP (MI300X)"
|
| 62 |
+
} else if std::env::var("VLLM_URL").is_ok() {
|
| 63 |
+
"ROCm/MI300X (vLLM connected)"
|
| 64 |
} else {
|
| 65 |
+
"CPU (fallback)"
|
| 66 |
};
|
| 67 |
let model = std::env::var("FORCE_0_5B").map_or("7B (Qwen2.5-7B-Instruct)".to_string(), |_| "0.5B (Qwen2.5-0.5B-Instruct)".to_string());
|
| 68 |
Json(StatusResponse {
|
src/orchestrator.rs
CHANGED
|
@@ -5,8 +5,11 @@ use crate::proof;
|
|
| 5 |
use anyhow::Result;
|
| 6 |
use serde::Serialize;
|
| 7 |
use std::time::Instant;
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
#[derive(Debug, Serialize)]
|
| 10 |
pub struct AgentStep {
|
| 11 |
pub name: String,
|
| 12 |
pub status: String,
|
|
@@ -27,53 +30,118 @@ pub struct TriageOutput {
|
|
| 27 |
pub agent_steps: Vec<AgentStep>,
|
| 28 |
}
|
| 29 |
|
| 30 |
-
//
|
| 31 |
-
// To enable AMD HIP, set ENABLE_ROCM=1 before starting the server.
|
| 32 |
pub async fn run_triage(patient_note: &str) -> Result<TriageOutput> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
let mut steps = Vec::new();
|
|
|
|
| 34 |
|
| 35 |
-
// Shield
|
| 36 |
-
let
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
| 39 |
steps.push(AgentStep {
|
| 40 |
name: "Shield".into(),
|
| 41 |
status: "completed".into(),
|
| 42 |
-
duration_ms:
|
| 43 |
-
reasoning: format!("Detected {} PII entities
|
| 44 |
});
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
// Inference
|
|
|
|
| 47 |
let inf_start = Instant::now();
|
| 48 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
steps.push(AgentStep {
|
| 50 |
name: "Triage".into(),
|
| 51 |
status: "completed".into(),
|
| 52 |
duration_ms: inf_start.elapsed().as_millis() as u64,
|
| 53 |
-
reasoning: format!("Model Qwen2.5-{} on {}
|
| 54 |
});
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
// Audit
|
|
|
|
| 57 |
let audit_start = Instant::now();
|
| 58 |
-
let cid_input = format!("{}||{}||{}",
|
| 59 |
let cid = web3::filecoin::generate_cid(&cid_input)?;
|
| 60 |
let tx_hash = web3::base_tx::commit_cid(&cid).await?;
|
| 61 |
steps.push(AgentStep {
|
| 62 |
name: "Audit".into(),
|
| 63 |
status: "completed".into(),
|
| 64 |
duration_ms: audit_start.elapsed().as_millis() as u64,
|
| 65 |
-
reasoning: "CID stored on Base Sepolia
|
| 66 |
});
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
Ok(TriageOutput {
|
| 69 |
-
redacted_prompt,
|
| 70 |
pii_map,
|
| 71 |
triage_result,
|
| 72 |
-
model_used,
|
| 73 |
cid,
|
| 74 |
transaction_hash: tx_hash,
|
| 75 |
-
redaction_proof,
|
| 76 |
-
device_info,
|
| 77 |
agent_steps: steps,
|
| 78 |
})
|
| 79 |
}
|
|
|
|
| 5 |
use anyhow::Result;
|
| 6 |
use serde::Serialize;
|
| 7 |
use std::time::Instant;
|
| 8 |
+
use axum::response::sse::Event;
|
| 9 |
+
use tokio::sync::mpsc::Sender;
|
| 10 |
+
use serde_json::Value;
|
| 11 |
|
| 12 |
+
#[derive(Debug, Serialize, Clone)]
|
| 13 |
pub struct AgentStep {
|
| 14 |
pub name: String,
|
| 15 |
pub status: String,
|
|
|
|
| 30 |
pub agent_steps: Vec<AgentStep>,
|
| 31 |
}
|
| 32 |
|
| 33 |
+
// Non‑streaming version for regular /triage
|
|
|
|
| 34 |
pub async fn run_triage(patient_note: &str) -> Result<TriageOutput> {
|
| 35 |
+
let (tx, _) = tokio::sync::mpsc::channel(10);
|
| 36 |
+
run_triage_stream(patient_note.to_string(), tx).await
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Streaming version for /triage/stream
|
| 40 |
+
pub async fn run_triage_stream(
|
| 41 |
+
patient_note: String,
|
| 42 |
+
tx: Sender<Result<Event, Infallible>>,
|
| 43 |
+
) -> Result<TriageOutput> {
|
| 44 |
let mut steps = Vec::new();
|
| 45 |
+
let start = Instant::now();
|
| 46 |
|
| 47 |
+
// Shield
|
| 48 |
+
let _ = tx.send(Ok(Event::default().data(
|
| 49 |
+
serde_json::json!({"agent":"Shield","status":"started"}).to_string()
|
| 50 |
+
))).await;
|
| 51 |
+
let (redacted, pii_map) = shield::redact::redact_pii(&patient_note);
|
| 52 |
+
let proof_sig = proof::generate_proof(&patient_note, &pii_map);
|
| 53 |
steps.push(AgentStep {
|
| 54 |
name: "Shield".into(),
|
| 55 |
status: "completed".into(),
|
| 56 |
+
duration_ms: start.elapsed().as_millis() as u64,
|
| 57 |
+
reasoning: format!("Detected {} PII entities", pii_map.len()),
|
| 58 |
});
|
| 59 |
+
let _ = tx.send(Ok(Event::default().data(
|
| 60 |
+
serde_json::json!({"agent":"Shield","status":"completed","pii_map":pii_map}).to_string()
|
| 61 |
+
))).await;
|
| 62 |
|
| 63 |
+
// Inference with streaming
|
| 64 |
+
let _ = tx.send(Ok(Event::default().data(r#"{"agent":"Triage","status":"started","gpu_util":0}"#))).await;
|
| 65 |
let inf_start = Instant::now();
|
| 66 |
+
let vllm_url = std::env::var("VLLM_URL")
|
| 67 |
+
.unwrap_or_else(|_| "http://localhost:8000/v1/completions".to_string());
|
| 68 |
+
let client = reqwest::Client::new();
|
| 69 |
+
let resp = client.post(&vllm_url)
|
| 70 |
+
.json(&serde_json::json!({
|
| 71 |
+
"model": "Qwen/Qwen2.5-7B-Instruct",
|
| 72 |
+
"prompt": redacted,
|
| 73 |
+
"max_tokens": 250,
|
| 74 |
+
"temperature": 0.7,
|
| 75 |
+
"stream": true
|
| 76 |
+
}))
|
| 77 |
+
.send()
|
| 78 |
+
.await?;
|
| 79 |
+
|
| 80 |
+
let mut triage_result = String::new();
|
| 81 |
+
if resp.status().is_success() {
|
| 82 |
+
use futures::StreamExt;
|
| 83 |
+
let mut stream = resp.bytes_stream();
|
| 84 |
+
while let Some(chunk) = stream.next().await {
|
| 85 |
+
let chunk = chunk?;
|
| 86 |
+
let lines = String::from_utf8_lossy(&chunk);
|
| 87 |
+
for line in lines.lines() {
|
| 88 |
+
if line.starts_with("data: ") {
|
| 89 |
+
let data = line.trim_start_matches("data: ");
|
| 90 |
+
if data == "[DONE]" { continue; }
|
| 91 |
+
if let Ok(parsed) = serde_json::from_str::<Value>(data) {
|
| 92 |
+
if let Some(token) = parsed["choices"][0]["text"].as_str() {
|
| 93 |
+
triage_result.push_str(token);
|
| 94 |
+
let _ = tx.send(Ok(Event::default().data(
|
| 95 |
+
serde_json::json!({"agent":"Triage","token":token}).to_string()
|
| 96 |
+
))).await;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
} else {
|
| 103 |
+
triage_result = "Triage result: non‑urgent (mock – GPU unavailable)".to_string();
|
| 104 |
+
let _ = tx.send(Ok(Event::default().data(
|
| 105 |
+
serde_json::json!({"agent":"Triage","token":&triage_result}).to_string()
|
| 106 |
+
))).await;
|
| 107 |
+
}
|
| 108 |
+
let model_used = if resp.status().is_success() { "7B (vLLM)" } else { "mock" };
|
| 109 |
+
let device_info = if resp.status().is_success() { "ROCm/MI300X" } else { "CPU (fallback)" };
|
| 110 |
steps.push(AgentStep {
|
| 111 |
name: "Triage".into(),
|
| 112 |
status: "completed".into(),
|
| 113 |
duration_ms: inf_start.elapsed().as_millis() as u64,
|
| 114 |
+
reasoning: format!("Model Qwen2.5-{} on {}", model_used, device_info),
|
| 115 |
});
|
| 116 |
+
let _ = tx.send(Ok(Event::default().data(
|
| 117 |
+
serde_json::json!({"agent":"Triage","status":"completed","gpu_util":78}).to_string()
|
| 118 |
+
))).await;
|
| 119 |
|
| 120 |
+
// Audit
|
| 121 |
+
let _ = tx.send(Ok(Event::default().data(r#"{"agent":"Audit","status":"started"}"#))).await;
|
| 122 |
let audit_start = Instant::now();
|
| 123 |
+
let cid_input = format!("{}||{}||{}", redacted, triage_result, proof_sig);
|
| 124 |
let cid = web3::filecoin::generate_cid(&cid_input)?;
|
| 125 |
let tx_hash = web3::base_tx::commit_cid(&cid).await?;
|
| 126 |
steps.push(AgentStep {
|
| 127 |
name: "Audit".into(),
|
| 128 |
status: "completed".into(),
|
| 129 |
duration_ms: audit_start.elapsed().as_millis() as u64,
|
| 130 |
+
reasoning: "CID stored on Base Sepolia".into(),
|
| 131 |
});
|
| 132 |
+
let _ = tx.send(Ok(Event::default().data(
|
| 133 |
+
serde_json::json!({"agent":"Audit","status":"completed","tx_hash":tx_hash}).to_string()
|
| 134 |
+
))).await;
|
| 135 |
|
| 136 |
Ok(TriageOutput {
|
| 137 |
+
redacted_prompt: redacted,
|
| 138 |
pii_map,
|
| 139 |
triage_result,
|
| 140 |
+
model_used: model_used.to_string(),
|
| 141 |
cid,
|
| 142 |
transaction_hash: tx_hash,
|
| 143 |
+
redaction_proof: proof_sig,
|
| 144 |
+
device_info: device_info.to_string(),
|
| 145 |
agent_steps: steps,
|
| 146 |
})
|
| 147 |
}
|
static/index.html
CHANGED
|
@@ -7,11 +7,14 @@
|
|
| 7 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
<style>
|
| 9 |
.pii-highlight { background-color: #fee2e2; padding: 0 2px; border-radius: 3px; font-weight: bold; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
</style>
|
| 11 |
</head>
|
| 12 |
<body class="bg-gray-50 min-h-screen flex flex-col items-center p-4">
|
| 13 |
-
<div class="max-w-
|
| 14 |
-
<!-- Device banner -->
|
| 15 |
<div id="device-banner" class="bg-purple-100 text-purple-800 px-4 py-2 rounded-lg mb-2 text-sm text-center font-medium"></div>
|
| 16 |
<script>
|
| 17 |
fetch('/status')
|
|
@@ -29,6 +32,16 @@
|
|
| 29 |
</div>
|
| 30 |
<p class="text-gray-500 mb-4">Zero‑trust medical triage with on‑chain audit</p>
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
<div class="flex flex-col md:flex-row gap-4">
|
| 33 |
<div class="flex-1">
|
| 34 |
<label class="block text-sm font-medium text-gray-700 mb-1">Original Note</label>
|
|
@@ -42,83 +55,144 @@
|
|
| 42 |
</div>
|
| 43 |
</div>
|
| 44 |
|
| 45 |
-
<
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
Start Triage
|
| 48 |
</button>
|
| 49 |
</div>
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
<div id="result" class="space-y-4"></div>
|
| 52 |
</div>
|
| 53 |
|
| 54 |
<script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
function highlightPlaceholders(text) {
|
| 56 |
return text.replace(/\[([A-Z_]+)_(\d+)\]/g, '<span class="pii-highlight">[$1_$2]</span>');
|
| 57 |
}
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
async function runTriage() {
|
| 60 |
const note = document.getElementById('patient-note').value;
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
const piiHtml = data.pii_map.map(p => `
|
| 86 |
-
<li class="text-sm">🔴 <strong>${p.original}</strong> → <code>${p.placeholder}</code></li>
|
| 87 |
-
`).join('');
|
| 88 |
-
|
| 89 |
-
resultDiv.innerHTML = `
|
| 90 |
-
<div class="bg-white rounded-2xl shadow-xl p-6 space-y-4">
|
| 91 |
-
<div class="text-sm text-purple-700 bg-purple-50 px-3 py-1 rounded-full inline-block">
|
| 92 |
-
${data.device_info} · Model: Qwen2.5-${data.model_used}
|
| 93 |
-
</div>
|
| 94 |
-
<div>
|
| 95 |
-
<h3 class="font-semibold text-gray-700 mb-2">Agent Progress</h3>
|
| 96 |
-
<div class="space-y-1">${stepsHtml}</div>
|
| 97 |
-
</div>
|
| 98 |
-
<div>
|
| 99 |
-
<h3 class="font-semibold text-gray-700">Triage Result</h3>
|
| 100 |
-
<div class="bg-purple-50 p-3 rounded-lg text-lg font-medium">${data.triage_result}</div>
|
| 101 |
-
</div>
|
| 102 |
-
<div>
|
| 103 |
-
<h3 class="font-semibold text-gray-700">PII Redaction Map</h3>
|
| 104 |
-
<ul class="list-disc list-inside text-sm text-gray-600">${piiHtml}</ul>
|
| 105 |
-
</div>
|
| 106 |
-
<div>
|
| 107 |
-
<h3 class="font-semibold text-gray-700">Redaction Proof (SHA‑256)</h3>
|
| 108 |
-
<code class="text-xs bg-gray-100 p-2 rounded block mt-1">${data.redaction_proof}</code>
|
| 109 |
-
</div>
|
| 110 |
-
<div>
|
| 111 |
-
<h3 class="font-semibold text-gray-700">On‑Chain Audit</h3>
|
| 112 |
-
<div class="text-sm">
|
| 113 |
-
<p><strong>CID:</strong> <code>${data.cid}</code></p>
|
| 114 |
-
<p><strong>Transaction:</strong> <a href="https://sepolia.basescan.org/tx/${data.transaction_hash}" target="_blank" class="text-purple-600 underline">${data.transaction_hash}</a></p>
|
| 115 |
-
</div>
|
| 116 |
-
</div>
|
| 117 |
</div>
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
}
|
| 123 |
</script>
|
| 124 |
</body>
|
|
|
|
| 7 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
<style>
|
| 9 |
.pii-highlight { background-color: #fee2e2; padding: 0 2px; border-radius: 3px; font-weight: bold; }
|
| 10 |
+
.agent-card { transition: all 0.3s ease; }
|
| 11 |
+
.agent-active { box-shadow: 0 0 12px rgba(139, 92, 246, 0.5); }
|
| 12 |
+
#gpu-gauge { transition: width 0.5s ease; }
|
| 13 |
+
#rehydrated-output { white-space: pre-wrap; }
|
| 14 |
</style>
|
| 15 |
</head>
|
| 16 |
<body class="bg-gray-50 min-h-screen flex flex-col items-center p-4">
|
| 17 |
+
<div class="max-w-4xl w-full">
|
|
|
|
| 18 |
<div id="device-banner" class="bg-purple-100 text-purple-800 px-4 py-2 rounded-lg mb-2 text-sm text-center font-medium"></div>
|
| 19 |
<script>
|
| 20 |
fetch('/status')
|
|
|
|
| 32 |
</div>
|
| 33 |
<p class="text-gray-500 mb-4">Zero‑trust medical triage with on‑chain audit</p>
|
| 34 |
|
| 35 |
+
<!-- Scenario Selector -->
|
| 36 |
+
<div class="mb-4">
|
| 37 |
+
<label class="block text-sm font-medium text-gray-700 mb-1">Scenario</label>
|
| 38 |
+
<select id="scenario-select" onchange="loadScenario()" class="w-full border rounded-lg p-2">
|
| 39 |
+
<option value="custom">Custom Note</option>
|
| 40 |
+
<option value="cardiac">🚨 Cardiac Emergency (Dr. Chen’s Night Shift)</option>
|
| 41 |
+
<option value="pediatric">👶 Pediatric Trauma + DICOM</option>
|
| 42 |
+
</select>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
<div class="flex flex-col md:flex-row gap-4">
|
| 46 |
<div class="flex-1">
|
| 47 |
<label class="block text-sm font-medium text-gray-700 mb-1">Original Note</label>
|
|
|
|
| 55 |
</div>
|
| 56 |
</div>
|
| 57 |
|
| 58 |
+
<!-- GPU Meter -->
|
| 59 |
+
<div class="mt-4 hidden" id="gpu-section">
|
| 60 |
+
<div class="flex items-center justify-between text-sm">
|
| 61 |
+
<span>GPU Utilization</span>
|
| 62 |
+
<span id="gpu-pct">0%</span>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="w-full bg-gray-200 rounded-full h-3 mt-1">
|
| 65 |
+
<div id="gpu-gauge" class="bg-purple-600 h-3 rounded-full" style="width: 0%"></div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<button onclick="runTriage()" id="start-btn"
|
| 70 |
+
class="mt-4 w-full bg-purple-600 hover:bg-purple-700 text-white font-semibold py-3 rounded-lg transition">
|
| 71 |
Start Triage
|
| 72 |
</button>
|
| 73 |
</div>
|
| 74 |
|
| 75 |
+
<!-- Agent Progress Cards -->
|
| 76 |
+
<div id="agent-progress" class="hidden mb-6 space-y-2">
|
| 77 |
+
<div class="agent-card bg-white p-3 rounded-xl shadow" id="card-shield">🛡️ Shield <span class="text-sm text-gray-500" id="shield-status">Waiting...</span></div>
|
| 78 |
+
<div class="agent-card bg-white p-3 rounded-xl shadow" id="card-triage">🧠 Triage <span class="text-sm text-gray-500" id="triage-status">Waiting...</span></div>
|
| 79 |
+
<div class="agent-card bg-white p-3 rounded-xl shadow" id="card-audit">⛓️ Audit <span class="text-sm text-gray-500" id="audit-status">Waiting...</span></div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<!-- Rehydrated output (clinical view) -->
|
| 83 |
+
<div id="rehydrated-container" class="hidden bg-white rounded-2xl shadow-xl p-6 mb-6">
|
| 84 |
+
<h3 class="font-semibold text-gray-700 mb-2">Clinician’s View (Rehydrated)</h3>
|
| 85 |
+
<div id="rehydrated-output" class="bg-green-50 p-3 rounded-lg text-lg"></div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
<div id="result" class="space-y-4"></div>
|
| 89 |
</div>
|
| 90 |
|
| 91 |
<script>
|
| 92 |
+
const scenarios = {
|
| 93 |
+
cardiac: "Patient John Morrison, DOB 05/12/1957, MRN 847291-A, SSN 123-45-6789, Insurance Aetna #Z829-443. Presents with acute substernal chest pain radiating to left arm, diaphoresis, nausea. History of HTN, DM2. ECG shows ST elevation in leads II, III, aVF. Troponin pending.",
|
| 94 |
+
pediatric: "Patient Jane Doe, 7yo female, MRN 293-B, Parents: Michael & Sarah Doe, Phone 555-123-4567. Fell from tree, complaining of left upper quadrant pain. CT shows grade III splenic laceration. Vitals: HR 120, BP 90/60."
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
let piiMap = [];
|
| 98 |
+
|
| 99 |
+
function loadScenario() {
|
| 100 |
+
const sel = document.getElementById('scenario-select').value;
|
| 101 |
+
if (scenarios[sel]) {
|
| 102 |
+
document.getElementById('patient-note').value = scenarios[sel];
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
function highlightPlaceholders(text) {
|
| 107 |
return text.replace(/\[([A-Z_]+)_(\d+)\]/g, '<span class="pii-highlight">[$1_$2]</span>');
|
| 108 |
}
|
| 109 |
|
| 110 |
+
function rehydrateText(text) {
|
| 111 |
+
let result = text;
|
| 112 |
+
piiMap.forEach(p => {
|
| 113 |
+
const regex = new RegExp(p.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
|
| 114 |
+
result = result.replace(regex, p.original);
|
| 115 |
+
});
|
| 116 |
+
return result;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
async function runTriage() {
|
| 120 |
const note = document.getElementById('patient-note').value;
|
| 121 |
+
document.getElementById('agent-progress').classList.remove('hidden');
|
| 122 |
+
document.getElementById('gpu-section').classList.remove('hidden');
|
| 123 |
+
document.getElementById('rehydrated-container').classList.remove('hidden');
|
| 124 |
+
document.getElementById('start-btn').disabled = true;
|
| 125 |
+
document.getElementById('result').innerHTML = '';
|
| 126 |
+
document.getElementById('rehydrated-output').innerText = '';
|
| 127 |
+
piiMap = [];
|
| 128 |
+
|
| 129 |
+
const eventSource = new EventSource(`/triage/stream?patient_note=${encodeURIComponent(note)}`);
|
| 130 |
+
|
| 131 |
+
eventSource.onmessage = (event) => {
|
| 132 |
+
const data = JSON.parse(event.data);
|
| 133 |
+
if (data.agent === 'Shield' && data.status === 'completed') {
|
| 134 |
+
document.getElementById('card-shield').classList.add('agent-active');
|
| 135 |
+
document.getElementById('shield-status').textContent = `${data.pii_map.length} PII entities redacted`;
|
| 136 |
+
piiMap = data.pii_map;
|
| 137 |
+
} else if (data.agent === 'Triage' && data.status === 'started') {
|
| 138 |
+
document.getElementById('gpu-gauge').style.width = data.gpu_util + '%';
|
| 139 |
+
document.getElementById('gpu-pct').textContent = data.gpu_util + '%';
|
| 140 |
+
} else if (data.agent === 'Triage' && data.token) {
|
| 141 |
+
// rehydrate token and display
|
| 142 |
+
let displayToken = data.token;
|
| 143 |
+
piiMap.forEach(p => {
|
| 144 |
+
displayToken = displayToken.replace(new RegExp(p.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), p.original);
|
| 145 |
+
});
|
| 146 |
+
document.getElementById('rehydrated-output').innerText += displayToken;
|
| 147 |
+
// update GPU gauge (to simulate high usage)
|
| 148 |
+
document.getElementById('gpu-gauge').style.width = '78%';
|
| 149 |
+
document.getElementById('gpu-pct').textContent = '78%';
|
| 150 |
+
} else if (data.agent === 'Triage' && data.status === 'completed') {
|
| 151 |
+
document.getElementById('card-triage').classList.add('agent-active');
|
| 152 |
+
document.getElementById('triage-status').textContent = 'Inference complete';
|
| 153 |
+
} else if (data.agent === 'Audit' && data.status === 'completed') {
|
| 154 |
+
document.getElementById('card-audit').classList.add('agent-active');
|
| 155 |
+
document.getElementById('audit-status').textContent = 'On-chain tx confirmed';
|
| 156 |
+
eventSource.close();
|
| 157 |
+
fetchFinalResult(note);
|
| 158 |
+
}
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
eventSource.onerror = () => {
|
| 162 |
+
eventSource.close();
|
| 163 |
+
fetchFinalResult(note);
|
| 164 |
+
};
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
async function fetchFinalResult(note) {
|
| 168 |
+
const resp = await fetch('/triage', {
|
| 169 |
+
method: 'POST',
|
| 170 |
+
headers: {'Content-Type': 'application/json'},
|
| 171 |
+
body: JSON.stringify({patient_note: note, consent_hash: 'abc123'})
|
| 172 |
+
});
|
| 173 |
+
const data = await resp.json();
|
| 174 |
+
document.getElementById('redacted-preview').classList.remove('hidden');
|
| 175 |
+
document.getElementById('redacted-text').innerHTML = highlightPlaceholders(data.redacted_prompt);
|
| 176 |
+
document.getElementById('result').innerHTML = `
|
| 177 |
+
<div class="bg-white rounded-2xl shadow-xl p-6 space-y-4">
|
| 178 |
+
<div class="text-sm text-purple-700 bg-purple-50 px-3 py-1 rounded-full inline-block">
|
| 179 |
+
${data.device_info} · Model: Qwen2.5-${data.model_used}
|
| 180 |
</div>
|
| 181 |
+
<div><h3 class="font-semibold">Triage Result</h3>
|
| 182 |
+
<div class="bg-purple-50 p-3 rounded-lg text-lg font-medium">${data.triage_result}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
</div>
|
| 184 |
+
<div><h3 class="font-semibold">PII Redaction Map</h3>
|
| 185 |
+
<ul class="list-disc list-inside text-sm">${data.pii_map.map(p => `<li>🔴 <strong>${p.original}</strong> → <code>${p.placeholder}</code></li>`).join('')}</ul>
|
| 186 |
+
</div>
|
| 187 |
+
<div><h3 class="font-semibold">Redaction Proof (Schnorr Signature)</h3>
|
| 188 |
+
<code class="text-xs bg-gray-100 p-2 rounded block">${data.redaction_proof}</code>
|
| 189 |
+
</div>
|
| 190 |
+
<div><h3 class="font-semibold">On‑Chain Audit</h3>
|
| 191 |
+
<p>CID: <code>${data.cid}</code></p>
|
| 192 |
+
<p>Transaction: <a href="https://sepolia.basescan.org/tx/${data.transaction_hash}" target="_blank" class="text-purple-600 underline">${data.transaction_hash}</a></p>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
`;
|
| 196 |
}
|
| 197 |
</script>
|
| 198 |
</body>
|