""" structured_output.py — Build the final structured API response (Task Group D). Takes outputs from ALL pipeline stages and produces a single, clean, frontend-friendly JSON structure. This is the "final word" — the frontend can render from this one object without needing priority fallback logic. Input sources: - Classifier result (YOLO-cls top-5) - Rule engine result (scored candidates, conflicts, rejections) - Reasoning engine result (diagnosis, chain, differential) - LLM validation (agree/disagree, agreement score, scenario) - Confidence fusion (weighted multi-signal confidence) Output: A single dict with clean sections: - diagnosis: disease name + fused confidence + grade - health: score + risk + urgency + yield loss - confidence_breakdown: per-source scores + weights - reasoning: step-by-step chain - evidence: supporting + contradicting features - rejected: diseases ruled out with reasons - differential: alternative diagnoses - treatment: recommendations + urgency + products - metadata: models used, agreement, timing, version """ from __future__ import annotations from loguru import logger # ════════════════════════════════════════════════════════════════ # Confidence grading # ════════════════════════════════════════════════════════════════ def _confidence_grade(conf: float) -> str: """Map a 0.0–1.0 confidence to a human-readable grade.""" if conf >= 0.90: return "VERY_HIGH" if conf >= 0.75: return "HIGH" if conf >= 0.55: return "MODERATE" if conf >= 0.35: return "LOW" return "UNCERTAIN" # ════════════════════════════════════════════════════════════════ # Main builder # ════════════════════════════════════════════════════════════════ def build_structured_output( classifier_result: dict | None, reasoning_result: dict | None, llm_validation_dict: dict | None, confidence_fusion: dict | None, ensemble: dict | None, processing_time_ms: float = 0, *, gradcam_data: dict | None = None, research_papers: list[dict] | None = None, ensemble_voting: dict | None = None, temporal_data: dict | None = None, ) -> dict: """Build the final structured output from all pipeline signals. All inputs are plain dicts (already serialized from dataclasses). Returns a clean dict ready for JSON response. """ # ── 1. Diagnosis: pick the best source ── diagnosis = _build_diagnosis(reasoning_result, llm_validation_dict, confidence_fusion, classifier_result) # ── 2. Health ── health = _build_health(reasoning_result, llm_validation_dict, ensemble) # ── 3. Confidence breakdown ── confidence = _build_confidence_breakdown( classifier_result, reasoning_result, llm_validation_dict, confidence_fusion ) # ── 4. Reasoning chain ── reasoning_chain = [] if reasoning_result and reasoning_result.get("reasoning_chain"): reasoning_chain = reasoning_result["reasoning_chain"] # ── 5. Evidence ── evidence = _build_evidence(reasoning_result) # ── 6. Rejected diagnoses ── rejected = _build_rejected(reasoning_result) # ── 7. Differential ── differential = [] if reasoning_result and reasoning_result.get("differential_diagnosis"): differential = reasoning_result["differential_diagnosis"] # ── 8. Treatment ── treatment = _build_treatment(reasoning_result, llm_validation_dict) # ── 9. Metadata ── models_used = ensemble.get("models_used", []) if ensemble else [] model_agreement = ensemble.get("model_agreement", "none") if ensemble else "none" metadata = { "models_used": models_used, "model_agreement": model_agreement, "processing_time_ms": round(processing_time_ms, 0), "pipeline_version": "3.0", "ensemble_note": ensemble.get("note", "") if ensemble else "", } result = { "diagnosis": diagnosis, "health": health, "confidence_breakdown": confidence, "reasoning_chain": reasoning_chain, "evidence": evidence, "rejected_diagnoses": rejected, "differential_diagnosis": differential, "treatment": treatment, "metadata": metadata, } # ── AI Validation (LLaVA) ── if llm_validation_dict: result["ai_validation"] = { "agrees": llm_validation_dict.get("agrees"), "agreement_score": llm_validation_dict.get("agreement_score"), "llm_diagnosis": llm_validation_dict.get("llm_diagnosis"), "scenario": llm_validation_dict.get("scenario"), "reasoning_text": llm_validation_dict.get("reasoning_text", ""), "health_score": llm_validation_dict.get("health_score"), "risk_level": llm_validation_dict.get("risk_level"), "model": "LLaVA", } # ── F1: Grad-CAM heatmap ── if gradcam_data: result["gradcam"] = gradcam_data # ── F2: Research papers ── if research_papers: result["research_papers"] = research_papers # ── F3: Ensemble voting details ── if ensemble_voting: result["ensemble_voting"] = ensemble_voting # ── F4: Temporal tracking ── if temporal_data: result["temporal"] = temporal_data # ── F5: Spectral analysis ── if reasoning_result and reasoning_result.get("spectral_analysis"): result["spectral_analysis"] = reasoning_result["spectral_analysis"] return result # ════════════════════════════════════════════════════════════════ # Section builders # ════════════════════════════════════════════════════════════════ def _build_diagnosis( reasoning: dict | None, llm_val: dict | None, fusion: dict | None, classifier: dict | None, ) -> dict: """Build the diagnosis section — single best answer.""" # Primary confidence comes from fusion if available fused_conf = fusion.get("fused_confidence", 0) if fusion else None # Disease name from reasoning engine (most reliable) if reasoning and reasoning.get("disease_key"): disease_key = reasoning["disease_key"] disease_name = reasoning.get("disease_name", disease_key) base_conf = reasoning.get("confidence", 0) elif classifier: disease_key = classifier.get("top5", [{}])[0].get("class_key", "unknown") disease_name = classifier.get("top_prediction", "Unknown") base_conf = classifier.get("top_confidence", 0) else: disease_key = "unknown" disease_name = "Unknown" base_conf = 0 # Use fused confidence if available, otherwise fall back to reasoning confidence final_conf = fused_conf if fused_conf is not None else base_conf # LLM validation enrichment llm_agrees = None llm_alt_diagnosis = None if llm_val: llm_agrees = llm_val.get("agrees") if not llm_agrees and llm_val.get("llm_diagnosis"): llm_alt_diagnosis = llm_val["llm_diagnosis"] return { "disease_key": disease_key, "disease_name": disease_name, "confidence": round(final_conf, 3), "confidence_grade": _confidence_grade(final_conf), "is_healthy": disease_key.startswith("healthy"), "llm_agrees": llm_agrees, "llm_alt_diagnosis": llm_alt_diagnosis, } def _build_health( reasoning: dict | None, llm_val: dict | None, ensemble: dict | None, ) -> dict: """Build the health section.""" # Priority: ensemble > reasoning > llm > default if ensemble and ensemble.get("ensemble_health_score") is not None: score = ensemble["ensemble_health_score"] risk = ensemble.get("ensemble_risk_level", "medium") elif reasoning: score = reasoning.get("health_score", 50) risk = reasoning.get("risk_level", "medium") elif llm_val: score = llm_val.get("health_score", 50) risk = llm_val.get("risk_level", "medium") else: score = 50 risk = "medium" urgency = "within_30_days" yield_loss = None affected_parts = [] if reasoning: urgency = reasoning.get("urgency", "within_30_days") yield_loss = reasoning.get("yield_loss") affected_parts = reasoning.get("affected_parts", []) return { "score": score, "risk_level": risk, "urgency": urgency, "yield_loss": yield_loss, "affected_parts": affected_parts, } def _build_confidence_breakdown( classifier: dict | None, reasoning: dict | None, llm_val: dict | None, fusion: dict | None, ) -> dict: """Build per-source confidence breakdown.""" sources = [] # Classifier source if classifier: cls_disease = classifier.get("top_prediction", "Unknown") cls_conf = classifier.get("top_confidence", 0) sources.append({ "source": "classifier", "label": "YOLO-CLS", "disease": cls_disease, "score": round(cls_conf, 3), "weight": fusion.get("weights", {}).get("classifier", 0.20) if fusion else 0.20, }) # Rule engine source if reasoning: re_disease = reasoning.get("disease_name", "Unknown") re_conf = reasoning.get("confidence", 0) sources.append({ "source": "rule_engine", "label": "Rule Engine", "disease": re_disease, "score": round(re_conf, 3), "weight": fusion.get("weights", {}).get("rule", 0.50) if fusion else 0.50, }) # LLM validator source if llm_val: llm_diag = llm_val.get("llm_diagnosis", "Unknown") llm_score = llm_val.get("agreement_score", 0) sources.append({ "source": "llm_validator", "label": "LLM Validator", "disease": llm_diag, "score": round(llm_score, 3), "agrees": llm_val.get("agrees", False), "scenario": llm_val.get("scenario", ""), "weight": fusion.get("weights", {}).get("llm", 0.30) if fusion else 0.30, }) # Fused result fused = fusion.get("fused_confidence", 0) if fusion else None return { "sources": sources, "fused_confidence": round(fused, 3) if fused is not None else None, "fusion_note": fusion.get("note", "") if fusion else "No fusion available", } def _build_evidence(reasoning: dict | None) -> dict: """Build evidence section from reasoning result.""" supporting = [] contradicting = [] if reasoning: # Symptoms matched = supporting evidence for s in reasoning.get("symptoms_matched", []): supporting.append(s) # Symptoms detected but not matched = observational for s in reasoning.get("symptoms_detected", []): if s not in supporting: supporting.append(s) # Conflict info → contradicting if reasoning.get("conflict"): c = reasoning["conflict"] if c.get("winner") == "rules": contradicting.append( f"Classifier predicted {c.get('yolo_prediction', 'other disease')} " f"({c.get('yolo_confidence', 0):.0%}) but visual evidence contradicts this" ) return { "supporting": supporting, "contradicting": contradicting, } def _build_rejected(reasoning: dict | None) -> list: """Build rejected diagnoses list.""" if not reasoning or not reasoning.get("rejections"): return [] rejected = [] for r in reasoning["rejections"]: entry = { "disease": r.get("disease", "Unknown"), "reasons": r.get("reasons", []), } if r.get("missing_features"): entry["missing_features"] = r["missing_features"] if r.get("contradicting_features"): entry["contradicting_features"] = r["contradicting_features"] rejected.append(entry) return rejected def _build_treatment( reasoning: dict | None, llm_val: dict | None, ) -> dict: """Build treatment recommendations.""" recommendations = [] urgency = "within_30_days" if reasoning: recommendations = reasoning.get("treatment", []) urgency = reasoning.get("urgency", "within_30_days") # LLM may add complementary recommendations llm_recs = [] if llm_val and llm_val.get("recommendations"): llm_recs = llm_val["recommendations"] # Only add LLM recs that aren't already covered existing_lower = {r.lower() for r in recommendations} for rec in llm_recs: if rec.lower() not in existing_lower: recommendations.append(rec) return { "recommendations": recommendations, "urgency": urgency, "urgency_display": urgency.replace("_", " ").title(), }