import subprocess, sys def _ensure(pkg, import_name=None): name = import_name or pkg try: __import__(name) except ImportError: subprocess.check_call([sys.executable, "-m", "pip", "install", pkg, "--quiet", "--break-system-packages"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) _ensure("pymupdf", "fitz") _ensure("pypdf") import gradio as gr import os, base64, json, urllib.request, urllib.error, re CLAIMS_TYPES = [ "Motor — Third Party (Bumper to Bumper)", "Motor — Own Damage (Comprehensive)", ] DEMO_CLAIMS = { "Motor — Third Party (Bumper to Bumper)": { "claimant_name": "John Azzopardi", "policy_number": "MTR-2024-88421", "claim_reference": "CLM-2024-001847", "claim_date": "12 March 2025", "incident_date": "10 March 2025", "claim_type": "Third Party Motor — Rear End Collision", "incident_location": "Msida Valley Road, Malta", "incident_description": "Claimant's vehicle was stationary at traffic lights when struck from behind by third party vehicle (Toyota Corolla, reg ABC-123). Rear bumper and boot lid damaged. No bodily injury reported. Police report filed. Third party admitted fault at scene.", "claimant_contact": "+356 9912 3456", "insurer_name": "", "supporting_documents": ["Police report", "Repair estimate", "Photos of damage", "Witness statement"], "key_flags": ["Third party admitted liability", "No bodily injury", "Low complexity"] }, "Motor — Own Damage (Comprehensive)": { "claimant_name": "Maria Camilleri", "policy_number": "MTR-2024-77310", "claim_reference": "CLM-2024-002103", "claim_date": "5 April 2025", "incident_date": "3 April 2025", "claim_type": "Own Damage — Comprehensive", "incident_location": "St Julian's Bypass, Malta", "incident_description": "Claimant lost control on wet road and collided with central barrier. Front end damage including bonnet, radiator, and front bumper. Airbags deployed. Vehicle towed. Claimant uninjured. No third party involved.", "claimant_contact": "+356 7734 8821", "insurer_name": "", "supporting_documents": ["Police accident report", "Two repair estimates", "Vehicle photos", "Towing invoice"], "key_flags": ["Single vehicle accident", "High repair cost", "Airbag deployment — severity check needed"] }, } STAGES = [ {"name": "FNOL / Claim Intake", "key": "fnol"}, {"name": "Validation & Triage", "key": "valid"}, {"name": "Investigation & Evidence", "key": "invest"}, {"name": "Coverage & Adjudication", "key": "adjud"}, {"name": "Damage Assessment & Valuation", "key": "val"}, {"name": "Settlement & Payment Routing", "key": "settle"}, {"name": "Closure & Reporting", "key": "close"}, ] POLICY_RULES = { "Motor — Third Party (Bumper to Bumper)": [ "Third party liability must be established before payment", "Claim must be reported within 24 hours of incident", "Police report mandatory for all motor claims", "Repair estimates from approved repairers only", "No bodily injury component — separate policy required", "Vehicle must have valid roadworthiness certificate", ], "Motor — Own Damage (Comprehensive)": [ "Vehicle must be roadworthy and have valid certificate at time of loss", "Driver must hold valid licence for vehicle class", "Own-damage excess applies — standard €500", "Repairs must be authorised before commencement", "Independent surveyor report required for claims exceeding €3,000 — initial reserve allocated pending survey outcome", "Airbag deployment triggers mandatory engineering inspection", ], } RESERVE_BENCHMARKS = { "Motor — Third Party (Bumper to Bumper)": {"low": 1500, "mid": 3000, "high": 6000, "avg_duration": "3–6 weeks"}, "Motor — Own Damage (Comprehensive)": {"low": 2000, "mid": 5500, "high": 12000, "avg_duration": "4–8 weeks"}, } CSS = """ .gradio-container { max-width: 1200px !important; margin: 0 auto !important; font-family: 'Segoe UI', Arial, sans-serif !important; } footer { display: none !important; } .info-bar { background:#FFF4EF; border:1.5px solid #FC5108; border-radius:8px; padding:12px 16px; font-size:13px; color:#555; margin:16px 0 20px; line-height:1.5; } .step-title { font-size:18px; font-weight:700; color:#0E2841; margin-bottom:3px; } .step-sub { font-size:13px; color:#777; margin-bottom:16px; } .result-panel { background:#f8f9fa; border:1px solid #e4e4e4; border-radius:10px; padding:18px 20px; margin:10px 0; } .result-panel-title { font-size:14px; font-weight:700; color:#0E2841; margin-bottom:14px; padding-bottom:10px; border-bottom:1px solid #e4e4e4; } .field-grid { display:grid; grid-template-columns:160px 1fr; gap:0; } .field-key { color:#888; font-size:12px; font-weight:600; padding:6px 0; border-bottom:1px solid #f0f0f0; text-transform:uppercase; letter-spacing:.3px; } .field-val { color:#111; font-size:13px; padding:6px 0 6px 12px; border-bottom:1px solid #f0f0f0; } .policy-pass { background:#d4edda; border:1.5px solid #28a745; border-radius:8px; padding:10px 14px; margin:6px 0; } .policy-fail { background:#f8d7da; border:1.5px solid #dc3545; border-radius:8px; padding:10px 14px; margin:6px 0; } .policy-warn { background:#fff3cd; border:1.5px solid #ffc107; border-radius:8px; padding:10px 14px; margin:6px 0; } .policy-unknown { background:#e8eaf6; border:1.5px solid #7986cb; border-radius:8px; padding:10px 14px; margin:6px 0; } .stat-row { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin:14px 0; } .stat-box { background:#fff; border:1px solid #eee; border-radius:10px; padding:14px; text-align:center; } .stat-num { font-size:26px; font-weight:700; } .stat-lbl { font-size:11px; color:#888; margin-top:3px; text-transform:uppercase; letter-spacing:.4px; } .fraud-low { background:#d4edda; border:1.5px solid #28a745; border-radius:10px; padding:18px 20px; } .fraud-medium { background:#fff3cd; border:1.5px solid #ffc107; border-radius:10px; padding:18px 20px; } .fraud-high { background:#f8d7da; border:1.5px solid #dc3545; border-radius:10px; padding:18px 20px; } .action-chip { display:inline-block; padding:6px 18px; border-radius:7px; font-size:14px; font-weight:700; margin-bottom:10px; } .chip-approve { background:#28a745; color:white; } .chip-review { background:#ffc107; color:#333; } .chip-investigate { background:#FC5108; color:white; } .chip-decline { background:#dc3545; color:white; } .reserve-box { background:#fff; border:1.5px solid #ddd; border-radius:10px; padding:18px 20px; margin:10px 0; } .reserve-grid { display:grid; grid-template-columns:1fr; gap:12px; margin:14px 0; } .reserve-col { text-align:center; padding:14px 10px; border-radius:8px; } .reserve-mid { background:#fff3cd; border:1px solid #ffc107; } .reserve-amount { font-size:22px; font-weight:700; margin-bottom:4px; } .reserve-label { font-size:11px; text-transform:uppercase; letter-spacing:.4px; font-weight:600; } .reserve-mid .reserve-amount { color:#856404; } .reserve-mid .reserve-label { color:#856404; } .routing-approve { background:#d4edda; border:2px solid #28a745; border-radius:12px; padding:20px 22px; margin:10px 0; } .routing-review { background:#fff3cd; border:2px solid #ffc107; border-radius:12px; padding:20px 22px; margin:10px 0; } .routing-investigate{ background:#fff4ee; border:2px solid #FC5108; border-radius:12px; padding:20px 22px; margin:10px 0; } .routing-decline { background:#f8d7da; border:2px solid #dc3545; border-radius:12px; padding:20px 22px; margin:10px 0; } .coverage-banner-yes { background:#d4edda; border:1.5px solid #28a745; border-radius:8px; padding:12px 16px; margin:10px 0; font-size:13px; color:#155724; } .coverage-banner-no { background:#f8d7da; border:1.5px solid #dc3545; border-radius:8px; padding:12px 16px; margin:10px 0; font-size:13px; color:#721c24; } .coverage-banner-unk { background:#fff3cd; border:1.5px solid #ffc107; border-radius:8px; padding:12px 16px; margin:10px 0; font-size:13px; color:#856404; } .escalation-banner { background:#f8d7da; border:1.5px solid #dc3545; border-radius:8px; padding:12px 16px; margin:10px 0; font-size:13px; color:#721c24; font-weight:600; } .fault-banner { background:#e8f4fd; border:1.5px solid #4a90d9; border-radius:8px; padding:14px 16px; margin:10px 0; } """ # ─── AI HELPERS ─────────────────────────────────────────────── def call_claude(prompt, system="", image_b64=None, image_type="image/jpeg"): api_key = os.getenv("ANTHROPIC_API_KEY") if not api_key: return None, "No ANTHROPIC_API_KEY in Secrets" try: content = ([{"type":"image","source":{"type":"base64","media_type":image_type,"data":image_b64}}, {"type":"text","text":prompt}] if image_b64 else prompt) payload = json.dumps({ "model": "claude-opus-4-5", "max_tokens": 2500, "system": system or "You are a senior insurance AI consultant.", "messages": [{"role":"user","content":content}] }).encode() req = urllib.request.Request( "https://api.anthropic.com/v1/messages", data=payload, headers={"x-api-key":api_key,"anthropic-version":"2023-06-01","content-type":"application/json"}, method="POST") with urllib.request.urlopen(req, timeout=90) as r: return json.loads(r.read())["content"][0]["text"].strip(), None except urllib.error.HTTPError as e: return None, f"HTTP {e.code}: {e.read().decode()[:300]}" except Exception as e: return None, str(e) def call_gemini(prompt, system=""): api_key = os.getenv("GEMINI_API_KEY") if not api_key: return None, "No GEMINI_API_KEY" try: payload = json.dumps({ "contents":[{"parts":[{"text": f"{system}\n\n{prompt}" if system else prompt}]}], "generationConfig":{"maxOutputTokens":2500} }).encode() url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}" req = urllib.request.Request(url, data=payload, headers={"content-type":"application/json"}, method="POST") with urllib.request.urlopen(req, timeout=90) as r: return json.loads(r.read())["candidates"][0]["content"]["parts"][0]["text"].strip(), None except Exception as e: return None, str(e) def ai_json(prompt, system="", image_b64=None, image_type="image/jpeg"): r, err = call_claude(prompt, system, image_b64, image_type) if not r and not image_b64: r, err = call_gemini(prompt, system) if not r: return None, err or "AI unavailable" clean = re.sub(r"```json|```","",r).strip() try: return json.loads(clean), None except: return None, f"JSON parse error: {clean[:200]}" def ai_text(prompt, system=""): r, _ = call_claude(prompt, system) if r: return r r2, _ = call_gemini(prompt, system) return r2 or "" def extract_file(filepath): if not filepath: return "", "No file" ext = os.path.splitext(filepath)[1].lower() if ext in [".txt",".csv"]: try: return open(filepath, errors="ignore").read()[:5000], "" except Exception as e: return "", str(e) if ext in [".jpg",".jpeg",".png",".webp",".bmp"]: try: raw = open(filepath,"rb").read() if len(raw)>4_000_000: return "", "Image >4MB" b64 = base64.b64encode(raw).decode() mime = {"jpg":"image/jpeg","jpeg":"image/jpeg","png":"image/png","webp":"image/webp","bmp":"image/bmp"}.get(ext.lstrip("."),"image/jpeg") r, err = call_claude("Extract ALL visible text from this insurance document. Return every field and value as 'Field: Value' pairs.", system="You are an expert OCR for insurance docs. Extract everything.", image_b64=b64, image_type=mime) return (r or ""), (err if not r else "") except Exception as e: return "", str(e) if ext == ".pdf": text = "" try: import fitz doc = fitz.open(filepath) for page in doc: text += page.get_text() doc.close() if text.strip(): return text[:5000].strip(), "" except: pass try: from pypdf import PdfReader for page in PdfReader(filepath).pages: text += (page.extract_text() or "") if text.strip(): return text[:5000].strip(), "" except: pass try: import fitz pdoc = fitz.open(filepath) pix = pdoc[0].get_pixmap(dpi=150) img_bytes = pix.tobytes("png") pdoc.close() b64v = base64.b64encode(img_bytes).decode() result, _ = call_claude( "Extract ALL visible text from this insurance document. Return every field and value as Field: Value pairs.", system="You are an expert OCR for insurance documents.", image_b64=b64v, image_type="image/png") if result: return result, "" except: pass return "", "Could not read PDF." return "", f"Unsupported file type: {ext}" def analyse_damage_photo(filepath): if not filepath: return {}, "No file" ext = os.path.splitext(filepath)[1].lower().lstrip(".") if ext not in ["jpg","jpeg","png","webp","bmp"]: return {}, f"Unsupported image type: {ext}" try: raw = open(filepath,"rb").read() if len(raw) > 4_000_000: return {}, "Image >4MB" b64 = base64.b64encode(raw).decode() mime = {"jpg":"image/jpeg","jpeg":"image/jpeg","png":"image/png","webp":"image/webp","bmp":"image/bmp"}.get(ext,"image/jpeg") prompt = ('This is a photo submitted as part of an insurance claim. Return ONLY JSON:\n' '{"damage_description":"1-2 sentences describing damage and severity",' '"number_plate_visible":true,"number_plate_text":"plate text or null"}') r, err = call_claude(prompt, system="You are an expert motor damage assessor. Return only valid JSON.", image_b64=b64, image_type=mime) if not r: return {}, err or "AI unavailable" clean = re.sub(r"```json|```","",r).strip() try: return json.loads(clean), "" except: return {"damage_description": r, "number_plate_visible": False, "number_plate_text": None}, "" except Exception as e: return {}, str(e) def analyse_document(filepath, claim_type, org, bumper_form_path=None, damage_photo_paths=None): text, err = extract_file(filepath) if err and not text and not bumper_form_path and not (damage_photo_paths or []): return None, "", f"❌ {err}" extra_sections = [] photo_findings = [] if bumper_form_path: b2b_text, _ = extract_file(bumper_form_path) if b2b_text: extra_sections.append(f"--- Bumper to Bumper Form ---\n{b2b_text[:2500]}") for p in (damage_photo_paths or []): if not p: continue finding, _ = analyse_damage_photo(p) if finding: photo_findings.append(finding) desc = finding.get("damage_description","") plate = finding.get("number_plate_text") section = f"--- Damage Photo ---\n{desc}" if plate: section += f"\nNumber plate: {plate}" extra_sections.append(section) combined_text = (text or "").strip() if extra_sections: combined_text = (combined_text + "\n\n" + "\n\n".join(extra_sections)).strip() if not combined_text: return None, "", "❌ No text extracted." prompt = f"""Insurance claim document for {org or 'insurer'}, type: {claim_type}. Raw text: {combined_text[:4500]} Return ONLY JSON (no markdown): {{"claimant_name":"...","policy_number":"...","claim_reference":"...","claim_date":"...","incident_date":"...","claim_type":"...","incident_location":"...","incident_description":"...","supporting_documents":["..."],"claimant_contact":"...","insurer_name":"...","vehicle_registration":"...","third_party_vehicle_registration":"...","key_flags":["..."]}}""" data, e2 = ai_json(prompt, system="Return only valid JSON.") data = data or {} if photo_findings: data["photo_findings"] = photo_findings return data, combined_text, e2 or "" def run_fnol_policy(claim_data, raw_text, claim_type, org, policy_file=None): policy_text = "" if policy_file: policy_text, _ = extract_file(policy_file) if not policy_text: rules = POLICY_RULES.get(claim_type, []) policy_text = f"Standard {claim_type} policy conditions:\n" + "\n".join(f"- {r}" for r in rules) claim_ctx = json.dumps(claim_data) if claim_data else raw_text[:2000] prompt = f"""Senior claims assessor reviewing FNOL for a {claim_type} claim at {org or 'insurer'}. CLAIM: {claim_ctx} POLICY: {policy_text[:3000]} Also assess fault probability: what is the probability (0-100%) that the insured is NOT at fault? Return ONLY JSON: {{"fnol_summary":"2-3 sentence summary","claim_validity":"VALID|POTENTIALLY VALID|REQUIRES INVESTIGATION|POTENTIALLY INVALID","validity_reason":"one sentence","policy_checks":[{{"rule":"...","status":"PASS|FAIL|WARNING|UNKNOWN","finding":"..."}}],"coverage_assessment":{{"likely_covered":true,"coverage_confidence":"HIGH|MEDIUM|LOW","coverage_notes":"..."}},"vehicle_match":{{"status":"MATCH|MISMATCH|NOT_DETECTED|NO_PHOTO_PROVIDED|NOT_APPLICABLE","claim_vehicle_registration":"...","photo_plate_numbers":["..."],"notes":"..."}},"fault_assessment":{{"probability_not_at_fault":85,"rationale":"one sentence explaining the fault assessment"}},"estimated_claim_validity_score":75,"complexity":"LOW|MEDIUM|HIGH","complexity_reason":"...","key_questions":["..."],"immediate_actions":["..."],"escalation_needed":false,"escalation_reason":null}}""" data, err = ai_json(prompt, system="You are an expert claims assessor. Return only valid JSON.") if data: return data, "" rules = POLICY_RULES.get(claim_type, []) return {"fnol_summary":"Manual review required.","claim_validity":"REQUIRES INVESTIGATION","validity_reason":"Automated analysis unavailable.","policy_checks":[{"rule":r,"status":"UNKNOWN","finding":"Manual check required"} for r in rules],"coverage_assessment":{"likely_covered":None,"coverage_confidence":"LOW","coverage_notes":"Manual assessment required"},"vehicle_match":{"status":"NOT_DETECTED","claim_vehicle_registration":(claim_data or {}).get("vehicle_registration"),"photo_plate_numbers":[],"notes":"Automated analysis unavailable."},"fault_assessment":{"probability_not_at_fault":50,"rationale":"Insufficient data for automated fault assessment."},"estimated_claim_validity_score":50,"complexity":"MEDIUM","complexity_reason":"Cannot determine.","key_questions":["Verify all claim details manually"],"immediate_actions":["Review claim file manually"],"escalation_needed":False,"escalation_reason":None}, "" def run_reserve(claim_data, fnol_data, claim_type, org): bench = RESERVE_BENCHMARKS.get(claim_type, {"low":1000,"mid":5000,"high":15000,"avg_duration":"unknown"}) prompt = f"""PwC actuarial consultant setting FNOL reserves for a {claim_type} claim. Benchmarks: Expected €{bench['mid']:,} | Duration: {bench['avg_duration']} Note: The repair amount is determined by a surveyor — not self-reported. An initial reserve estimate is uploaded per claim type and reviewed by the surveyor. CLAIM: {json.dumps(claim_data)[:2000]} FNOL: {json.dumps({k:fnol_data.get(k) for k in ["claim_validity","complexity","estimated_claim_validity_score"]})} Reserve increase triggers to consider (use exactly these): - Legal proceedings initiated - Medical complications arise - Third party injury claims submitted - Evidence of underinsurance identified - Liability remains disputed after investigation - Repair costs exceed initial surveyor estimate Next reserve review: minimum 30 days from today. Return ONLY JSON: {{"reserve_expected":0,"confidence":"HIGH|MEDIUM|LOW","currency":"EUR","rationale":"2-3 sentences — note that amount is subject to surveyor confirmation","key_drivers":["..."],"adjustment_triggers":["Legal proceedings initiated","Medical complications arise","Third party injury claims submitted","Evidence of underinsurance identified","Liability remains disputed after investigation","Repair costs exceed initial surveyor estimate"],"recommended_review":"Minimum 30 days from today","ibnr_note":"..."}}""" data, err = ai_json(prompt, system="You are an actuarial expert. Return only valid JSON.") if data: return data, "" return {"reserve_expected":bench["mid"],"confidence":"MEDIUM","currency":"EUR","rationale":f"Initial benchmark reserve for {claim_type}. Subject to surveyor confirmation.","key_drivers":["Claim type benchmark"],"adjustment_triggers":["Legal proceedings initiated","Medical complications arise","Third party injury claims submitted","Evidence of underinsurance identified","Liability remains disputed after investigation","Repair costs exceed initial surveyor estimate"],"recommended_review":"Minimum 30 days from today","ibnr_note":"Standard IBNR provisions apply."}, "" def run_fraud(claim_data, fnol_data, claim_type): prompt = f"""Senior fraud investigator analysing a {claim_type} claim. Context: {json.dumps({"claim":claim_data,"fnol":fnol_data})[:3500]} Return ONLY JSON: {{"fraud_risk_level":"LOW|MEDIUM|HIGH","risk_score":45,"risk_factors":["..."],"positive_indicators":["..."],"red_flags":["..."],"recommended_action":"APPROVE|REVIEW|INVESTIGATE|DECLINE","action_reason":"one sentence","data_gaps":["..."],"fnol_consistency":"CONSISTENT|INCONSISTENT|PARTIALLY CONSISTENT","fnol_notes":"..."}}""" data, err = ai_json(prompt, system="You are an expert fraud detection AI. Return only valid JSON.") if data: return data, "" return {"fraud_risk_level":"MEDIUM","risk_score":45,"risk_factors":["Insufficient data"],"positive_indicators":["Document provided"],"red_flags":[],"recommended_action":"REVIEW","action_reason":"Manual review recommended.","data_gaps":["API key required"],"fnol_consistency":"PARTIALLY CONSISTENT","fnol_notes":"Unable to cross-reference."}, "" def run_routing(claim_data, fnol_data, fraud_data, reserve_data, claim_type, org): prompt = f"""Claims adjudicator routing decision for a {claim_type} claim at {org or 'insurer'}. CLAIM: {json.dumps(claim_data)[:1000]} FNOL: validity={fnol_data.get('claim_validity')}, score={fnol_data.get('estimated_claim_validity_score')}, complexity={fnol_data.get('complexity')} FRAUD: risk={fraud_data.get('fraud_risk_level')}, score={fraud_data.get('risk_score')}, action={fraud_data.get('recommended_action')} RESERVE: expected=€{reserve_data.get('reserve_expected',0):,}, confidence={reserve_data.get('confidence')} Return ONLY JSON: {{"routing_decision":"STRAIGHT_TO_PAYMENT|FAST_TRACK_REVIEW|STANDARD_REVIEW|FULL_INVESTIGATION|DECLINE","confidence_score":85,"decision_rationale":"2-3 sentences","decision_factors":[{{"factor":"...","impact":"POSITIVE|NEGATIVE|NEUTRAL","weight":"HIGH|MEDIUM|LOW"}}],"estimated_settlement_days":5,"settlement_amount_recommendation":"...","conditions":["..."],"next_handler":"Automated Payment System|Junior Adjuster|Senior Adjuster|Special Investigations Unit|Legal Team","audit_trail":"one sentence"}}""" data, err = ai_json(prompt, system="You are an expert claims adjudicator. Return only valid JSON.") if data: return data, "" return {"routing_decision":"STANDARD_REVIEW","confidence_score":60,"decision_rationale":"Manual review required.","decision_factors":[{"factor":"Manual review required","impact":"NEUTRAL","weight":"HIGH"}],"estimated_settlement_days":14,"settlement_amount_recommendation":"Per adjuster","conditions":["Manual review required"],"next_handler":"Senior Adjuster","audit_trail":"Claim routed for manual review."}, "" def run_survey(claim_data, fnol_data, claim_type, org, survey_file=None): survey_text = "" if survey_file: survey_text, _ = extract_file(survey_file) prompt = f"""Surveyor assessment for a {claim_type} claim at {org or 'insurer'}. {'Surveyor report content: ' + survey_text[:2000] if survey_text else 'Generate a sample surveyor assessment.'} CLAIM: {json.dumps(claim_data)[:1000]} Note: The repair/settlement amount is determined solely by the surveyor — not self-reported. Return ONLY JSON: {{"inspection_findings":"2-3 sentences","damage_description":"...","surveyor_repair_recommendation":"...","surveyor_cost_estimate":0,"currency":"EUR","discrepancies":["..."],"surveyor_overall_assessment":"...","approved_repairer":true,"additional_inspections_required":false}}""" data, err = ai_json(prompt, system="You are an expert motor surveyor. Return only valid JSON.") if data: return data, "" return {"inspection_findings":"Manual surveyor inspection required.","damage_description":"To be confirmed by surveyor.","surveyor_repair_recommendation":"Pending inspection.","surveyor_cost_estimate":0,"currency":"EUR","discrepancies":[],"surveyor_overall_assessment":"Awaiting surveyor report.","approved_repairer":None,"additional_inspections_required":None}, "" def run_final_reserve(claim_data, fnol_data, reserve_data, survey_data, claim_type, org): surveyor_estimate = survey_data.get("surveyor_cost_estimate", 0) prompt = f"""Final reserve recommendation for a {claim_type} claim at {org or 'insurer'}. CLAIM: {json.dumps(claim_data)[:800]} INITIAL RESERVE: €{reserve_data.get('reserve_expected',0):,} SURVEYOR ESTIMATE: €{surveyor_estimate:,} FNOL validity: {fnol_data.get('claim_validity')} | Complexity: {fnol_data.get('complexity')} Is sufficient information available to proceed to settlement? If yes — recommend final settlement amount and target date. If no — list specifically what is still outstanding. Return ONLY JSON: {{"ready_to_settle":true,"final_reserve":0,"currency":"EUR","settlement_recommendation":"...","outstanding_items":["..."],"final_settlement_target_date":"...","rationale":"2-3 sentences"}}""" data, err = ai_json(prompt, system="You are a senior claims manager. Return only valid JSON.") if data: return data, "" return {"ready_to_settle":False,"final_reserve":0,"currency":"EUR","settlement_recommendation":"Manual review required.","outstanding_items":["Surveyor report","Policy verification"],"final_settlement_target_date":"TBD","rationale":"Insufficient information for automated recommendation."}, "" def run_kpis(claim_data, fnol_data, reserve_data, routing_data, claim_type, org, open_date, settlement_date): prompt = f"""Generate KPI summary for a {claim_type} claim at {org or 'insurer'}. Claim opened: {open_date or 'Not specified'} Claim settled: {settlement_date or 'Not specified'} FNOL score: {fnol_data.get('estimated_claim_validity_score',50)}/100 Routing: {routing_data.get('routing_decision','STANDARD_REVIEW')} Reserve: €{reserve_data.get('reserve_expected',0):,} Return ONLY JSON: {{"total_days_to_settle":0,"sla_target_days":30,"sla_met":true,"stage_breakdown":[{{"stage":"...","days":0}}],"reserve_accuracy":"...","fraud_indicators_triggered":0,"customer_touchpoints":0,"performance_rating":"GREEN|AMBER|RED","performance_notes":"..."}}""" data, err = ai_json(prompt, system="You are a claims analytics expert. Return only valid JSON.") if data: return data, "" return {"total_days_to_settle":0,"sla_target_days":30,"sla_met":None,"stage_breakdown":[],"reserve_accuracy":"TBD","fraud_indicators_triggered":0,"customer_touchpoints":0,"performance_rating":"AMBER","performance_notes":"Insufficient data for KPI calculation."}, "" # ─── HTML RENDERERS ─────────────────────────────────────────── def render_claim_fields(data): if not data: return "