Initial commit
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
//! `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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user