436 lines
13 KiB
Rust
436 lines
13 KiB
Rust
//! `TumorBoard` AI - Agentic Multi-Modal Medical Imaging Analysis.
|
|
//!
|
|
//! This demo showcases an AI system that analyzes multiple imaging modalities
|
|
//! (CT, MRI, pathology) together to provide comprehensive diagnostic insights.
|
|
|
|
pub mod ct_analyzer;
|
|
pub mod fusion;
|
|
pub mod mri_analyzer;
|
|
pub mod pathology_analyzer;
|
|
pub mod report_generator;
|
|
pub mod sample_data;
|
|
|
|
use thiserror::Error;
|
|
use tumorboard_shared::{
|
|
AnalysisMetadata, CTFindings, CaseAnalysisRequest, CaseAnalysisResult, FusedAnalysis,
|
|
MRIFindings, PathologyFindings, Recommendation,
|
|
};
|
|
|
|
use ct_analyzer::CTAnalyzer;
|
|
use fusion::MultiModalFusion;
|
|
use mri_analyzer::MRIAnalyzer;
|
|
use pathology_analyzer::PathologyAnalyzer;
|
|
use report_generator::ReportGenerator;
|
|
|
|
/// Errors that can occur during tumor board analysis.
|
|
#[derive(Debug, Error)]
|
|
pub enum TumorBoardError {
|
|
/// No imaging data provided.
|
|
#[error("No imaging data provided for analysis")]
|
|
NoImagingData,
|
|
|
|
/// CT analysis failed.
|
|
#[error("CT analysis failed: {0}")]
|
|
CTAnalysisError(String),
|
|
|
|
/// MRI analysis failed.
|
|
#[error("MRI analysis failed: {0}")]
|
|
MRIAnalysisError(String),
|
|
|
|
/// Pathology analysis failed.
|
|
#[error("Pathology analysis failed: {0}")]
|
|
PathologyAnalysisError(String),
|
|
|
|
/// Fusion failed.
|
|
#[error("Multi-modal fusion failed: {0}")]
|
|
FusionError(String),
|
|
|
|
/// Report generation failed.
|
|
#[error("Report generation failed: {0}")]
|
|
ReportError(String),
|
|
}
|
|
|
|
/// Main tumor board AI system.
|
|
#[derive(Debug)]
|
|
pub struct TumorBoardAI {
|
|
ct_analyzer: CTAnalyzer,
|
|
mri_analyzer: MRIAnalyzer,
|
|
pathology_analyzer: PathologyAnalyzer,
|
|
fusion: MultiModalFusion,
|
|
report_generator: ReportGenerator,
|
|
}
|
|
|
|
impl Default for TumorBoardAI {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl TumorBoardAI {
|
|
/// Create a new tumor board AI system.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
ct_analyzer: CTAnalyzer::new(),
|
|
mri_analyzer: MRIAnalyzer::new(),
|
|
pathology_analyzer: PathologyAnalyzer::new(),
|
|
fusion: MultiModalFusion::new(),
|
|
report_generator: ReportGenerator::new(),
|
|
}
|
|
}
|
|
|
|
/// Analyze a case.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns error if analysis fails or no imaging data is provided.
|
|
pub fn analyze_case(
|
|
&self,
|
|
request: &CaseAnalysisRequest,
|
|
) -> Result<CaseAnalysisResult, TumorBoardError> {
|
|
use std::time::Instant;
|
|
let start = Instant::now();
|
|
|
|
// Check that we have at least some imaging data
|
|
if request.studies.is_empty() {
|
|
return Err(TumorBoardError::NoImagingData);
|
|
}
|
|
|
|
// Analyze each modality
|
|
let ct_findings = if request.config.analyze_ct {
|
|
self.analyze_ct(request)?
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mri_findings = if request.config.analyze_mri {
|
|
self.analyze_mri(request)?
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let pathology_findings = if request.config.analyze_pathology {
|
|
self.analyze_pathology(request)?
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Fuse multi-modal findings
|
|
let fused = self
|
|
.fusion
|
|
.fuse(&ct_findings, &mri_findings, &pathology_findings)
|
|
.map_err(|e| TumorBoardError::FusionError(e.to_string()))?;
|
|
|
|
// Generate recommendations
|
|
let recommendations = self.generate_recommendations(&fused);
|
|
|
|
// Generate report if requested
|
|
let report = if request.config.generate_report {
|
|
Some(
|
|
self.report_generator
|
|
.generate(
|
|
request,
|
|
&ct_findings,
|
|
&mri_findings,
|
|
&pathology_findings,
|
|
&fused,
|
|
&recommendations,
|
|
)
|
|
.map_err(|e| TumorBoardError::ReportError(e.to_string()))?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let processing_time = start.elapsed().as_secs_f32();
|
|
|
|
Ok(CaseAnalysisResult {
|
|
case_id: request.case_id.clone(),
|
|
ct_findings,
|
|
mri_findings,
|
|
pathology_findings,
|
|
fused_analysis: fused,
|
|
report,
|
|
recommendations,
|
|
metadata: AnalysisMetadata {
|
|
processing_time,
|
|
models_used: vec![
|
|
"CT-3D-CNN-v1".to_string(),
|
|
"MRI-MultiSeq-v1".to_string(),
|
|
"Pathology-WSI-v1".to_string(),
|
|
"Fusion-CrossAttention-v1".to_string(),
|
|
],
|
|
timestamp: chrono_lite_timestamp(),
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Analyze CT studies.
|
|
fn analyze_ct(
|
|
&self,
|
|
request: &CaseAnalysisRequest,
|
|
) -> Result<Option<CTFindings>, TumorBoardError> {
|
|
use tumorboard_shared::ImagingModality;
|
|
|
|
let ct_studies: Vec<_> = request
|
|
.studies
|
|
.iter()
|
|
.filter(|s| s.modality == ImagingModality::CT)
|
|
.collect();
|
|
|
|
if ct_studies.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
self.ct_analyzer
|
|
.analyze(&ct_studies)
|
|
.map(Some)
|
|
.map_err(|e| TumorBoardError::CTAnalysisError(e.to_string()))
|
|
}
|
|
|
|
/// Analyze MRI studies.
|
|
fn analyze_mri(
|
|
&self,
|
|
request: &CaseAnalysisRequest,
|
|
) -> Result<Option<MRIFindings>, TumorBoardError> {
|
|
use tumorboard_shared::ImagingModality;
|
|
|
|
let mri_studies: Vec<_> = request
|
|
.studies
|
|
.iter()
|
|
.filter(|s| s.modality == ImagingModality::MRI)
|
|
.collect();
|
|
|
|
if mri_studies.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
self.mri_analyzer
|
|
.analyze(&mri_studies)
|
|
.map(Some)
|
|
.map_err(|e| TumorBoardError::MRIAnalysisError(e.to_string()))
|
|
}
|
|
|
|
/// Analyze pathology studies.
|
|
fn analyze_pathology(
|
|
&self,
|
|
request: &CaseAnalysisRequest,
|
|
) -> Result<Option<PathologyFindings>, TumorBoardError> {
|
|
use tumorboard_shared::ImagingModality;
|
|
|
|
let pathology_studies: Vec<_> = request
|
|
.studies
|
|
.iter()
|
|
.filter(|s| s.modality == ImagingModality::Pathology)
|
|
.collect();
|
|
|
|
if pathology_studies.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
self.pathology_analyzer
|
|
.analyze(&pathology_studies)
|
|
.map(Some)
|
|
.map_err(|e| TumorBoardError::PathologyAnalysisError(e.to_string()))
|
|
}
|
|
|
|
/// Generate treatment recommendations based on fused analysis.
|
|
fn generate_recommendations(&self, fused: &FusedAnalysis) -> Vec<Recommendation> {
|
|
use tumorboard_shared::{Priority, RecommendationType};
|
|
|
|
let mut recommendations = Vec::new();
|
|
|
|
// Recommend biopsy if high malignancy probability
|
|
if fused.primary_diagnosis.probability > 0.5 {
|
|
recommendations.push(Recommendation {
|
|
recommendation_type: RecommendationType::Biopsy,
|
|
description: "Consider tissue biopsy for definitive diagnosis".to_string(),
|
|
priority: Priority::High,
|
|
rationale: format!(
|
|
"Primary diagnosis ({}) has {:.0}% probability",
|
|
fused.primary_diagnosis.name,
|
|
fused.primary_diagnosis.probability * 100.0
|
|
),
|
|
});
|
|
}
|
|
|
|
// Recommend follow-up imaging
|
|
if fused.confidence < 0.8 {
|
|
recommendations.push(Recommendation {
|
|
recommendation_type: RecommendationType::ImagingFollowUp,
|
|
description: "Consider follow-up imaging for clarification".to_string(),
|
|
priority: Priority::Moderate,
|
|
rationale: format!(
|
|
"Analysis confidence is {:.0}%, additional imaging may help",
|
|
fused.confidence * 100.0
|
|
),
|
|
});
|
|
}
|
|
|
|
// Recommend specialist referral for malignancy
|
|
if fused.primary_diagnosis.probability > 0.7 {
|
|
recommendations.push(Recommendation {
|
|
recommendation_type: RecommendationType::Referral,
|
|
description: "Refer to oncology for multidisciplinary evaluation".to_string(),
|
|
priority: Priority::High,
|
|
rationale: "High probability of malignancy requires specialist evaluation"
|
|
.to_string(),
|
|
});
|
|
}
|
|
|
|
recommendations
|
|
}
|
|
|
|
/// Get a summary of findings.
|
|
#[must_use]
|
|
pub fn get_summary(&self, result: &CaseAnalysisResult) -> String {
|
|
let mut summary = String::new();
|
|
|
|
summary.push_str(&format!("Case: {}\n", result.case_id));
|
|
summary.push('\n');
|
|
|
|
summary.push_str("PRIMARY DIAGNOSIS:\n");
|
|
summary.push_str(&format!(
|
|
" {}: {:.0}% probability\n",
|
|
result.fused_analysis.primary_diagnosis.name,
|
|
result.fused_analysis.primary_diagnosis.probability * 100.0
|
|
));
|
|
|
|
if !result.fused_analysis.differential_diagnoses.is_empty() {
|
|
summary.push_str("\nDIFFERENTIAL DIAGNOSES:\n");
|
|
for dx in &result.fused_analysis.differential_diagnoses {
|
|
summary.push_str(&format!(
|
|
" - {}: {:.0}%\n",
|
|
dx.name,
|
|
dx.probability * 100.0
|
|
));
|
|
}
|
|
}
|
|
|
|
if !result.recommendations.is_empty() {
|
|
summary.push_str("\nRECOMMENDATIONS:\n");
|
|
for rec in &result.recommendations {
|
|
summary.push_str(&format!(" [{:?}] {}\n", rec.priority, rec.description));
|
|
}
|
|
}
|
|
|
|
summary.push_str(&format!(
|
|
"\nConfidence: {:.0}%\n",
|
|
result.fused_analysis.confidence * 100.0
|
|
));
|
|
summary.push_str(&format!(
|
|
"Processing time: {:.2}s\n",
|
|
result.metadata.processing_time
|
|
));
|
|
|
|
summary
|
|
}
|
|
}
|
|
|
|
/// Generate a simple timestamp.
|
|
fn chrono_lite_timestamp() -> String {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
let duration = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default();
|
|
|
|
format!("{}000", duration.as_secs())
|
|
}
|
|
|
|
/// Run a complete tumor board session with sample data.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns error if analysis fails.
|
|
pub fn run_demo() -> Result<CaseAnalysisResult, TumorBoardError> {
|
|
let request = tumorboard_shared::get_sample_case();
|
|
let ai = TumorBoardAI::new();
|
|
ai.analyze_case(&request)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_tumor_board_creation() {
|
|
let ai = TumorBoardAI::new();
|
|
assert!(std::mem::size_of_val(&ai) > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_analyze_case() {
|
|
let request = tumorboard_shared::get_sample_case();
|
|
let ai = TumorBoardAI::new();
|
|
|
|
let result = ai.analyze_case(&request);
|
|
assert!(result.is_ok());
|
|
|
|
let analysis = result.unwrap();
|
|
assert!(!analysis.case_id.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_imaging_data_error() {
|
|
let request = CaseAnalysisRequest {
|
|
case_id: "TEST001".to_string(),
|
|
patient_info: tumorboard_shared::PatientInfo {
|
|
age: Some(60),
|
|
sex: Some(tumorboard_shared::Sex::Male),
|
|
medical_history: vec![],
|
|
},
|
|
studies: vec![],
|
|
clinical_context: tumorboard_shared::ClinicalContext {
|
|
clinical_question: "Test".to_string(),
|
|
known_diagnosis: None,
|
|
lab_values: vec![],
|
|
prior_treatments: vec![],
|
|
},
|
|
config: tumorboard_shared::AnalysisConfig::default(),
|
|
};
|
|
|
|
let ai = TumorBoardAI::new();
|
|
let result = ai.analyze_case(&request);
|
|
assert!(result.is_err());
|
|
assert!(matches!(
|
|
result.unwrap_err(),
|
|
TumorBoardError::NoImagingData
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_run_demo() {
|
|
let result = run_demo();
|
|
assert!(result.is_ok());
|
|
|
|
let analysis = result.unwrap();
|
|
assert!(!analysis.fused_analysis.primary_diagnosis.name.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_summary() {
|
|
let request = tumorboard_shared::get_sample_case();
|
|
let ai = TumorBoardAI::new();
|
|
|
|
let result = ai.analyze_case(&request).unwrap();
|
|
let summary = ai.get_summary(&result);
|
|
|
|
assert!(summary.contains("PRIMARY DIAGNOSIS"));
|
|
assert!(summary.contains("Confidence"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_recommendations_generated() {
|
|
let request = tumorboard_shared::get_sample_case();
|
|
let ai = TumorBoardAI::new();
|
|
|
|
let result = ai.analyze_case(&request).unwrap();
|
|
// Recommendations should be generated for significant findings
|
|
assert!(
|
|
!result.recommendations.is_empty()
|
|
|| result.fused_analysis.primary_diagnosis.probability < 0.5
|
|
);
|
|
}
|
|
}
|