""" ══════════════════════════════════════════════════════════════════════════════ AGENT 3 — Underwriting Agent (Step 3 of 5) ══════════════════════════════════════════════════════════════════════════════ PURPOSE : Apply carrier underwriting guidelines, state compliance checks, reinsurance eligibility, and coverage adequacy validation. Reads Property Risk output from Agent 2. INPUT : silver/property_risk/{sub_id}_property.json OUTPUT : silver/uw_decisions/{sub_id}_uw.json DECISION : UW_APPROVED → pipeline continues to Agent 4 (ML Pricing) UW_DECLINED → pipeline halts, submission DECLINED UW_REFERRAL → manual referral (edge cases) TRAINING : XGBoost binary classifier + rules engine. Features combine KYC signals (credit), property risk scores, coverage parameters, and actuarial portfolio factors. UW RULES APPLIED: ① Coverage limit adequacy — limit must be >= 80% of estimated RCV ② Deductible reasonableness — deductible cannot exceed 10% of limit ③ State compliance — state-specific exclusions and endorsements ④ Reinsurance eligibility — limits above $2M require reinsurance sign-off ⑤ Combined ratio guard — portfolio ELR + risk score must be within band ⑥ Coverage-property match — HO-4 only for renters; HO-6 only for condos ══════════════════════════════════════════════════════════════════════════════ """ import json import pickle import datetime import numpy as np import pandas as pd # mysql.connector kept as fallback; primary driver is PyMySQL via SQLAlchemy import mysql.connector try: from sqlalchemy import create_engine, text from urllib.parse import quote_plus as _qp SQLALCHEMY_AVAILABLE = True except ImportError: SQLALCHEMY_AVAILABLE = False from pathlib import Path from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score, classification_report import xgboost as xgb # ─── CONFIG ────────────────────────────────────────────────────────────────── # ─── DB CONFIG — supports local MySQL and HuggingFace + Clever Cloud ───────── import os as _os def _is_huggingface() -> bool: return ( _os.environ.get("SPACE_ID") is not None or _os.environ.get("HUGGINGFACE_SPACE") is not None or _os.environ.get("MYSQL_ADDON_HOST") is not None or _os.environ.get("MYSQL_HOST") is not None ) def _env(addon_key: str, generic_key: str, default: str = "") -> str: """Reads MYSQL_ADDON_* first (Clever Cloud), then MYSQL_* (generic), then default.""" return _os.environ.get(addon_key) or _os.environ.get(generic_key) or default if _is_huggingface(): DB = dict( host = _env("MYSQL_ADDON_HOST", "MYSQL_HOST"), port = int(_env("MYSQL_ADDON_PORT", "MYSQL_PORT", "3306")), user = _env("MYSQL_ADDON_USER", "MYSQL_USER"), password = _env("MYSQL_ADDON_PASSWORD", "MYSQL_PASSWORD"), database = _env("MYSQL_ADDON_DB", "MYSQL_DATABASE"), ) else: DB = dict(host="localhost", port=3306, user="root", password="root@123", database="bronze") def T(layer: str, table: str) -> str: """ Returns the correct table reference for the active environment. HuggingFace (single schema): `bronze_submissions` Local (separate schemas): `bronze`.`submissions` """ return f"`{layer}_{table}`" if _is_huggingface() else f"`{layer}`.`{table}`" MODEL_PATH = Path("models/agent3_underwriting.pkl") SILVER_IN = Path("silver/property_risk") SILVER_OUT = Path("silver/uw_decisions") MODEL_PATH.parent.mkdir(exist_ok=True) SILVER_OUT.mkdir(parents=True, exist_ok=True) # ── State-specific underwriting rules ────────────────────────────────────── STATE_RULES = { "FL": {"min_wind_deductible_pct": 2.0, "requires_flood_endorsement": True, "max_limit": 5_000_000, "surplus_lines_above": 3_000_000}, "TX": {"requires_windstorm_exclusion_coast": True, "max_limit": 5_000_000}, "CA": {"requires_earthquake_endorsement": False, "wildfire_exclusion_zones": True, "max_limit": 4_000_000}, "LA": {"requires_flood_endorsement": True, "max_limit": 3_000_000}, "NY": {"requires_lead_paint_inspection": True, "max_limit": 6_000_000}, } # ── Coverage type eligibility rules ─────────────────────────────────────── COVERAGE_ELIGIBILITY = { "HO-3": {"eligible_types": ["Single Family", "Townhouse"], "min_credit": 580}, "HO-5": {"eligible_types": ["Single Family", "Townhouse"], "min_credit": 650}, "HO-4": {"eligible_types": ["Condo Unit", "Multi-Family", "Single Family"], "min_credit": 560}, # renters "HO-6": {"eligible_types": ["Condo Unit"], "min_credit": 570}, "DP-3": {"eligible_types": ["Single Family", "Multi-Family"], "min_credit": 560}, "DP-1": {"eligible_types": ["Single Family", "Multi-Family", "Vacation / Seasonal"], "min_credit": 540}, "BOP": {"eligible_types": ["Commercial Building"], "min_credit": 620}, "FARM": {"eligible_types": ["Single Family", "Commercial Building"],"min_credit": 560}, "WC-3": {"eligible_types": ["Single Family", "Vacation / Seasonal","Townhouse"], "min_credit": 560}, } # ── Carrier portfolio load factors ──────────────────────────────────────── EXPECTED_LOSS_RATIO = { "HO-3": 0.58, "HO-5": 0.55, "HO-4": 0.45, "HO-6": 0.48, "DP-3": 0.62, "DP-1": 0.65, "BOP": 0.60, "FARM": 0.68, "WC-3": 0.70, } REINSURANCE_THRESHOLD = 2_000_000 # limits above this need re sign-off DEDUCTIBLE_MAX_PCT = 10.0 # deductible cannot exceed 10% of limit # ─── FEATURE ENGINEERING ───────────────────────────────────────────────────── def extract_uw_features(df: pd.DataFrame, risk_df: pd.DataFrame = None) -> pd.DataFrame: """ Combine Bronze submission fields + Agent 2 peril scores into UW features. risk_df is the parsed Silver property-risk output (if available). """ feats = pd.DataFrame() n = len(df) # ── From Bronze ── feats["credit_score"] = pd.to_numeric(df.get("credit_score", pd.Series([650]*n)), errors="coerce").fillna(650) feats["coverage_limit"] = pd.to_numeric(df.get("requested_coverage_limit", pd.Series([300_000]*n)), errors="coerce").fillna(300_000) feats["deductible"] = pd.to_numeric(df.get("requested_deductible", pd.Series([1_000]*n)), errors="coerce").fillna(1_000) feats["property_age"] = (2024 - pd.to_numeric(df.get("year_built", pd.Series([1990]*n)), errors="coerce").fillna(1990)).clip(0, 150) feats["roof_age"] = (2024 - pd.to_numeric(df.get("roof_year", pd.Series([2010]*n)), errors="coerce").fillna(2010)).clip(0, 50) # Coverage type encoded cov_map = {c: i for i, c in enumerate(COVERAGE_ELIGIBILITY.keys())} feats["coverage_type_enc"] = df.get("coverage_type_code", pd.Series(["HO-3"]*n)).map(cov_map).fillna(0).astype(int) # ELR for selected coverage feats["expected_loss_ratio"] = df.get("coverage_type_code", pd.Series(["HO-3"]*n))\ .map(EXPECTED_LOSS_RATIO).fillna(0.60) # Deductible as % of limit feats["deductible_pct"] = (feats["deductible"] / feats["coverage_limit"].clip(lower=1) * 100).clip(0, 20) # Limit per sq-ft (over-insurance detector) sqft = pd.to_numeric(df.get("square_footage", pd.Series([1800]*n)), errors="coerce").fillna(1800).clip(500, 15000) feats["limit_per_sqft"] = (feats["coverage_limit"] / sqft).clip(0, 2000) # ── From Agent 2 Silver (peril scores) ── if risk_df is not None: feats["wind_score"] = pd.to_numeric(risk_df.get("wind_score", pd.Series([30]*n)), errors="coerce").fillna(30) feats["flood_score"] = pd.to_numeric(risk_df.get("flood_score", pd.Series([30]*n)), errors="coerce").fillna(30) feats["fire_score"] = pd.to_numeric(risk_df.get("fire_score", pd.Series([30]*n)), errors="coerce").fillna(30) feats["overall_risk"] = pd.to_numeric(risk_df.get("overall_risk",pd.Series([30]*n)), errors="coerce").fillna(30) else: # Derive approximate peril scores from state when Silver not yet populated feats["wind_score"] = pd.Series([30.0]*n) feats["flood_score"] = pd.Series([30.0]*n) feats["fire_score"] = pd.Series([30.0]*n) feats["overall_risk"]= pd.Series([30.0]*n) # ── Derived UW signals ── feats["above_reinsurance_threshold"] = (feats["coverage_limit"] > REINSURANCE_THRESHOLD).astype(int) feats["excess_deductible"] = (feats["deductible_pct"] > DEDUCTIBLE_MAX_PCT).astype(int) feats["old_roof_high_wind"] = ((feats["roof_age"] > 20) & (feats["wind_score"] > 50)).astype(int) feats["combined_uw_risk"] = (feats["overall_risk"] * (1 + feats["expected_loss_ratio"])).clip(0, 150) return feats # ─── DATA LOADING ──────────────────────────────────────────────────────────── def load_bronze_uw_data() -> pd.DataFrame: print("Connecting to Bronze MySQL...") if SQLALCHEMY_AVAILABLE: _pwd = _qp(DB['password']) eng = create_engine( f"mysql+pymysql://{DB['user']}:{_pwd}@{DB['host']}:{DB['port']}/{DB['database']}?charset=utf8mb4", pool_pre_ping=True, pool_recycle=280 ) conn = eng.connect() else: conn = mysql.connector.connect(**DB) query = f""" SELECT s.submission_id, s.coverage_type_code, s.requested_coverage_limit, s.requested_deductible, s.final_outcome, s.pipeline_status, s.halt_reason, s.raw_payload, pr.property_type, pr.construction_type, pr.year_built, pr.roof_year, pr.square_footage, pr.state_code FROM {T('bronze','submissions')} s JOIN {T('bronze','properties')} pr ON s.property_id = pr.property_id WHERE s.submitted_at BETWEEN '2024-01-01' AND '2024-12-31 23:59:59' ORDER BY s.submitted_at """ df = pd.read_sql(query, conn) conn.close() print(f" Loaded {len(df)} Bronze records") # Extract credit_score from JSON def get_credit(row): try: return json.loads(row["raw_payload"]).get("insured", {}).get("credit_score", 650) except Exception: return 650 df["credit_score"] = df.apply(get_credit, axis=1) # ── UW training label strategy ────────────────────────────────────────── # In our Bronze data, all submissions that reached the UW step were APPROVED # (KYC declines and property declines stopped earlier). There are no UW_DECLINED # records in training data because the simulation didn't model UW-level declines. # # Strategy: Use the full dataset (all 500 records) as UW training universe. # Generate synthetic UW decline labels based on UW rules that WOULD have fired: # - Roof age > 25 years → 0 (UW_DECLINED) # - Frame construction + property age > 75 years → 0 (UW_DECLINED) # - Deductible > 10% of limit → 0 (UW_DECLINED) # - Coverage limit > $2M → 0 (UW_DECLINED / REFERRAL) # - All others → 1 (UW_APPROVED) # # This gives the model realistic positive/negative examples aligned with rules. df["roof_age"] = 2024 - pd.to_numeric(df["roof_year"], errors="coerce").fillna(2010) df["property_age"] = 2024 - pd.to_numeric(df["year_built"], errors="coerce").fillna(1990) df["deductible_pct"] = df["requested_deductible"] / df["requested_coverage_limit"].clip(lower=1) * 100 roof_fail = df["roof_age"] > 25 frame_old = (df["roof_age"] > 60) & (df["construction_type"] == "Frame") ded_excess = df["deductible_pct"] > 10.0 limit_excess = df["requested_coverage_limit"] > 2_000_000 df["uw_label"] = (~(roof_fail | frame_old | ded_excess | limit_excess)).astype(int) n_approved = df["uw_label"].sum() n_declined = (df["uw_label"] == 0).sum() print(f" UW records: {len(df)} | APPROVED: {n_approved} | DECLINED (synthetic): {n_declined}") if n_declined == 0: # Absolute fallback: force ~15% decline rate on oldest-roof records roof_ages = df["roof_age"].sort_values(ascending=False) decline_idx = roof_ages.head(int(len(df) * 0.15)).index df.loc[decline_idx, "uw_label"] = 0 print(f" Fallback labels applied — DECLINED: {(df['uw_label']==0).sum()}") return df # ─── TRAINING ──────────────────────────────────────────────────────────────── def train_uw_model(): print("\n" + "═"*60) print("AGENT 3 — Underwriting Model Training") print("═"*60) df = load_bronze_uw_data() X = extract_uw_features(df) y = df["uw_label"] FEATURES = X.columns.tolist() print(f"\nFeatures ({len(FEATURES)}): {FEATURES}") X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) model = xgb.XGBClassifier( n_estimators = 300, max_depth = 5, learning_rate = 0.04, subsample = 0.80, colsample_bytree = 0.75, reg_alpha = 0.1, reg_lambda = 1.2, eval_metric = "auc", early_stopping_rounds = 20, random_state = 42, verbosity = 0, ) model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False) y_prob = model.predict_proba(X_test)[:, 1] y_pred = model.predict(X_test) auc = roc_auc_score(y_test, y_prob) print(f"\n ROC-AUC : {auc:.4f}") print(f" Gini : {2*auc-1:.4f}") print("\n Classification Report:") print(classification_report(y_test, y_pred, target_names=["UW_DECLINED", "UW_APPROVED"])) imp = pd.Series(model.feature_importances_, index=FEATURES).sort_values(ascending=False) print("\n Top Feature Importances:") for feat, val in imp.head(8).items(): print(f" {feat:<35} {val:.4f}") artefact = { "model": model, "features": FEATURES, "thresholds": {"uw_approve_threshold": 0.50, "referral_band": (0.40, 0.65)}, "trained_at": datetime.datetime.now().isoformat(), "version": "1.0", } with open(MODEL_PATH, "wb") as f: pickle.dump(artefact, f) print(f"\n Model saved → {MODEL_PATH}") return artefact # ─── RULE ENGINE ───────────────────────────────────────────────────────────── def apply_uw_rules(submission_json: dict, property_risk: dict) -> list: """ Hard underwriting rules — checked BEFORE the ML model. Returns a list of rule-violation strings (empty = all rules passed). """ violations = [] prop = submission_json.get("property", {}) policy = submission_json.get("policy_request", {}) insured = submission_json.get("insured", {}) limit = float(policy.get("limit", 0) or 0) deductible = float(policy.get("deductible", 0) or 0) cov_type = policy.get("coverage_type", "HO-3") prop_type = prop.get("property_type", "Single Family") state = prop.get("state", "XX").upper() credit = float(insured.get("credit_score", 650) or 650) year_built = int(prop.get("year_built", 1990) or 1990) roof_year = int(prop.get("roof_year", 2010) or 2010) roof_age = 2024 - roof_year # ① Coverage-property type mismatch eligible = COVERAGE_ELIGIBILITY.get(cov_type, {}) eligible_types = eligible.get("eligible_types", []) if eligible_types and prop_type not in eligible_types: violations.append( f"UW-RULE-01: {cov_type} is not eligible for property type '{prop_type}'. " f"Eligible types: {eligible_types}" ) # ② Minimum credit for coverage type min_credit = eligible.get("min_credit", 550) if credit < min_credit: violations.append( f"UW-RULE-02: Credit score {credit:.0f} below minimum {min_credit} for {cov_type}" ) # ③ Deductible exceeds 10% of coverage limit if limit > 0 and (deductible / limit * 100) > DEDUCTIBLE_MAX_PCT: violations.append( f"UW-RULE-03: Deductible ${deductible:,.0f} exceeds {DEDUCTIBLE_MAX_PCT}% " f"of coverage limit ${limit:,.0f}" ) # ④ Reinsurance threshold if limit > REINSURANCE_THRESHOLD: violations.append( f"UW-RULE-04: Coverage limit ${limit:,.0f} exceeds reinsurance threshold " f"${REINSURANCE_THRESHOLD:,.0f}. Manual reinsurance sign-off required. → UW_REFERRAL" ) # ⑤ Property age > 75 years with Frame construction if (2024 - year_built) > 75 and prop.get("construction_type") == "Frame": violations.append( f"UW-RULE-05: Frame construction property built {year_built} " f"({2024-year_built} years old) exceeds 75-year guideline" ) # ⑥ Roof age > 25 years if roof_age > 25: violations.append( f"UW-RULE-06: Roof age {roof_age} years exceeds 25-year maximum. " f"Roof replacement or inspection report required." ) # ⑦ State max limit check state_rules = STATE_RULES.get(state, {}) state_max = state_rules.get("max_limit", 10_000_000) if limit > state_max: violations.append( f"UW-RULE-07: Coverage limit ${limit:,.0f} exceeds {state} state maximum " f"${state_max:,.0f}" ) # ⑧ High wind area with wood shake roof wind_score = property_risk.get("peril_scores", {}).get("wind_score", 0) if property_risk else 0 if wind_score > 55 and prop.get("roof_type") == "Wood Shake": violations.append( f"UW-RULE-08: Wood Shake roof not eligible in high-wind zones " f"(wind score {wind_score:.0f}/100)" ) return violations # ─── INFERENCE ─────────────────────────────────────────────────────────────── def run_underwriting_agent(property_risk: dict, submission_json: dict) -> dict: """ Parameters ---------- property_risk : dict Output from Agent 2 (silver/property_risk/) submission_json : dict Full Bronze JSON payload Returns ------- dict UW decision written to silver/uw_decisions/ """ sub_id = submission_json.get("submission_id", "UNKNOWN") # Guard: skip if property risk declined if property_risk.get("status") in ("RISK_DECLINED", "SKIPPED"): return {"submission_id": sub_id, "status": "SKIPPED", "skip_reason": "RISK_DECLINED — pipeline halted at Step 2"} # ── Rule engine (hard gates first) ── rule_violations = apply_uw_rules(submission_json, property_risk) # Separate referrals (reinsurance) from outright declines referrals = [v for v in rule_violations if "REFERRAL" in v] declines = [v for v in rule_violations if "REFERRAL" not in v] if declines: return _build_uw_output(sub_id, "UW_DECLINED", declines[0], rule_violations, None, None, submission_json, property_risk) if referrals: return _build_uw_output(sub_id, "UW_REFERRAL", referrals[0], rule_violations, None, None, submission_json, property_risk) # ── ML model ── prop = submission_json.get("property", {}) policy = submission_json.get("policy_request", {}) peril = property_risk.get("peril_scores", {}) row = pd.DataFrame([{ "credit_score": submission_json.get("insured", {}).get("credit_score", 650), "requested_coverage_limit": policy.get("limit", 300_000), "requested_deductible": policy.get("deductible", 1_000), "coverage_type_code": policy.get("coverage_type", "HO-3"), "year_built": prop.get("year_built", 1990), "roof_year": prop.get("roof_year", 2010), "square_footage": prop.get("square_footage", 1800), }]) risk_row = pd.DataFrame([peril]) if peril else None feats = extract_uw_features(row, risk_row) try: with open(MODEL_PATH, "rb") as f: art = pickle.load(f) uw_prob = float(art["model"].predict_proba(feats[art["features"]])[0, 1]) thres = art["thresholds"]["uw_approve_threshold"] ref_lo, ref_hi = art["thresholds"]["referral_band"] except FileNotFoundError: uw_prob, thres, ref_lo, ref_hi = 0.80, 0.50, 0.40, 0.65 if uw_prob >= thres: return _build_uw_output(sub_id, "UW_APPROVED", None, [], uw_prob, thres, submission_json, property_risk) elif ref_lo <= uw_prob < thres: return _build_uw_output(sub_id, "UW_REFERRAL", f"ML-UW-009 — Model probability {uw_prob:.2%} in referral band. Manual review required.", [], uw_prob, thres, submission_json, property_risk) else: return _build_uw_output(sub_id, "UW_DECLINED", f"ML-UW-010 — Underwriting model probability {uw_prob:.2%} below threshold {thres:.0%}", [], uw_prob, thres, submission_json, property_risk) def _build_uw_output(sub_id, status, primary_reason, all_violations, uw_prob, threshold, submission_json, property_risk): policy = submission_json.get("policy_request", {}) if submission_json else {} cov = policy.get("coverage_type", "HO-3") output = { "submission_id": sub_id, "agent": "Underwriting_Agent", "step": 3, "status": status, # UW_APPROVED | UW_DECLINED | UW_REFERRAL "decision": "APPROVE" if status == "UW_APPROVED" else ("REFERRAL" if "REFERRAL" in status else "DECLINE"), "primary_decline_reason": primary_reason, "all_rule_violations": all_violations, "uw_approval_probability": round(uw_prob, 4) if uw_prob is not None else None, "decision_threshold": threshold, "coverage_type": cov, "expected_loss_ratio": EXPECTED_LOSS_RATIO.get(cov, 0.60), "reinsurance_required": float(policy.get("limit", 0) or 0) > REINSURANCE_THRESHOLD, "processed_at": datetime.datetime.now().isoformat(), "next_step": "ML_Pricing_Agent" if status == "UW_APPROVED" else "PIPELINE_HALTED", "s3_output_uri": f"s3://pcins-silver/uw_decisions/{sub_id}_uw.json", } out_file = SILVER_OUT / f"{sub_id}_uw.json" with open(out_file, "w") as f: json.dump(output, f, indent=2) return output # ─── MAIN ──────────────────────────────────────────────────────────────────── if __name__ == "__main__": train_uw_model() print("\n" + "─"*60) print("SMOKE TESTS") print("─"*60) prop_risk_pass = {"status": "RISK_ACCEPTABLE", "risk_band": "LOW", "peril_scores": {"wind_score": 30, "flood_score": 25, "fire_score": 20, "overall_risk": 28}} tests = [ { # Should APPROVE — clean profile "sub": {"submission_id": "SUB-TEST-001", "insured": {"credit_score": 760}, "property": {"state": "PA", "property_type": "Single Family", "construction_type": "Masonry", "roof_type": "Tile", "year_built": 2005, "roof_year": 2005, "square_footage": 2200}, "policy_request": {"coverage_type": "HO-3", "limit": 380_000, "deductible": 2_500}}, }, { # Should DECLINE — roof age 32 years "sub": {"submission_id": "SUB-TEST-002", "insured": {"credit_score": 680}, "property": {"state": "TX", "property_type": "Single Family", "construction_type": "Frame", "roof_type": "Asphalt Shingles", "year_built": 1960, "roof_year": 1992, "square_footage": 1600}, "policy_request": {"coverage_type": "HO-3", "limit": 220_000, "deductible": 1_500}}, }, { # Should REFERRAL — above reinsurance threshold "sub": {"submission_id": "SUB-TEST-003", "insured": {"credit_score": 810}, "property": {"state": "NY", "property_type": "Single Family", "construction_type": "Masonry", "roof_type": "Slate", "year_built": 2018, "roof_year": 2018, "square_footage": 6000}, "policy_request": {"coverage_type": "HO-5", "limit": 3_500_000, "deductible": 10_000}}, }, ] for t in tests: r = run_underwriting_agent(prop_risk_pass, t["sub"]) print(f" {t['sub']['submission_id']} | {t['sub']['policy_request']['coverage_type']} " f"| Limit ${t['sub']['policy_request']['limit']:,.0f} " f"→ {r['status']} {r['primary_decline_reason'] or ''}")