| |
| """ |
| Critic Agent for MediAgent. |
| Final quality control and peer-review layer. Validates report consistency, |
| flags low-confidence observations, enforces regulatory disclaimers, assigns |
| quality scores, and applies corrective refinements before final delivery. |
| """ |
|
|
| import logging |
| from typing import Any, Dict, List, Optional |
|
|
| from core.llm import LLMClient |
| from core.models import AgentStatus, PipelineState, ReportSection, VisionOutput |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class CriticAgent: |
| """ |
| Medical QA/QC engine. Cross-validates the synthesized report against |
| upstream agent outputs, detects logical inconsistencies, enforces |
| clinical safety thresholds, and produces a finalized, auditable report. |
| """ |
|
|
| 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 radiology peer-reviewer. Evaluate the draft report against the vision findings and return ONLY valid JSON: |
| {"clinical_history":"string","technique":"string","findings":"string","impression":"string","recommendations":"string","disclaimer":"string","quality_score":0-100,"review_issues":["string"],"uncertainty_warnings":["string"]} |
| |
| Review criteria: |
| 1. CONSISTENCY: Every vision anomaly must appear in the report. Flag contradictions. |
| 2. CONFIDENCE: Findings < 50% confidence or LOW → add uncertainty warning, recommend confirmatory imaging. |
| 3. DISCLAIMER: Must be exactly: "This analysis is AI-generated and must be reviewed by a licensed radiologist before any clinical decisions are made." |
| 4. TONE: Formal, objective radiological language. No speculative phrasing or definitive claims without imaging evidence. |
| 5. QUALITY SCORE: completeness 30% + accuracy/consistency 40% + clinical safety 20% + compliance 10%. |
| 6. Apply corrections directly. No placeholders or TODOs. |
| No markdown. Never fabricate findings. Lower score for failed pipeline agents.""" |
|
|
| def __init__(self, llm_client: Optional[LLMClient] = None): |
| self.llm = llm_client or LLMClient() |
| self.last_quality_score: int = 100 |
| self.last_review_issues: List[str] = [] |
| self.last_uncertainty_warnings: List[str] = [] |
|
|
| def process(self, draft_report: ReportSection, pipeline_state: PipelineState) -> ReportSection: |
| """ |
| Execute final peer review and quality enforcement. |
| |
| Args: |
| draft_report: Unreviewed ReportSection from Report Agent |
| pipeline_state: Full execution context including vision findings and agent statuses |
| |
| Returns: |
| ReportSection: Finalized, QA-reviewed clinical report |
| """ |
| logger.info("🛡️ Critic Agent initiated final quality review") |
| |
| user_prompt = self._build_review_prompt(draft_report, pipeline_state) |
| |
| result = self.llm.generate_text( |
| prompt=user_prompt, |
| system_prompt=self.SYSTEM_PROMPT, |
| temperature=0.0, |
| force_json=True |
| ) |
|
|
| if not result.get("success"): |
| logger.error(f"❌ Critic LLM call failed: {result.get('error')}") |
| return self._apply_deterministic_qa(draft_report, pipeline_state) |
|
|
| raw_content = result.get("content", "") |
| parsed = LLMClient.extract_json_from_response(raw_content) |
| if not parsed: |
| logger.warning("⚠️ Failed to parse critic JSON response. Applying deterministic QA.") |
| return self._apply_deterministic_qa(draft_report, pipeline_state) |
|
|
| try: |
| return self._parse_qa_response(parsed, pipeline_state) |
| except Exception as e: |
| logger.error(f"💥 Critic mapping failed: {e}") |
| return self._apply_deterministic_qa(draft_report, pipeline_state) |
|
|
| def _build_review_prompt(self, draft: ReportSection, state: PipelineState) -> str: |
| """Format pipeline context and draft report for LLM critique.""" |
| |
| vision_text = "No vision findings available." |
| if state.vision_output: |
| findings_list = [] |
| for f in state.vision_output.findings: |
| findings_list.append( |
| f"- Region: {f.anatomical_region} | Desc: {f.description} | " |
| f"Severity: {f.severity.value} | Confidence: {f.confidence.value} ({f.confidence_score}%) | " |
| f"Anomaly: {f.is_anomaly}" |
| ) |
| vision_text = "\n".join(findings_list) if findings_list else "No specific findings." |
|
|
| |
| agent_status = ", ".join([f"{k}: {v.value}" for k, v in state.agent_statuses.items()]) |
|
|
| return f"""[PIPELINE EXECUTION STATUS] |
| {agent_status} |
| |
| [VISION AGENT FINDINGS FOR CROSS-REFERENCE] |
| {vision_text} |
| |
| [DRAFT REPORT FOR REVIEW] |
| Clinical History: {draft.clinical_history} |
| Technique: {draft.technique} |
| Findings: {draft.findings} |
| Impression: {draft.impression} |
| Recommendations: {draft.recommendations} |
| Disclaimer: {draft.disclaimer} |
| |
| Critique the draft against the vision findings and pipeline status. Apply corrections, flag uncertainties, verify compliance, and output the refined JSON report.""" |
|
|
| def _parse_qa_response(self, data: Dict[str, Any], state: PipelineState) -> ReportSection: |
| """Validate, extract, and enforce QA metadata on the report.""" |
| |
| draft = ReportSection( |
| clinical_history=str(data.get("clinical_history", "Not provided.")), |
| technique=str(data.get("technique", "Imaging technique not specified.")), |
| 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=str(data.get("disclaimer", self.STANDARD_DISCLAIMER)) |
| ) |
|
|
| |
| self.last_quality_score = int(data.get("quality_score", 85)) |
| self.last_review_issues = data.get("review_issues", []) |
| self.last_uncertainty_warnings = data.get("uncertainty_warnings", []) |
|
|
| |
| qa_summary = "\n\n[QUALITY ASSESSMENT]\nScore: {score}/100\nIssues: {issues}\nUncertainties: {warnings}".format( |
| score=self.last_quality_score, |
| issues=" | ".join(self.last_review_issues) if self.last_review_issues else "None", |
| warnings=" | ".join(self.last_uncertainty_warnings) if self.last_uncertainty_warnings else "None" |
| ) |
| draft.recommendations += qa_summary |
|
|
| return self._apply_deterministic_qa(draft, state) |
|
|
| def _apply_deterministic_qa(self, draft: ReportSection, state: PipelineState) -> ReportSection: |
| """Hard-rule safety checks that cannot be overridden by LLM output.""" |
| |
| if draft.disclaimer != self.STANDARD_DISCLAIMER: |
| draft.disclaimer = self.STANDARD_DISCLAIMER |
| if self.last_review_issues: |
| self.last_review_issues.append("Disclaimer corrected to regulatory standard.") |
| else: |
| self.last_review_issues = ["Disclaimer corrected to regulatory standard."] |
|
|
| |
| critical_failures = [ |
| k for k, v in state.agent_statuses.items() |
| if v == AgentStatus.ERROR and k in ["VISION", "REPORT"] |
| ] |
| if critical_failures: |
| self.last_quality_score = min(self.last_quality_score, 40) |
| self.last_review_issues.append(f"Pipeline degraded: {', '.join(critical_failures)} agents failed.") |
|
|
| |
| if state.vision_output: |
| low_conf_findings = [ |
| f.anatomical_region for f in state.vision_output.findings |
| if f.confidence.value == "LOW" or f.confidence_score < 50.0 |
| ] |
| if low_conf_findings: |
| warning = f"Low confidence observations in: {', '.join(low_conf_findings)}. Confirmatory imaging recommended." |
| if warning not in self.last_uncertainty_warnings: |
| self.last_uncertainty_warnings.append(warning) |
|
|
| |
| qa_summary = "\n\n[QUALITY ASSESSMENT]\nScore: {score}/100\nIssues: {issues}\nUncertainties: {warnings}".format( |
| score=self.last_quality_score, |
| issues=" | ".join(self.last_review_issues) if self.last_review_issues else "None", |
| warnings=" | ".join(self.last_uncertainty_warnings) if self.last_uncertainty_warnings else "None" |
| ) |
| if qa_summary not in draft.recommendations: |
| draft.recommendations += qa_summary |
|
|
| logger.info(f"✅ Critic Agent completed | QA Score: {self.last_quality_score}/100") |
| return draft |
|
|
| def _get_fallback_report(self, draft: ReportSection) -> ReportSection: |
| """Safe fallback when critic review cannot be completed.""" |
| draft.disclaimer = self.STANDARD_DISCLAIMER |
| self.last_quality_score = 50 |
| self.last_review_issues = ["Critic agent unavailable. Report delivered unreviewed."] |
| self.last_uncertainty_warnings = ["Peer review skipped. Manual radiologist verification mandatory."] |
| return self._apply_deterministic_qa(draft, PipelineState()) |
|
|