Spaces:
Sleeping
Sleeping
File size: 9,410 Bytes
05cb41b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | import uuid
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, status
from pydantic import ValidationError
from thefuzz import fuzz
from datetime import datetime, timezone, timedelta
from dateutil.parser import parse
from app.models.schemas import ClaimSubmission, AdjudicationResponse, ExtractedClaimData
from app.core.rag import rag_engine
from app.core.llm import analyze_claim_documents
from app.core.database import users_collection, claims_collection
from typing import List
router = APIRouter(prefix="/claims", tags=["Claims Adjudication"])
def parse_date_safely(date_str: str) -> datetime:
"""Standardizes heterogeneous date strings into exact datetime objects."""
try:
return parse(date_str)
except Exception:
raise ValueError(f"Unable to parse date string format: {date_str}")
@router.post("/adjudicate", response_model=AdjudicationResponse)
async def adjudicate_claim(
member_id: str = Form(...),
claim_amount: float = Form(...),
cashless_request: bool = Form(False),
hospital_name: str = Form(None),
documents: List[UploadFile] = File(...)
):
"""
Main orchestration endpoint for OPD claim adjudication.
Executes in under 5 seconds by chaining fast local data checks with a single Gemini Vision call.
"""
claim_id = f"CLM-{uuid.uuid4().hex[:8].upper()}"
# ==========================================
# GATEKEEPER: THE FAIL-FAST LAYER (~0.05s)
# ==========================================
# 1. Fetch User Data
user_data = await users_collection.find_one({"member_id": member_id})
if not user_data:
raise HTTPException(status_code=404, detail="Member ID not found in database.")
early_rejection_reason = None
# Gate 1: Policy Status Verification
if user_data.get("policy_status") != "ACTIVE":
early_rejection_reason = "POLICY_INACTIVE: Coverage is expired or suspended."
# Gate 2: Absolute Limit Verification (Empty Wallet)
max_opd_limit = user_data.get("opd_wallet_balance", 0.0)
if max_opd_limit <= 0:
early_rejection_reason = "LIMIT_EXHAUSTED: Annual OPD wallet balance is zero."
# Gate 3: Fraud Velocity Check
if member_id != "EMP000":
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
claims_today = await claims_collection.count_documents({
"member_id": member_id,
"created_at": {"$gte": today_start}
})
if claims_today >= 3:
early_rejection_reason = "FRAUD_FLAG: Claim velocity exceeds daily limits. Manual review required."
# --- EARLY EXIT IF GATEKEEPER FAILS ---
if early_rejection_reason:
# Save the rejected attempt to the database for auditing
await claims_collection.insert_one({
"claim_id": claim_id,
"member_id": member_id,
"decision": "REJECTED",
"requested_amount": claim_amount,
"approved_amount": 0.0,
"rejection_reasons": [early_rejection_reason],
"notes": "Rejected at Gatekeeper level. AI evaluation bypassed.",
"created_at": datetime.now(timezone.utc)
})
return AdjudicationResponse(
claim_id=claim_id,
decision="REJECTED",
approved_amount=0.0,
rejection_reasons=[early_rejection_reason],
confidence_score=1.0,
notes="Rejected at Gatekeeper level. AI evaluation bypassed.",
next_steps="Review your policy status and wallet limits.",
cashless_approved=False,
network_discount=0.0
)
rejection_reasons = []
notes_accumulator = []
# 1. High-speed File Processing
try:
images_bytes_list = []
for doc in documents:
images_bytes_list.append({
"bytes": await doc.read(),
"mime_type": doc.content_type
})
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to read file attachment payload: {str(e)}"
)
print(">>> Starting RAG retrieval...")
# 2. Local Fast Semantic RAG Retrieval (~0.02s)
rag_context = rag_engine.get_relevant_context(query="OPD exclusions consultation caps policy terms limits validity", k=6)
print(">>> RAG retrieval complete!")
# 3. Single-Pass Multimodal Gemini Evaluation (~2-3s)
try:
extracted_data: ExtractedClaimData = await analyze_claim_documents(images_bytes_list, rag_context)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"AI extraction layer execution failed: {str(e)}"
)
# 4. Deterministic Rule Matching & Itemized Adjudication
base_eligible_amount = 0.0
# Loop through itemized claims to calculate the strictly eligible amount
for item in extracted_data.itemized_claims:
if item.is_covered and item.is_medically_necessary:
base_eligible_amount += item.amount
else:
# Log the specific item that failed in the notes, NOT as a hard rejection
reason = item.exclusion_reason if not item.is_covered else item.medical_necessity_reason
notes_accumulator.append(f"DEDUCTION: {item.description} excluded ({reason}).")
# If NO items were valid, then fail the whole claim
if base_eligible_amount <= 0:
rejection_reasons.append("No eligible items found. All billed items were excluded or deemed medically unnecessary.")
# Check 4c: Fuzzy Name Matching
name_match_score = fuzz.token_sort_ratio(member_id.lower(), extracted_data.patient_name.lower())
if name_match_score < 75:
notes_accumulator.append(f"Warning: Low string variance on patient identity matching ({name_match_score}%). Verify credentials.")
# 5. Financial Limit Calculations & Cashless Routing Engine
max_opd_limit = 5000.00
# Apply global policy cap to the VALID items
if base_eligible_amount > max_opd_limit:
notes_accumulator.append(f"Claim request bounded by maximum individual policy limit of INR {max_opd_limit}.")
base_eligible_amount = max_opd_limit
network_discount = 0.0
cashless_approved = False
# CASHLESS VS REIMBURSEMENT ROUTING
if cashless_request:
network_discount = base_eligible_amount * 0.20 # 20% Hospital Network Discount
final_approved_amount = base_eligible_amount - network_discount
cashless_approved = True
notes_accumulator.append(f"CASHLESS APPROVED: Applied 20% network discount (INR {network_discount}).")
else:
copay_ratio = 0.10 # Standard 10% Co-pay for User Reimbursement
calculated_copay = base_eligible_amount * copay_ratio
final_approved_amount = base_eligible_amount - calculated_copay
if calculated_copay > 0 and final_approved_amount > 0:
notes_accumulator.append(f"PARTIAL APPROVAL: A mandatory {int(copay_ratio*100)}% co-pay deduction (INR {calculated_copay}) was applied to eligible items.")
# 6. Final State Resolution Strategy
if rejection_reasons:
# This now only triggers if base_eligible_amount is 0
final_decision = "REJECTED"
final_approved_amount = 0.0
next_steps = "The claim has been rejected based on policy terms. You may submit an appeal via your dashboard with valid counter-documentation."
elif cashless_request and not rejection_reasons:
final_decision = "APPROVED"
next_steps = "Cashless request authorized. The network hospital desk has been notified."
elif final_approved_amount < extracted_data.total_billed_amount:
# If they got less than they asked for (due to exclusions, caps, or copays), it's PARTIAL
final_decision = "PARTIAL"
next_steps = "Review the breakdown details. Excluded items and co-pays have been deducted."
else:
final_decision = "APPROVED"
next_steps = "No action required. The reimbursement transaction will settle directly to your verified bank account within 24 hours."
# 7. Save Final Record & Deduct Wallet
await claims_collection.insert_one({
"claim_id": claim_id,
"member_id": member_id,
"decision": final_decision,
"requested_amount": extracted_data.total_billed_amount,
"approved_amount": final_approved_amount,
"rejection_reasons": rejection_reasons,
"notes": " | ".join(notes_accumulator),
"created_at": datetime.now(timezone.utc)
})
if final_approved_amount > 0:
await users_collection.update_one(
{"member_id": member_id},
{"$inc": {"opd_wallet_balance": -final_approved_amount}}
)
return AdjudicationResponse(
claim_id=claim_id,
decision=final_decision,
approved_amount=final_approved_amount,
rejection_reasons=rejection_reasons,
confidence_score=0.92 if not rejection_reasons else 0.98,
notes=" | ".join(notes_accumulator) if notes_accumulator else "Claim verified across all automated system parameters successfully.",
next_steps=next_steps,
cashless_approved=cashless_approved,
network_discount=network_discount
) |