Spaces:
Running
Running
| """ | |
| Case Analysis Agent — ReAct Tool-Calling Agent | |
| Uses tools to normalize, look up, classify, and validate patient case facts. | |
| Tool Pipeline: | |
| 1. medical_term_normalizer → normalize abbreviations, detect conditions | |
| 2. icd_procedure_lookup → get ICD codes and cost data | |
| 3. city_tier_classifier → classify hospital/city into IRDAI tiers | |
| 4. hospital_cost_estimator → validate claimed costs against benchmarks | |
| 5. LLM (Gemini Flash) → structure any remaining unstructured fields | |
| """ | |
| import json | |
| import logging | |
| import time | |
| from agents.model_router import router | |
| from models.case import CaseFacts, RoomType, AdmissionType, CityTier | |
| from tools.case_tools import ( | |
| medical_term_normalizer, icd_procedure_lookup, | |
| city_tier_classifier, hospital_cost_estimator, | |
| ) | |
| from tools.vision_tools import google_vision_ocr, is_image | |
| from tools.audit_tools import audit_trail_logger | |
| logger = logging.getLogger(__name__) | |
| # Maps for enum normalization | |
| _ROOM_TYPE_MAP = { | |
| "general": RoomType.GENERAL, "general ward": RoomType.GENERAL, "ward": RoomType.GENERAL, | |
| "semi_private": RoomType.SEMI_PRIVATE, "semi private": RoomType.SEMI_PRIVATE, | |
| "sharing": RoomType.SEMI_PRIVATE, "twin sharing": RoomType.SEMI_PRIVATE, | |
| "private": RoomType.PRIVATE, "private room": RoomType.PRIVATE, | |
| "single_ac": RoomType.SINGLE_AC, "single ac": RoomType.SINGLE_AC, | |
| "single a/c": RoomType.SINGLE_AC, "ac room": RoomType.SINGLE_AC, | |
| "deluxe": RoomType.DELUXE, "deluxe room": RoomType.DELUXE, | |
| "suite": RoomType.SUITE, "executive suite": RoomType.SUITE, | |
| "icu": RoomType.ICU, "intensive care": RoomType.ICU, "critical care": RoomType.ICU, | |
| } | |
| _ADMISSION_MAP = { | |
| "planned": AdmissionType.PLANNED, "elective": AdmissionType.PLANNED, | |
| "scheduled": AdmissionType.PLANNED, | |
| "emergency": AdmissionType.EMERGENCY, "urgent": AdmissionType.EMERGENCY, | |
| "accident": AdmissionType.EMERGENCY, | |
| } | |
| SYSTEM_PROMPT = """You are an expert hospital billing data analyst. | |
| You have been provided with: | |
| 1. NORMALIZED_INPUT: Patient case text after medical term normalization | |
| 2. PROCEDURE_DATA: ICD code and cost benchmarks for the procedure | |
| 3. CITY_TIER: IRDAI city tier classification for the hospital location | |
| 4. COST_BENCHMARK: Expected cost ranges for this procedure in this city tier | |
| YOUR TASK: Structure the raw case input into a complete CaseFacts JSON, using the tool-provided | |
| data to validate and fill in any missing fields. | |
| IMPORTANT VALIDATION RULES: | |
| - If the claimed amount deviates >50% from the benchmark, flag as "cost_anomaly: true" | |
| - If room cost per day seems unreasonable for the city tier, adjust or flag | |
| - Map all room types to valid enums: general, semi_private, private, single_ac, deluxe, suite, icu | |
| - Map admission type to: planned, emergency | |
| - Map city_tier to: tier_1, tier_2, tier_3 | |
| Return ONLY valid JSON matching CaseFacts schema.""" | |
| async def extract_case_facts(raw_input: dict) -> CaseFacts: | |
| """ | |
| Full case analysis pipeline with tool-calling: | |
| Step 1: [Tool] medical_term_normalizer → normalize procedure name and conditions | |
| Step 2: [Tool] icd_procedure_lookup → get ICD code and cost data | |
| Step 3: [Tool] city_tier_classifier → classify city/hospital into IRDAI tier | |
| Step 4: [Tool] hospital_cost_estimator → get cost benchmarks | |
| Step 5: [LLM] Structure remaining fields (with all tool context) | |
| Each step is audited. | |
| """ | |
| pipeline_start = time.time() | |
| logger.info("[CaseAgent] ▶ Starting case analysis pipeline") | |
| # === Step 0: Handle Image Input (FREE Vision API) === | |
| if raw_input.get("file_bytes") and is_image(raw_input.get("filename", "")): | |
| t0 = time.time() | |
| ocr_result = google_vision_ocr(raw_input["file_bytes"]) | |
| t1 = time.time() | |
| logger.info(f"[CaseAgent] Image detected. OCR extracted {len(ocr_result['text'])} chars") | |
| # Treat OCR text as the new raw input for subsequent tools | |
| raw_input["text_content"] = ocr_result["text"] | |
| audit_trail_logger( | |
| agent_name="CaseAgent", action="vision_ocr", | |
| input_summary=f"Image: {raw_input.get('filename')}", | |
| output_summary=f"Extracted: '{ocr_result['text'][:50]}...'", | |
| tools_used=["google_vision_ocr"], | |
| duration_ms=(t1 - t0) * 1000, | |
| ) | |
| # Pre-extract typed fields (avoid LLM for structured input) | |
| raw_procedure = str(raw_input.get("procedure", raw_input.get("diagnosis", ""))) | |
| raw_hospital = str(raw_input.get("hospital_name", raw_input.get("hospital", ""))) | |
| raw_city = str(raw_input.get("city", raw_input.get("location", ""))) | |
| raw_room = str(raw_input.get("room_type", "semi_private")) | |
| # === Step 1: Normalize medical terms === | |
| t0 = time.time() | |
| norm_result = medical_term_normalizer(raw_procedure) | |
| t1 = time.time() | |
| normalized_procedure = norm_result.get("detected_procedure") or norm_result["normalized"] | |
| detected_conditions = norm_result.get("detected_conditions", []) | |
| audit_trail_logger( | |
| agent_name="CaseAgent", action="normalize_terms", | |
| input_summary=f"Raw procedure: '{raw_procedure}'", | |
| output_summary=f"Normalized: '{normalized_procedure}', conditions: {detected_conditions}", | |
| tools_used=["medical_term_normalizer"], | |
| duration_ms=(t1 - t0) * 1000, | |
| ) | |
| # === Step 2: ICD procedure lookup === | |
| t0 = time.time() | |
| icd_result = icd_procedure_lookup(normalized_procedure) | |
| t1 = time.time() | |
| audit_trail_logger( | |
| agent_name="CaseAgent", action="icd_lookup", | |
| input_summary=f"Procedure: '{normalized_procedure}'", | |
| output_summary=f"Found: {icd_result['found']}, ICD: {icd_result['procedure']['icd_code'] if icd_result['found'] and icd_result['procedure'] else 'N/A'}", | |
| tools_used=["icd_procedure_lookup"], | |
| duration_ms=(t1 - t0) * 1000, | |
| ) | |
| # === Step 3: City tier classification === | |
| city_input = raw_city or raw_hospital | |
| t0 = time.time() | |
| tier_result = city_tier_classifier(city_input) if city_input else { | |
| "tier": "tier_1", "confidence": "low", "reasoning": "No city/hospital provided, defaulting to Tier 1" | |
| } | |
| t1 = time.time() | |
| audit_trail_logger( | |
| agent_name="CaseAgent", action="classify_city_tier", | |
| input_summary=f"Input: '{city_input}'", | |
| output_summary=f"Tier: {tier_result['tier']}, confidence: {tier_result['confidence']}", | |
| tools_used=["city_tier_classifier"], | |
| duration_ms=(t1 - t0) * 1000, | |
| ) | |
| # === Step 4: Cost estimation === | |
| t0 = time.time() | |
| cost_result = hospital_cost_estimator( | |
| procedure=normalized_procedure, | |
| room_type=raw_room, | |
| city_tier=tier_result["tier"], | |
| stay_days=raw_input.get("stay_duration_days"), | |
| ) | |
| t1 = time.time() | |
| audit_trail_logger( | |
| agent_name="CaseAgent", action="estimate_costs", | |
| input_summary=f"Procedure: '{normalized_procedure}', room: {raw_room}, tier: {tier_result['tier']}", | |
| output_summary=f"Estimated total: ₹{cost_result['estimated_total']['median']:,.0f} " | |
| f"(range: ₹{cost_result['estimated_total']['low']:,.0f}-₹{cost_result['estimated_total']['high']:,.0f})", | |
| tools_used=["hospital_cost_estimator"], | |
| duration_ms=(t1 - t0) * 1000, | |
| ) | |
| # === Step 5: Attempt direct structuring first (avoid LLM if input is already structured) === | |
| t0 = time.time() | |
| try: | |
| facts = _build_facts_from_structured(raw_input, norm_result, icd_result, tier_result, cost_result) | |
| method = "direct_structuring" | |
| except Exception as e: | |
| logger.info(f"[CaseAgent] Direct structuring failed ({e}), using LLM") | |
| facts = await _build_facts_via_llm(raw_input, norm_result, icd_result, tier_result, cost_result) | |
| method = "llm_structuring" | |
| t1 = time.time() | |
| audit_trail_logger( | |
| agent_name="CaseAgent", action="structure_facts", | |
| input_summary=f"Method: {method}", | |
| output_summary=f"CaseFacts: {facts.procedure}, ₹{facts.total_claimed_amount:,.0f}, " | |
| f"{facts.room_type.value}, {facts.city_tier.value}", | |
| tools_used=[method + (" (model_router)" if method == "llm_structuring" else "")], | |
| duration_ms=(t1 - t0) * 1000, | |
| ) | |
| # Cost anomaly check | |
| claimed = facts.total_claimed_amount | |
| benchmark_median = cost_result["estimated_total"]["median"] | |
| if benchmark_median > 0 and abs(claimed - benchmark_median) / benchmark_median > 0.5: | |
| logger.warning( | |
| f"[CaseAgent] ⚠ Cost anomaly: claimed ₹{claimed:,.0f} vs benchmark ₹{benchmark_median:,.0f} " | |
| f"(deviation: {abs(claimed - benchmark_median)/benchmark_median*100:.0f}%)" | |
| ) | |
| pipeline_end = time.time() | |
| total_ms = (pipeline_end - pipeline_start) * 1000 | |
| audit_trail_logger( | |
| agent_name="CaseAgent", action="pipeline_complete", | |
| input_summary=f"Raw input with {len(raw_input)} fields", | |
| output_summary=f"CaseFacts ready: {facts.procedure}, ₹{facts.total_claimed_amount:,.0f}", | |
| tools_used=["medical_term_normalizer", "icd_procedure_lookup", "city_tier_classifier", | |
| "hospital_cost_estimator", method], | |
| duration_ms=total_ms, | |
| ) | |
| logger.info(f"[CaseAgent] ✓ Pipeline complete in {total_ms:.0f}ms") | |
| return facts | |
| def _build_facts_from_structured( | |
| raw: dict, norm: dict, icd: dict, tier: dict, cost: dict | |
| ) -> CaseFacts: | |
| """ | |
| Build CaseFacts directly from structured input + tool outputs (pure local logic, zero LLM). | |
| Handles missing fields gracefully with sensible defaults and tool-provided data. | |
| """ | |
| # === Room Type (with normalization) === | |
| room_key = str(raw.get("room_type", "semi_private")).lower().replace("-", "_").replace(" ", "_") | |
| room_type = _ROOM_TYPE_MAP.get(room_key, RoomType.SEMI_PRIVATE) | |
| # === Admission Type (with normalization) === | |
| admission_key = str(raw.get("admission_type", "planned")).lower() | |
| admission_type = _ADMISSION_MAP.get(admission_key, AdmissionType.PLANNED) | |
| # === City Tier (from classifier tool or raw input) === | |
| tier_key = tier.get("tier", "tier_1") | |
| city_tier = CityTier(tier_key) | |
| # === Procedure (prefer detected, then from raw input) === | |
| procedure = norm.get("detected_procedure") or str(raw.get("procedure", raw.get("diagnosis", "Unknown Procedure"))).strip() | |
| # === Pre-existing Conditions (combine raw + detected) === | |
| conditions = list(set( | |
| [str(c).strip() for c in raw.get("pre_existing_conditions", []) if c] + | |
| norm.get("detected_conditions", []) | |
| )) | |
| # === Room Cost Per Day (prefer raw, fall back to cost estimator) === | |
| room_cost = None | |
| if "room_cost_per_day" in raw and raw["room_cost_per_day"]: | |
| room_cost = float(raw["room_cost_per_day"]) | |
| else: | |
| room_cost = float(cost.get("room_cost_per_day", 4000)) | |
| # === Stay Duration (prefer raw, fall back to cost estimator) === | |
| stay_days = None | |
| if "stay_duration_days" in raw and raw["stay_duration_days"]: | |
| stay_days = int(raw["stay_duration_days"]) | |
| else: | |
| stay_days = int(cost.get("stay_days", 3)) | |
| # === Procedure Cost (optional, may be part of total_claimed_amount) === | |
| proc_cost = None | |
| if "procedure_cost" in raw and raw["procedure_cost"]: | |
| try: | |
| proc_cost = float(raw["procedure_cost"]) | |
| except (ValueError, TypeError): | |
| pass | |
| # === Total Claimed Amount (calculate if not provided) === | |
| total = 0.0 | |
| if "total_claimed_amount" in raw and raw["total_claimed_amount"]: | |
| total = float(raw["total_claimed_amount"]) | |
| else: | |
| # Calculate from components if not provided | |
| room_component = room_cost * stay_days | |
| procedure_component = proc_cost if proc_cost else 0 | |
| total = max(room_component + procedure_component, 0) | |
| # Fallback: use cost estimator median if total is zero | |
| if total <= 0: | |
| total = cost.get("estimated_total", {}).get("median", 100000) | |
| # === Optional Fields === | |
| patient_name = raw.get("patient_name") or raw.get("patient") or None | |
| patient_age = None | |
| if "patient_age" in raw and raw["patient_age"]: | |
| try: | |
| patient_age = int(raw["patient_age"]) | |
| except (ValueError, TypeError): | |
| pass | |
| policy_start_date = raw.get("policy_start_date") or raw.get("policy_inception_date") or None | |
| policy_tenure_years = 1 | |
| if "policy_tenure_years" in raw and raw["policy_tenure_years"]: | |
| try: | |
| policy_tenure_years = int(raw["policy_tenure_years"]) | |
| except (ValueError, TypeError): | |
| policy_tenure_years = 1 | |
| is_renewal = raw.get("is_renewal", False) | |
| hospital_name = raw.get("hospital_name") or raw.get("hospital") or None | |
| logger.info(f"[CaseAgent] ✓ Built CaseFacts from structured data: {procedure}, ₹{total:,.0f}, {city_tier.value}") | |
| return CaseFacts( | |
| patient_name=patient_name, | |
| patient_age=patient_age, | |
| room_type=room_type, | |
| room_cost_per_day=room_cost, | |
| stay_duration_days=stay_days, | |
| admission_type=admission_type, | |
| procedure=procedure, | |
| procedure_cost=proc_cost, | |
| pre_existing_conditions=conditions, | |
| policy_start_date=policy_start_date, | |
| policy_tenure_years=policy_tenure_years, | |
| is_renewal=is_renewal, | |
| city_tier=city_tier, | |
| hospital_name=hospital_name, | |
| total_claimed_amount=total, | |
| ) | |
| async def _build_facts_via_llm( | |
| raw: dict, norm: dict, icd: dict, tier: dict, cost: dict | |
| ) -> CaseFacts: | |
| """Fallback: use LLM to structure ambiguous or text-heavy input.""" | |
| user_prompt = f"""Structure the following patient case data into a valid CaseFacts JSON. | |
| RAW INPUT: {json.dumps(raw, indent=2, default=str)} | |
| TOOL-PROVIDED CONTEXT: | |
| - Normalized Procedure: {norm.get('detected_procedure', norm['normalized'])} | |
| - Detected Conditions: {norm.get('detected_conditions', [])} | |
| - ICD Procedure Match: {json.dumps(icd.get('procedure', {}), default=str)[:500]} | |
| - City Tier: {tier.get('tier', 'tier_1')} (confidence: {tier.get('confidence', 'low')}) | |
| - Cost Benchmark (median): ₹{cost['estimated_total']['median']:,.0f} | |
| - Room Cost/Day Benchmark: ₹{cost.get('room_cost_per_day', 'N/A')} | |
| Required fields: room_type (enum), room_cost_per_day, stay_duration_days, admission_type (enum), | |
| procedure, city_tier (enum), total_claimed_amount. | |
| Return ONLY valid JSON.""" | |
| result = await router.call_json( | |
| role="case_analysis", | |
| system_prompt=SYSTEM_PROMPT, | |
| user_prompt=user_prompt, | |
| temperature=0.05, | |
| ) | |
| return CaseFacts(**result) | |