Consistent formatting pass: line wrapping, import sorting, trailing whitespace removal, let-chain indentation, merged derive attributes, and unsafe block reformatting. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
553 lines
17 KiB
Rust
553 lines
17 KiB
Rust
//! Medical report generation.
|
|
//!
|
|
//! Generates structured medical reports from multi-modal analysis.
|
|
|
|
use tumorboard_shared::{
|
|
CTFindings, CaseAnalysisRequest, FusedAnalysis, MRIFindings, MedicalReport, PathologyFindings,
|
|
Recommendation, ReportMetadata, ReportSection,
|
|
};
|
|
|
|
/// Error type for report generation.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ReportError {
|
|
/// Missing required data.
|
|
#[error("Missing required data: {0}")]
|
|
MissingData(String),
|
|
|
|
/// Template error.
|
|
#[error("Report template error: {0}")]
|
|
TemplateError(String),
|
|
}
|
|
|
|
/// Report generator.
|
|
#[derive(Debug)]
|
|
pub struct ReportGenerator {
|
|
/// Include detailed methodology.
|
|
include_methodology: bool,
|
|
/// Model version.
|
|
model_version: String,
|
|
}
|
|
|
|
impl Default for ReportGenerator {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl ReportGenerator {
|
|
/// Create a new report generator.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
include_methodology: true,
|
|
model_version: "TumorBoard-AI-v1.0".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Generate a medical report.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns error if required data is missing.
|
|
pub fn generate(
|
|
&self,
|
|
request: &CaseAnalysisRequest,
|
|
ct_findings: &Option<CTFindings>,
|
|
mri_findings: &Option<MRIFindings>,
|
|
pathology_findings: &Option<PathologyFindings>,
|
|
fused: &FusedAnalysis,
|
|
recommendations: &[Recommendation],
|
|
) -> Result<MedicalReport, ReportError> {
|
|
let mut sections = Vec::new();
|
|
|
|
// Clinical history section
|
|
sections.push(self.build_clinical_section(request));
|
|
|
|
// Imaging findings section
|
|
if ct_findings.is_some() || mri_findings.is_some() {
|
|
sections.push(self.build_imaging_section(ct_findings, mri_findings));
|
|
}
|
|
|
|
// Pathology section
|
|
if let Some(path) = pathology_findings {
|
|
sections.push(self.build_pathology_section(path));
|
|
}
|
|
|
|
// Integrated findings section
|
|
sections.push(self.build_integrated_section(fused));
|
|
|
|
// Recommendations section
|
|
if !recommendations.is_empty() {
|
|
sections.push(self.build_recommendations_section(recommendations));
|
|
}
|
|
|
|
// Methodology section
|
|
if self.include_methodology {
|
|
sections.push(self.build_methodology_section());
|
|
}
|
|
|
|
// Generate summary
|
|
let summary = self.build_summary(fused);
|
|
|
|
// Generate impression
|
|
let impression = self.build_impression(fused);
|
|
|
|
Ok(MedicalReport {
|
|
sections,
|
|
summary,
|
|
impression,
|
|
metadata: ReportMetadata {
|
|
generated_at: chrono_lite_timestamp(),
|
|
model_version: self.model_version.clone(),
|
|
disclaimer: self.generate_disclaimer(),
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Build clinical history section.
|
|
fn build_clinical_section(&self, request: &CaseAnalysisRequest) -> ReportSection {
|
|
let mut content = String::new();
|
|
|
|
content.push_str("CLINICAL HISTORY:\n\n");
|
|
|
|
if let Some(age) = request.patient_info.age {
|
|
content.push_str(&format!("Age: {age} years\n"));
|
|
}
|
|
|
|
if let Some(sex) = &request.patient_info.sex {
|
|
content.push_str(&format!("Sex: {sex:?}\n"));
|
|
}
|
|
|
|
if !request.patient_info.medical_history.is_empty() {
|
|
content.push_str("\nRelevant History:\n");
|
|
for item in &request.patient_info.medical_history {
|
|
content.push_str(&format!(" - {item}\n"));
|
|
}
|
|
}
|
|
|
|
content.push_str(&format!(
|
|
"\nClinical Question: {}\n",
|
|
request.clinical_context.clinical_question
|
|
));
|
|
|
|
if !request.clinical_context.lab_values.is_empty() {
|
|
content.push_str("\nRelevant Lab Values:\n");
|
|
for lab in &request.clinical_context.lab_values {
|
|
content.push_str(&format!(" - {}: {} {}\n", lab.name, lab.value, lab.unit));
|
|
}
|
|
}
|
|
|
|
ReportSection {
|
|
title: "Clinical History".to_string(),
|
|
content,
|
|
image_references: vec![],
|
|
}
|
|
}
|
|
|
|
/// Build imaging findings section.
|
|
fn build_imaging_section(
|
|
&self,
|
|
ct: &Option<CTFindings>,
|
|
mri: &Option<MRIFindings>,
|
|
) -> ReportSection {
|
|
let mut content = String::new();
|
|
|
|
content.push_str("IMAGING FINDINGS:\n\n");
|
|
|
|
if let Some(ct_findings) = ct {
|
|
content.push_str("CT Findings:\n");
|
|
content.push_str(&format!(" {}\n", ct_findings.assessment));
|
|
|
|
if !ct_findings.lesions.is_empty() {
|
|
content.push_str("\n Lesions:\n");
|
|
for lesion in &ct_findings.lesions {
|
|
content.push_str(&format!(
|
|
" - {}: {:.1} x {:.1} x {:.1} mm, {:?}\n",
|
|
lesion.location.region,
|
|
lesion.size[0],
|
|
lesion.size[1],
|
|
lesion.size[2],
|
|
lesion.lesion_type
|
|
));
|
|
}
|
|
}
|
|
|
|
content.push_str(&format!(
|
|
"\n Confidence: {:.0}%\n",
|
|
ct_findings.confidence * 100.0
|
|
));
|
|
}
|
|
|
|
if let Some(mri_findings) = mri {
|
|
if ct.is_some() {
|
|
content.push('\n');
|
|
}
|
|
content.push_str("MRI Findings:\n");
|
|
content.push_str(&format!(" {}\n", mri_findings.assessment));
|
|
|
|
if !mri_findings.sequence_findings.is_empty() {
|
|
content.push_str("\n Sequence Findings:\n");
|
|
for seq in &mri_findings.sequence_findings {
|
|
content.push_str(&format!(" - {}: {}\n", seq.sequence, seq.finding));
|
|
}
|
|
}
|
|
|
|
if let Some(enh) = &mri_findings.enhancement_pattern {
|
|
content.push_str(&format!(
|
|
"\n Enhancement: {} ({:?})\n",
|
|
enh.pattern, enh.degree
|
|
));
|
|
}
|
|
|
|
content.push_str(&format!(
|
|
"\n Confidence: {:.0}%\n",
|
|
mri_findings.confidence * 100.0
|
|
));
|
|
}
|
|
|
|
ReportSection {
|
|
title: "Imaging Findings".to_string(),
|
|
content,
|
|
image_references: vec![],
|
|
}
|
|
}
|
|
|
|
/// Build pathology section.
|
|
fn build_pathology_section(&self, path: &PathologyFindings) -> ReportSection {
|
|
let mut content = String::new();
|
|
|
|
content.push_str("PATHOLOGY FINDINGS:\n\n");
|
|
content.push_str(&format!("Diagnosis: {}\n", path.assessment));
|
|
|
|
if let Some(grade) = &path.grading {
|
|
content.push_str(&format!("\nGrade: {} ({})\n", grade.grade, grade.system));
|
|
}
|
|
|
|
if !path.biomarkers.is_empty() {
|
|
content.push_str("\nBiomarkers:\n");
|
|
for marker in &path.biomarkers {
|
|
let expr = marker
|
|
.expression
|
|
.map(|e| format!(" ({e:.0}%)"))
|
|
.unwrap_or_default();
|
|
content.push_str(&format!(
|
|
" - {}: {:?}{}\n",
|
|
marker.name, marker.status, expr
|
|
));
|
|
}
|
|
}
|
|
|
|
content.push_str(&format!("\nConfidence: {:.0}%\n", path.confidence * 100.0));
|
|
|
|
ReportSection {
|
|
title: "Pathology Findings".to_string(),
|
|
content,
|
|
image_references: vec![],
|
|
}
|
|
}
|
|
|
|
/// Build integrated findings section.
|
|
fn build_integrated_section(&self, fused: &FusedAnalysis) -> ReportSection {
|
|
let mut content = String::new();
|
|
|
|
content.push_str("INTEGRATED ANALYSIS:\n\n");
|
|
|
|
content.push_str(&format!(
|
|
"Primary Diagnosis: {} (probability: {:.0}%)\n",
|
|
fused.primary_diagnosis.name,
|
|
fused.primary_diagnosis.probability * 100.0
|
|
));
|
|
|
|
if let Some(icd) = &fused.primary_diagnosis.icd_code {
|
|
content.push_str(&format!("ICD-10: {icd}\n"));
|
|
}
|
|
|
|
if !fused.differential_diagnoses.is_empty() {
|
|
content.push_str("\nDifferential Diagnoses:\n");
|
|
for dx in &fused.differential_diagnoses {
|
|
content.push_str(&format!(
|
|
" - {} ({:.0}%)\n",
|
|
dx.name,
|
|
dx.probability * 100.0
|
|
));
|
|
}
|
|
}
|
|
|
|
if let Some(assessment) = &fused.tumor_assessment {
|
|
if let Some(tnm) = &assessment.tnm_stage {
|
|
content.push_str(&format!(
|
|
"\nTNM Staging: T{} N{} M{} (Stage {})\n",
|
|
tnm.t, tnm.n, tnm.m, tnm.overall_stage
|
|
));
|
|
}
|
|
|
|
if !assessment.prognostic_factors.is_empty() {
|
|
content.push_str("\nPrognostic Factors:\n");
|
|
for factor in &assessment.prognostic_factors {
|
|
content.push_str(&format!(
|
|
" - {}: {} ({:?})\n",
|
|
factor.name, factor.value, factor.impact
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
if !fused.correlations.is_empty() {
|
|
content.push_str("\nCross-Modal Correlations:\n");
|
|
for corr in &fused.correlations {
|
|
content.push_str(&format!(
|
|
" - {:?} ↔ {:?}: {} ({:?})\n",
|
|
corr.modality_a, corr.modality_b, corr.description, corr.correlation_type
|
|
));
|
|
}
|
|
}
|
|
|
|
content.push_str(&format!(
|
|
"\nOverall Confidence: {:.0}%\n",
|
|
fused.confidence * 100.0
|
|
));
|
|
|
|
ReportSection {
|
|
title: "Integrated Analysis".to_string(),
|
|
content,
|
|
image_references: vec![],
|
|
}
|
|
}
|
|
|
|
/// Build recommendations section.
|
|
fn build_recommendations_section(&self, recommendations: &[Recommendation]) -> ReportSection {
|
|
let mut content = String::new();
|
|
|
|
content.push_str("RECOMMENDATIONS:\n\n");
|
|
|
|
for (i, rec) in recommendations.iter().enumerate() {
|
|
content.push_str(&format!(
|
|
"{}. [{:?}] {:?}: {}\n",
|
|
i + 1,
|
|
rec.priority,
|
|
rec.recommendation_type,
|
|
rec.description
|
|
));
|
|
content.push_str(&format!(" Rationale: {}\n\n", rec.rationale));
|
|
}
|
|
|
|
ReportSection {
|
|
title: "Recommendations".to_string(),
|
|
content,
|
|
image_references: vec![],
|
|
}
|
|
}
|
|
|
|
/// Build methodology section.
|
|
fn build_methodology_section(&self) -> ReportSection {
|
|
let content = r"METHODOLOGY:
|
|
|
|
This analysis was performed using the TumorBoard AI system, which employs
|
|
deep learning models for multi-modal medical image analysis.
|
|
|
|
CT Analysis:
|
|
- 3D convolutional neural network for lesion detection
|
|
- Volumetric measurement using automated segmentation
|
|
- Malignancy probability estimation based on imaging features
|
|
|
|
MRI Analysis:
|
|
- Multi-sequence feature extraction
|
|
- Enhancement pattern characterization
|
|
- Diffusion analysis when available
|
|
|
|
Pathology Analysis:
|
|
- Whole slide image analysis
|
|
- Cell detection and classification
|
|
- Biomarker quantification using validated algorithms
|
|
|
|
Multi-Modal Fusion:
|
|
- Cross-attention mechanism for modality integration
|
|
- Concordance assessment between modalities
|
|
- Integrated staging using AJCC guidelines
|
|
"
|
|
.to_string();
|
|
|
|
ReportSection {
|
|
title: "Methodology".to_string(),
|
|
content,
|
|
image_references: vec![],
|
|
}
|
|
}
|
|
|
|
/// Build summary.
|
|
fn build_summary(&self, fused: &FusedAnalysis) -> String {
|
|
format!(
|
|
"{} with {:.0}% probability. {}",
|
|
fused.primary_diagnosis.name,
|
|
fused.primary_diagnosis.probability * 100.0,
|
|
fused
|
|
.tumor_assessment
|
|
.as_ref()
|
|
.and_then(|a| a.tnm_stage.as_ref())
|
|
.map(|s| format!("Stage {}.", s.overall_stage))
|
|
.unwrap_or_default()
|
|
)
|
|
}
|
|
|
|
/// Build impression.
|
|
fn build_impression(&self, fused: &FusedAnalysis) -> String {
|
|
let mut impression = String::new();
|
|
|
|
impression.push_str(&format!(
|
|
"1. {}: {:.0}% probability\n",
|
|
fused.primary_diagnosis.name,
|
|
fused.primary_diagnosis.probability * 100.0
|
|
));
|
|
|
|
if let Some(assessment) = &fused.tumor_assessment
|
|
&& let Some(tnm) = &assessment.tnm_stage
|
|
{
|
|
impression.push_str(&format!(
|
|
"2. Clinical stage: {} (T{}N{}M{})\n",
|
|
tnm.overall_stage, tnm.t, tnm.n, tnm.m
|
|
));
|
|
}
|
|
|
|
impression.push_str(&format!(
|
|
"3. Overall analysis confidence: {:.0}%\n",
|
|
fused.confidence * 100.0
|
|
));
|
|
|
|
impression
|
|
}
|
|
|
|
/// Generate disclaimer.
|
|
fn generate_disclaimer(&self) -> String {
|
|
"DISCLAIMER: This AI-generated report is intended to assist healthcare providers \
|
|
and should not replace clinical judgment. All findings should be correlated with \
|
|
clinical history and verified by qualified medical professionals before treatment \
|
|
decisions are made. This system is not FDA-approved for clinical use."
|
|
.to_string()
|
|
}
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tumorboard_shared::{
|
|
AnalysisConfig, ClinicalContext, Diagnosis, ImagingModality, LabValue, PatientInfo, Sex,
|
|
};
|
|
|
|
fn create_test_request() -> CaseAnalysisRequest {
|
|
CaseAnalysisRequest {
|
|
case_id: "TEST001".to_string(),
|
|
patient_info: PatientInfo {
|
|
age: Some(65),
|
|
sex: Some(Sex::Male),
|
|
medical_history: vec!["Former smoker".to_string()],
|
|
},
|
|
studies: vec![],
|
|
clinical_context: ClinicalContext {
|
|
clinical_question: "Evaluate lung nodule".to_string(),
|
|
known_diagnosis: None,
|
|
lab_values: vec![LabValue {
|
|
name: "CEA".to_string(),
|
|
value: 8.5,
|
|
unit: "ng/mL".to_string(),
|
|
reference_range: Some((0.0, 3.0)),
|
|
}],
|
|
prior_treatments: vec![],
|
|
},
|
|
config: AnalysisConfig::default(),
|
|
}
|
|
}
|
|
|
|
fn create_fused_analysis() -> FusedAnalysis {
|
|
FusedAnalysis {
|
|
primary_diagnosis: Diagnosis {
|
|
icd_code: Some("C34.9".to_string()),
|
|
name: "Lung adenocarcinoma".to_string(),
|
|
probability: 0.85,
|
|
evidence: vec![],
|
|
},
|
|
differential_diagnoses: vec![],
|
|
correlations: vec![],
|
|
tumor_assessment: None,
|
|
confidence: 0.88,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_report_generator_creation() {
|
|
let generator = ReportGenerator::new();
|
|
assert!(generator.include_methodology);
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_report() {
|
|
let generator = ReportGenerator::new();
|
|
let request = create_test_request();
|
|
let fused = create_fused_analysis();
|
|
|
|
let result = generator.generate(&request, &None, &None, &None, &fused, &[]);
|
|
|
|
assert!(result.is_ok());
|
|
let report = result.unwrap();
|
|
assert!(!report.sections.is_empty());
|
|
assert!(!report.summary.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_clinical_section() {
|
|
let generator = ReportGenerator::new();
|
|
let request = create_test_request();
|
|
|
|
let section = generator.build_clinical_section(&request);
|
|
assert!(section.content.contains("Age: 65"));
|
|
assert!(section.content.contains("Former smoker"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_summary_generation() {
|
|
let generator = ReportGenerator::new();
|
|
let fused = create_fused_analysis();
|
|
|
|
let summary = generator.build_summary(&fused);
|
|
assert!(summary.contains("Lung adenocarcinoma"));
|
|
assert!(summary.contains("85%"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_impression_generation() {
|
|
let generator = ReportGenerator::new();
|
|
let fused = create_fused_analysis();
|
|
|
|
let impression = generator.build_impression(&fused);
|
|
assert!(impression.contains("Lung adenocarcinoma"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_disclaimer() {
|
|
let generator = ReportGenerator::new();
|
|
let disclaimer = generator.generate_disclaimer();
|
|
|
|
assert!(disclaimer.contains("DISCLAIMER"));
|
|
assert!(disclaimer.contains("clinical judgment"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_methodology_section() {
|
|
let generator = ReportGenerator::new();
|
|
let section = generator.build_methodology_section();
|
|
|
|
assert!(section.content.contains("METHODOLOGY"));
|
|
assert!(section.content.contains("deep learning"));
|
|
}
|
|
}
|