| |
| """ |
| Report Agent for MediAgent. |
| Synthesizes outputs from Intake, Vision, and Research agents into a |
| structured, professional clinical radiology report. Follows standard |
| ACR/NICE radiological reporting conventions with strict JSON enforcement. |
| """ |
|
|
| import logging |
| from typing import Optional, Any |
|
|
| from core.llm import LLMClient |
| from core.models import IntakeOutput, ReportSection, ResearchOutput, VisionOutput |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class ReportAgent: |
| """ |
| Clinical report synthesis engine. Transforms structured agent outputs |
| into a formal radiology report suitable for clinician review. |
| """ |
|
|
| STANDARD_DISCLAIMER = ( |
| "This analysis is AI-generated and must be reviewed by a licensed radiologist " |
| "before any clinical decisions are made." |
| ) |
|
|
| SYSTEM_PROMPT = """You are a senior board-certified radiologist. Synthesize the provided findings into a formal radiology report. Return ONLY valid JSON: |
| {"clinical_history":"string","technique":"string","findings":"string","impression":"string","recommendations":"string","disclaimer":"string"} |
| |
| Section rules: |
| 1. clinical_history: Age, sex, chief complaint, relevant context — formal clinical language. |
| 2. technique: Modality, planes/sequences, contrast status, image quality. |
| 3. findings: Anatomical region-by-region description; severity levels (NORMAL/INCIDENTAL/SIGNIFICANT/CRITICAL); size, shape, density, location. |
| 4. impression: Top 3 differentials with probabilities; clinical significance; end with "Confidence Level: High/Medium/Low — [reason]". |
| 5. recommendations: Next steps based on severity and confidence (further imaging, referral, urgent eval, follow-up). |
| 6. disclaimer: MUST be exactly: "This analysis is AI-generated and must be reviewed by a licensed radiologist before any clinical decisions are made." |
| No markdown, no preamble, no extra text. Use standard radiological terminology. State "Not provided" for missing data.""" |
|
|
| def __init__(self, llm_client: Optional[LLMClient] = None): |
| self.llm = llm_client or LLMClient() |
|
|
| def process( |
| self, |
| intake: Optional[IntakeOutput] = None, |
| vision: Optional[VisionOutput] = None, |
| research: Optional[ResearchOutput] = None, |
| ) -> ReportSection: |
| """ |
| Synthesize agent outputs into a formal clinical radiology report. |
| |
| Args: |
| intake: Structured patient context and safety flags |
| vision: Imaging analysis findings and technical details |
| research: Ranked differential diagnoses and clinical correlations |
| |
| Returns: |
| ReportSection: Complete, structured radiology report |
| """ |
| logger.info("📝 Report Agent initiated report synthesis") |
| |
| user_prompt = self._build_user_prompt(intake, vision, research) |
|
|
| result = self.llm.generate_text( |
| prompt=user_prompt, |
| system_prompt=self.SYSTEM_PROMPT, |
| temperature=0.1, |
| force_json=True, |
| ) |
|
|
| if not result.get("success"): |
| logger.error(f"❌ Report generation LLM call failed: {result.get('error')}") |
| return self._get_fallback_report(intake, vision, research) |
|
|
| raw_content = result.get("content", "") |
| parsed = LLMClient.extract_json_from_response(raw_content) |
| if not parsed: |
| logger.warning("⚠️ Failed to parse report LLM JSON response. Using fallback.") |
| return self._get_fallback_report(intake, vision, research) |
|
|
| try: |
| return self._parse_report_response(parsed) |
| except Exception as e: |
| logger.error(f"💥 Report mapping failed: {e}") |
| return self._get_fallback_report(intake, vision, research) |
|
|
| def _build_user_prompt(self, intake, vision, research) -> str: |
| """Construct a highly structured prompt for the LLM synthesizer.""" |
| |
| history = "Patient information not provided." |
| if intake: |
| age = f"{intake.extracted_demographics.get('age', 'Unknown')} years" |
| sex = intake.extracted_demographics.get('sex', 'Unknown') |
| symptoms = intake.standardized_symptoms or "No symptoms reported" |
| context = intake.processing_notes or "" |
| history = f"Age: {age} | Sex: {sex} | Chief Complaint: {symptoms} | Additional Context: {context}" |
|
|
| |
| technique = "Imaging technique not specified." |
| if vision: |
| modality = vision.modality_detected.value |
| quality = vision.technical_quality |
| technique = f"Modality: {modality} | Technical Assessment: {quality}" |
|
|
| |
| findings_text = "No imaging findings available." |
| if vision and vision.findings: |
| finding_blocks = [] |
| for f in vision.findings: |
| finding_blocks.append( |
| f"- {f.anatomical_region}: {f.description} (Severity: {f.severity.value}, " |
| f"Confidence: {f.confidence.value} {f.confidence_score:.0f}%, Anomaly: {'Yes' if f.is_anomaly else 'No'})" |
| ) |
| findings_text = "\n".join(finding_blocks) |
|
|
| |
| diff_text = "No differential diagnoses generated." |
| if research and research.differential_diagnoses: |
| top_three = research.differential_diagnoses[:3] |
| diff_blocks = [] |
| for d in top_three: |
| diff_blocks.append( |
| f"{d.differential_rank}. {d.condition_name} (ICD-10: {d.icd10_code}) - " |
| f"Probability: {d.match_probability:.1f}% | Evidence: {d.supporting_evidence}" |
| ) |
| diff_text = "\n".join(diff_blocks) |
|
|
| return f"""Synthesize the following structured medical data into a formal radiology report: |
| |
| [CLINICAL HISTORY] |
| {history} |
| |
| [IMAGING TECHNIQUE] |
| {technique} |
| |
| [IMAGING FINDINGS] |
| {findings_text} |
| |
| [DIFFERENTIAL DIAGNOSES] |
| {diff_text} |
| |
| Generate the complete report following the exact JSON schema and clinical guidelines provided in the system prompt.""" |
|
|
| def _parse_report_response(self, data: dict) -> ReportSection: |
| """Validate and map LLM output to ReportSection model with safety enforcement.""" |
| |
| clinical_history = str(data.get("clinical_history", "Not provided.")) |
| technique = str(data.get("technique", "Digital advanced imaging acquisition.")) |
| findings = str(data.get("findings", "No abnormalities detected.")) |
| impression = str(data.get("impression", "Within normal limits.")) |
| recommendations = str(data.get("recommendations", "Routine follow-up as clinically indicated.")) |
| |
| |
| disclaimer = self.STANDARD_DISCLAIMER |
|
|
| return ReportSection( |
| clinical_history=clinical_history, |
| technique=technique, |
| findings=findings, |
| impression=impression, |
| recommendations=recommendations, |
| disclaimer=disclaimer |
| ) |
|
|
| def _get_fallback_report( |
| self, |
| intake: Optional[IntakeOutput], |
| vision: Optional[VisionOutput], |
| research: Optional[ResearchOutput] |
| ) -> ReportSection: |
| """Deterministic fallback report when LLM synthesis fails.""" |
| logger.warning("⚠️ Generating deterministic fallback radiology report.") |
| |
| |
| history = "Patient data available. See raw intake logs for details." |
| if intake: |
| history = f"Symptoms: {intake.standardized_symptoms} | Demographics: {intake.extracted_demographics}" |
| |
| technique = "Imaging modality unspecified due to processing limitation." |
| if vision: |
| technique = f"Modality: {vision.modality_detected.value} | Quality: {vision.technical_quality}" |
| |
| findings = "Imaging analysis incomplete. Manual radiologist review strongly recommended." |
| if vision and vision.findings: |
| findings = "; ".join([f"{f.anatomical_region}: {f.description}" for f in vision.findings]) |
| |
| impression = "Diagnostic certainty limited. Top clinical considerations based on available data:" |
| if research and research.differential_diagnoses: |
| impression += " " + ", ".join([d.condition_name for d in research.differential_diagnoses[:3]]) |
| |
| recommendations = "Urgent manual review by a licensed radiologist is required. Correlate with clinical presentation." |
| |
| return ReportSection( |
| clinical_history=history, |
| technique=technique, |
| findings=findings, |
| impression=impression, |
| recommendations=recommendations, |
| disclaimer=self.STANDARD_DISCLAIMER |
| ) |
|
|