| use chrono::SecondsFormat; |
|
|
| use crate::{models::TriageRecord, pipeline::orchestrator::TriageOutcome}; |
|
|
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] |
| pub struct TriageViewModel { |
| pub record_id: String, |
| pub patient_id: String, |
| pub reason: String, |
| pub redacted_note: String, |
| pub pii_map: Vec<crate::shield::redact::PiiMatch>, |
| pub triage_result: String, |
| pub model_used: String, |
| pub device_used: String, |
| pub cid: String, |
| pub transaction_hash: String, |
| pub redaction_proof: crate::pipeline::proof::RedactionProof, |
| pub consortium_attestations: Vec<crate::models::ConsortiumAttestation>, |
| pub enrichment: crate::models::MedicalEnrichment, |
| pub degraded_reason: Option<String>, |
| pub created_at: String, |
| } |
|
|
| impl TriageViewModel { |
| pub fn from_outcome(outcome: TriageOutcome) -> Self { |
| let record = outcome.record; |
| Self { |
| record_id: record.record_id.as_str().to_string(), |
| patient_id: record.patient_id.as_str().to_string(), |
| reason: record.reason, |
| redacted_note: record.redacted_note, |
| pii_map: record.pii_map, |
| triage_result: record.triage_result, |
| model_used: record.model_used, |
| device_used: record.device_used, |
| cid: record.cid, |
| transaction_hash: record.transaction_hash, |
| redaction_proof: record.redaction_proof, |
| consortium_attestations: record.consortium_attestations, |
| enrichment: record.enrichment, |
| degraded_reason: record.degraded_reason, |
| created_at: record.created_at.to_rfc3339_opts(SecondsFormat::Secs, true), |
| } |
| } |
| } |
|
|
| pub fn render_dashboard_fragment(records: &[TriageRecord]) -> String { |
| if records.is_empty() { |
| return r#"<div class='rounded-2xl border border-dashed border-slate-300 p-6 text-slate-500'>No triage history yet. Run a case and it appears here instantly.</div>"#.to_string(); |
| } |
|
|
| let rows = records.iter().rev().take(12).map(render_history_row).collect::<String>(); |
| format!(r#" |
| <div class='overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm'> |
| <div class='border-b border-slate-200 px-5 py-4'> |
| <div class='text-xs uppercase tracking-[0.25em] text-slate-400'>Live Memory</div> |
| <h3 class='mt-1 text-lg font-semibold text-slate-900'>Recent triage decisions</h3> |
| </div> |
| <div class='divide-y divide-slate-100'> |
| {} |
| </div> |
| </div> |
| "#, rows) |
| } |
|
|
| fn render_history_row(record: &TriageRecord) -> String { |
| format!(r#" |
| <div class='grid gap-3 px-5 py-4 md:grid-cols-4'> |
| <div> |
| <div class='text-xs text-slate-400'>Record</div> |
| <div class='font-mono text-sm text-slate-900'>{}</div> |
| </div> |
| <div> |
| <div class='text-xs text-slate-400'>Patient</div> |
| <div class='text-sm font-medium text-slate-900'>{}</div> |
| </div> |
| <div> |
| <div class='text-xs text-slate-400'>Decision</div> |
| <div class='text-sm text-slate-700'>{}</div> |
| </div> |
| <div> |
| <div class='text-xs text-slate-400'>Audit</div> |
| <div class='text-sm text-slate-700'>CID {}</div> |
| </div> |
| </div> |
| "#, record.record_id.as_str(), escape_html(record.patient_id.as_str()), escape_html(&record.triage_result), escape_html(&record.cid)) |
| } |
|
|
| pub fn render_triage_fragment(vm: &TriageViewModel) -> String { |
| let pii_rows = if vm.pii_map.is_empty() { |
| "<li class='text-slate-500'>No PHI patterns detected by the deterministic shield.</li>".to_string() |
| } else { |
| vm.pii_map.iter().map(|item| { |
| format!(r#"<li class='flex gap-2'><span class='text-rose-500'>●</span><span><strong>{}</strong> → <code class='rounded bg-slate-100 px-1.5 py-0.5'>{}</code></span></li>"#, escape_html(&item.original), escape_html(&item.placeholder)) |
| }).collect::<String>() |
| }; |
|
|
| let attestation_rows = vm.consortium_attestations.iter().map(|a| { |
| format!(r#"<div class='rounded-2xl border border-slate-200 bg-slate-50 p-3'><div class='font-medium text-slate-900'>{}</div><div class='text-xs text-slate-500'>{}</div><div class='mt-2 font-mono text-xs text-slate-600 break-all'>{}</div></div>"#, escape_html(&a.hospital), escape_html(&a.signature_status), escape_html(&a.attestation_hash)) |
| }).collect::<String>(); |
|
|
| let pubmed_rows = if vm.enrichment.pubmed_hits.is_empty() { |
| "<div class='text-sm text-slate-500'>No PubMed results matched this case.</div>".to_string() |
| } else { |
| vm.enrichment.pubmed_hits.iter().map(|hit| { |
| format!(r#"<a class='block rounded-2xl border border-slate-200 p-3 hover:bg-slate-50' href='{}' target='_blank' rel='noreferrer'><div class='text-xs text-slate-400'>PubMed {}</div><div class='text-sm font-medium text-slate-900'>{}</div></a>"#, escape_html(&hit.url), escape_html(&hit.pmid), escape_html(&hit.title)) |
| }).collect::<String>() |
| }; |
|
|
| let degraded = vm.degraded_reason.as_ref().map(|reason| format!(r#"<div class='rounded-2xl border border-amber-200 bg-amber-50 p-4 text-amber-900'><div class='font-semibold'>Backend degraded mode</div><div class='text-sm mt-1'>{}</div></div>"#, escape_html(reason))).unwrap_or_default(); |
|
|
| format!(r#" |
| <div class='space-y-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-xl'> |
| <div class='flex flex-wrap items-center justify-between gap-3'> |
| <div> |
| <div class='text-xs uppercase tracking-[0.25em] text-slate-400'>Triage Result</div> |
| <h3 class='mt-1 text-2xl font-semibold text-slate-900'>Case {}</h3> |
| </div> |
| <div class='rounded-full bg-emerald-50 px-3 py-1 text-sm font-medium text-emerald-700'>{}</div> |
| </div> |
| |
| {} |
| |
| <div class='grid gap-4 lg:grid-cols-2'> |
| <div class='rounded-3xl bg-slate-50 p-4'> |
| <div class='mb-2 text-sm font-semibold text-slate-900'>Clinical synthesis</div> |
| <p class='whitespace-pre-wrap text-sm leading-7 text-slate-700'>{}</p> |
| </div> |
| <div class='space-y-4 rounded-3xl bg-slate-50 p-4'> |
| <div> |
| <div class='mb-2 text-sm font-semibold text-slate-900'>Redacted note</div> |
| <pre class='whitespace-pre-wrap rounded-2xl bg-slate-950 p-4 text-xs leading-6 text-emerald-200'>{}</pre> |
| </div> |
| <div> |
| <div class='mb-2 text-sm font-semibold text-slate-900'>Redaction proof</div> |
| <div class='break-all rounded-2xl bg-slate-950 p-4 font-mono text-xs text-sky-200'>{}</div> |
| <div class='mt-2 text-xs text-slate-500'>Verified: {}</div> |
| </div> |
| </div> |
| </div> |
| |
| <div class='grid gap-4 lg:grid-cols-2'> |
| <div class='rounded-3xl border border-slate-200 p-4'> |
| <div class='mb-2 text-sm font-semibold text-slate-900'>PII shield map</div> |
| <ul class='space-y-2 text-sm text-slate-700'>{}</ul> |
| </div> |
| <div class='rounded-3xl border border-slate-200 p-4'> |
| <div class='mb-2 text-sm font-semibold text-slate-900'>Consortium attestations</div> |
| <div class='grid gap-3'>{}</div> |
| </div> |
| </div> |
| |
| <div class='grid gap-4 lg:grid-cols-2'> |
| <div class='rounded-3xl border border-slate-200 p-4'> |
| <div class='mb-2 text-sm font-semibold text-slate-900'>PubMed enrichment</div> |
| <div class='space-y-3'>{}</div> |
| </div> |
| <div class='rounded-3xl border border-slate-200 p-4'> |
| <div class='mb-2 text-sm font-semibold text-slate-900'>On-chain audit</div> |
| <div class='space-y-2 text-sm text-slate-700'> |
| <div><span class='text-slate-400'>CID:</span> <span class='font-mono break-all'>{}</span></div> |
| <div><span class='text-slate-400'>Tx:</span> <span class='font-mono break-all'>{}</span></div> |
| <div><span class='text-slate-400'>Model:</span> {} · {}</div> |
| <div><span class='text-slate-400'>Created:</span> {}</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| "#, escape_html(&vm.record_id), escape_html(&vm.reason), degraded, escape_html(&vm.triage_result), escape_html(&vm.redacted_note), escape_html(&vm.redaction_proof.proof), vm.redaction_proof.verified, pii_rows, attestation_rows, pubmed_rows, escape_html(&vm.cid), escape_html(&vm.transaction_hash), escape_html(&vm.model_used), escape_html(&vm.device_used), escape_html(&vm.created_at)) |
| } |
|
|
| pub fn render_dicom_fragment(result: &crate::pipeline::dicom::DicomRedactionResult) -> String { |
| let fields = if result.report.detected_fields.is_empty() { |
| "<li class='text-slate-500'>No structured DICOM fields were parsed in this demo file.</li>".to_string() |
| } else { |
| result.report.detected_fields.iter().map(|field| format!(r#"<li>{}</li>"#, escape_html(field))).collect::<String>() |
| }; |
|
|
| format!(r#" |
| <div class='space-y-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-lg'> |
| <div> |
| <div class='text-xs uppercase tracking-[0.25em] text-slate-400'>DICOM Shield</div> |
| <h3 class='mt-1 text-xl font-semibold text-slate-900'>{}</h3> |
| </div> |
| <div class='grid gap-4 lg:grid-cols-2'> |
| <div class='rounded-2xl bg-slate-50 p-4'> |
| <div class='mb-2 text-sm font-semibold text-slate-900'>Parsed fields</div> |
| <ul class='list-disc space-y-1 pl-5 text-sm text-slate-700'>{}</ul> |
| </div> |
| <div class='rounded-2xl bg-slate-50 p-4'> |
| <div class='mb-2 text-sm font-semibold text-slate-900'>Burned-in OCR preview</div> |
| <div class='whitespace-pre-wrap text-sm text-slate-700'>{}</div> |
| </div> |
| </div> |
| <div> |
| <div class='mb-2 text-sm font-semibold text-slate-900'>Redacted preview</div> |
| <pre class='whitespace-pre-wrap rounded-2xl bg-slate-950 p-4 text-xs text-emerald-200'>{}</pre> |
| </div> |
| </div> |
| "#, escape_html(&result.report.filename), fields, escape_html(&result.report.burned_in_text_preview), escape_html(&result.report.redacted_preview)) |
| } |
|
|
| fn escape_html(input: &str) -> String { |
| html_escape::encode_text(input).to_string() |
| } |
|
|