MediAgent / agents /report.py
medi422's picture
Upload 21 files
9a75c73 verified
# mediagent/agents/report.py
"""
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."""
# Clinical History Block
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 Block
technique = "Imaging technique not specified."
if vision:
modality = vision.modality_detected.value
quality = vision.technical_quality
technique = f"Modality: {modality} | Technical Assessment: {quality}"
# Findings Block
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)
# Differentials Block
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."""
# Extract fields with safe defaults
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."))
# Enforce exact disclaimer for regulatory compliance
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.")
# Build minimal clinically safe sections from raw data
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
)