secureshield-backend / agents /case_agent.py
Sharma7's picture
Clean up temporary artifacts and finalize backend updates
70bffd5
Raw
History Blame Contribute Delete
14.7 kB
"""
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)