CoolFace
Apppublic

ITNovaML/PCAgentinAI

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
agent3_underwriting.py538 linesDownload Raw Back to root
1"""2══════════════════════════════════════════════════════════════════════════════3AGENT 3 — Underwriting Agent  (Step 3 of 5)4══════════════════════════════════════════════════════════════════════════════5PURPOSE  : Apply carrier underwriting guidelines, state compliance checks,6           reinsurance eligibility, and coverage adequacy validation.7           Reads Property Risk output from Agent 2.8 9INPUT    : silver/property_risk/{sub_id}_property.json10OUTPUT   : silver/uw_decisions/{sub_id}_uw.json11 12DECISION : UW_APPROVED      → pipeline continues to Agent 4 (ML Pricing)13           UW_DECLINED      → pipeline halts, submission DECLINED14           UW_REFERRAL      → manual referral (edge cases)15 16TRAINING : XGBoost binary classifier + rules engine.17           Features combine KYC signals (credit), property risk scores,18           coverage parameters, and actuarial portfolio factors.19 20UW RULES APPLIED:21    ① Coverage limit adequacy   — limit must be >= 80% of estimated RCV22    ② Deductible reasonableness — deductible cannot exceed 10% of limit23    ③ State compliance          — state-specific exclusions and endorsements24    ④ Reinsurance eligibility   — limits above $2M require reinsurance sign-off25    ⑤ Combined ratio guard      — portfolio ELR + risk score must be within band26    ⑥ Coverage-property match   — HO-4 only for renters; HO-6 only for condos27══════════════════════════════════════════════════════════════════════════════28"""29 30import json31import pickle32import datetime33import numpy as np34import pandas as pd35# mysql.connector kept as fallback; primary driver is PyMySQL via SQLAlchemy36import mysql.connector37try:38    from sqlalchemy import create_engine, text39    from urllib.parse import quote_plus as _qp40    SQLALCHEMY_AVAILABLE = True41except ImportError:42    SQLALCHEMY_AVAILABLE = False43from pathlib import Path44from sklearn.model_selection import train_test_split45from sklearn.metrics import roc_auc_score, classification_report46import xgboost as xgb47 48# ─── CONFIG ──────────────────────────────────────────────────────────────────49# ─── DB CONFIG — supports local MySQL and HuggingFace + Clever Cloud ─────────50import os as _os51 52def _is_huggingface() -> bool:53    return (54        _os.environ.get("SPACE_ID")            is not None55        or _os.environ.get("HUGGINGFACE_SPACE") is not None56        or _os.environ.get("MYSQL_ADDON_HOST")  is not None57        or _os.environ.get("MYSQL_HOST")        is not None58    )59 60def _env(addon_key: str, generic_key: str, default: str = "") -> str:61    """Reads MYSQL_ADDON_* first (Clever Cloud), then MYSQL_* (generic), then default."""62    return _os.environ.get(addon_key) or _os.environ.get(generic_key) or default63 64if _is_huggingface():65    DB = dict(66        host     = _env("MYSQL_ADDON_HOST",     "MYSQL_HOST"),67        port     = int(_env("MYSQL_ADDON_PORT", "MYSQL_PORT", "3306")),68        user     = _env("MYSQL_ADDON_USER",     "MYSQL_USER"),69        password = _env("MYSQL_ADDON_PASSWORD", "MYSQL_PASSWORD"),70        database = _env("MYSQL_ADDON_DB",       "MYSQL_DATABASE"),71    )72else:73    DB = dict(host="localhost", port=3306, user="root", password="root@123", database="bronze")74 75def T(layer: str, table: str) -> str:76    """77    Returns the correct table reference for the active environment.78    HuggingFace (single schema):  `bronze_submissions`79    Local (separate schemas):     `bronze`.`submissions`80    """81    return f"`{layer}_{table}`" if _is_huggingface() else f"`{layer}`.`{table}`"82MODEL_PATH  = Path("models/agent3_underwriting.pkl")83SILVER_IN   = Path("silver/property_risk")84SILVER_OUT  = Path("silver/uw_decisions")85MODEL_PATH.parent.mkdir(exist_ok=True)86SILVER_OUT.mkdir(parents=True, exist_ok=True)87 88# ── State-specific underwriting rules ──────────────────────────────────────89STATE_RULES = {90    "FL": {"min_wind_deductible_pct": 2.0, "requires_flood_endorsement": True,91           "max_limit": 5_000_000, "surplus_lines_above": 3_000_000},92    "TX": {"requires_windstorm_exclusion_coast": True, "max_limit": 5_000_000},93    "CA": {"requires_earthquake_endorsement": False, "wildfire_exclusion_zones": True,94           "max_limit": 4_000_000},95    "LA": {"requires_flood_endorsement": True,  "max_limit": 3_000_000},96    "NY": {"requires_lead_paint_inspection": True, "max_limit": 6_000_000},97}98 99# ── Coverage type eligibility rules ───────────────────────────────────────100COVERAGE_ELIGIBILITY = {101    "HO-3": {"eligible_types": ["Single Family", "Townhouse"],          "min_credit": 580},102    "HO-5": {"eligible_types": ["Single Family", "Townhouse"],          "min_credit": 650},103    "HO-4": {"eligible_types": ["Condo Unit", "Multi-Family", "Single Family"], "min_credit": 560},  # renters104    "HO-6": {"eligible_types": ["Condo Unit"],                          "min_credit": 570},105    "DP-3": {"eligible_types": ["Single Family", "Multi-Family"],       "min_credit": 560},106    "DP-1": {"eligible_types": ["Single Family", "Multi-Family", "Vacation / Seasonal"], "min_credit": 540},107    "BOP":  {"eligible_types": ["Commercial Building"],                 "min_credit": 620},108    "FARM": {"eligible_types": ["Single Family", "Commercial Building"],"min_credit": 560},109    "WC-3": {"eligible_types": ["Single Family", "Vacation / Seasonal","Townhouse"], "min_credit": 560},110}111 112# ── Carrier portfolio load factors ────────────────────────────────────────113EXPECTED_LOSS_RATIO = {114    "HO-3": 0.58, "HO-5": 0.55, "HO-4": 0.45, "HO-6": 0.48,115    "DP-3": 0.62, "DP-1": 0.65, "BOP":  0.60, "FARM": 0.68, "WC-3": 0.70,116}117REINSURANCE_THRESHOLD = 2_000_000   # limits above this need re sign-off118DEDUCTIBLE_MAX_PCT    = 10.0        # deductible cannot exceed 10% of limit119 120# ─── FEATURE ENGINEERING ─────────────────────────────────────────────────────121def extract_uw_features(df: pd.DataFrame, risk_df: pd.DataFrame = None) -> pd.DataFrame:122    """123    Combine Bronze submission fields + Agent 2 peril scores into UW features.124    risk_df is the parsed Silver property-risk output (if available).125    """126    feats = pd.DataFrame()127    n     = len(df)128 129    # ── From Bronze ──130    feats["credit_score"]        = pd.to_numeric(df.get("credit_score", pd.Series([650]*n)), errors="coerce").fillna(650)131    feats["coverage_limit"]      = pd.to_numeric(df.get("requested_coverage_limit", pd.Series([300_000]*n)), errors="coerce").fillna(300_000)132    feats["deductible"]          = pd.to_numeric(df.get("requested_deductible", pd.Series([1_000]*n)), errors="coerce").fillna(1_000)133    feats["property_age"]        = (2024 - pd.to_numeric(df.get("year_built", pd.Series([1990]*n)), errors="coerce").fillna(1990)).clip(0, 150)134    feats["roof_age"]            = (2024 - pd.to_numeric(df.get("roof_year",  pd.Series([2010]*n)), errors="coerce").fillna(2010)).clip(0, 50)135 136    # Coverage type encoded137    cov_map  = {c: i for i, c in enumerate(COVERAGE_ELIGIBILITY.keys())}138    feats["coverage_type_enc"]   = df.get("coverage_type_code", pd.Series(["HO-3"]*n)).map(cov_map).fillna(0).astype(int)139 140    # ELR for selected coverage141    feats["expected_loss_ratio"] = df.get("coverage_type_code", pd.Series(["HO-3"]*n))\142                                      .map(EXPECTED_LOSS_RATIO).fillna(0.60)143 144    # Deductible as % of limit145    feats["deductible_pct"]      = (feats["deductible"] / feats["coverage_limit"].clip(lower=1) * 100).clip(0, 20)146 147    # Limit per sq-ft (over-insurance detector)148    sqft = pd.to_numeric(df.get("square_footage", pd.Series([1800]*n)), errors="coerce").fillna(1800).clip(500, 15000)149    feats["limit_per_sqft"]      = (feats["coverage_limit"] / sqft).clip(0, 2000)150 151    # ── From Agent 2 Silver (peril scores) ──152    if risk_df is not None:153        feats["wind_score"]      = pd.to_numeric(risk_df.get("wind_score",  pd.Series([30]*n)), errors="coerce").fillna(30)154        feats["flood_score"]     = pd.to_numeric(risk_df.get("flood_score", pd.Series([30]*n)), errors="coerce").fillna(30)155        feats["fire_score"]      = pd.to_numeric(risk_df.get("fire_score",  pd.Series([30]*n)), errors="coerce").fillna(30)156        feats["overall_risk"]    = pd.to_numeric(risk_df.get("overall_risk",pd.Series([30]*n)), errors="coerce").fillna(30)157    else:158        # Derive approximate peril scores from state when Silver not yet populated159        feats["wind_score"]  = pd.Series([30.0]*n)160        feats["flood_score"] = pd.Series([30.0]*n)161        feats["fire_score"]  = pd.Series([30.0]*n)162        feats["overall_risk"]= pd.Series([30.0]*n)163 164    # ── Derived UW signals ──165    feats["above_reinsurance_threshold"] = (feats["coverage_limit"] > REINSURANCE_THRESHOLD).astype(int)166    feats["excess_deductible"]           = (feats["deductible_pct"] > DEDUCTIBLE_MAX_PCT).astype(int)167    feats["old_roof_high_wind"]          = ((feats["roof_age"] > 20) & (feats["wind_score"] > 50)).astype(int)168    feats["combined_uw_risk"]            = (feats["overall_risk"] * (1 + feats["expected_loss_ratio"])).clip(0, 150)169 170    return feats171 172# ─── DATA LOADING ────────────────────────────────────────────────────────────173def load_bronze_uw_data() -> pd.DataFrame:174    print("Connecting to Bronze MySQL...")175    if SQLALCHEMY_AVAILABLE:176        _pwd = _qp(DB['password'])177        eng  = create_engine(178            f"mysql+pymysql://{DB['user']}:{_pwd}@{DB['host']}:{DB['port']}/{DB['database']}?charset=utf8mb4",179            pool_pre_ping=True, pool_recycle=280180        )181        conn = eng.connect()182    else:183        conn = mysql.connector.connect(**DB)184    query = f"""185        186        SELECT187            s.submission_id,188            s.coverage_type_code,189            s.requested_coverage_limit,190            s.requested_deductible,191            s.final_outcome,192            s.pipeline_status,193            s.halt_reason,194            s.raw_payload,195            pr.property_type,196            pr.construction_type,197            pr.year_built,198            pr.roof_year,199            pr.square_footage,200            pr.state_code201        FROM {T('bronze','submissions')}  s202        JOIN {T('bronze','properties')}   pr ON s.property_id = pr.property_id203        WHERE s.submitted_at BETWEEN '2024-01-01' AND '2024-12-31 23:59:59'204        ORDER BY s.submitted_at205    """206    df = pd.read_sql(query, conn)207    conn.close()208    print(f"  Loaded {len(df)} Bronze records")209 210    # Extract credit_score from JSON211    def get_credit(row):212        try:213            return json.loads(row["raw_payload"]).get("insured", {}).get("credit_score", 650)214        except Exception:215            return 650216    df["credit_score"] = df.apply(get_credit, axis=1)217 218    # ── UW training label strategy ──────────────────────────────────────────219    # In our Bronze data, all submissions that reached the UW step were APPROVED220    # (KYC declines and property declines stopped earlier). There are no UW_DECLINED221    # records in training data because the simulation didn't model UW-level declines.222    #223    # Strategy: Use the full dataset (all 500 records) as UW training universe.224    # Generate synthetic UW decline labels based on UW rules that WOULD have fired:225    #   - Roof age > 25 years                          → 0 (UW_DECLINED)226    #   - Frame construction + property age > 75 years → 0 (UW_DECLINED)227    #   - Deductible > 10% of limit                    → 0 (UW_DECLINED)228    #   - Coverage limit > $2M                         → 0 (UW_DECLINED / REFERRAL)229    #   - All others                                   → 1 (UW_APPROVED)230    #231    # This gives the model realistic positive/negative examples aligned with rules.232 233    df["roof_age"]      = 2024 - pd.to_numeric(df["roof_year"],  errors="coerce").fillna(2010)234    df["property_age"]  = 2024 - pd.to_numeric(df["year_built"], errors="coerce").fillna(1990)235    df["deductible_pct"] = df["requested_deductible"] / df["requested_coverage_limit"].clip(lower=1) * 100236 237    roof_fail     = df["roof_age"]      > 25238    frame_old     = (df["roof_age"] > 60) & (df["construction_type"] == "Frame")239    ded_excess    = df["deductible_pct"] > 10.0240    limit_excess  = df["requested_coverage_limit"] > 2_000_000241 242    df["uw_label"] = (~(roof_fail | frame_old | ded_excess | limit_excess)).astype(int)243 244    n_approved = df["uw_label"].sum()245    n_declined = (df["uw_label"] == 0).sum()246    print(f"  UW records: {len(df)} | APPROVED: {n_approved} | DECLINED (synthetic): {n_declined}")247 248    if n_declined == 0:249        # Absolute fallback: force ~15% decline rate on oldest-roof records250        roof_ages   = df["roof_age"].sort_values(ascending=False)251        decline_idx = roof_ages.head(int(len(df) * 0.15)).index252        df.loc[decline_idx, "uw_label"] = 0253        print(f"  Fallback labels applied — DECLINED: {(df['uw_label']==0).sum()}")254 255    return df256 257# ─── TRAINING ────────────────────────────────────────────────────────────────258def train_uw_model():259    print("\n" + "═"*60)260    print("AGENT 3 — Underwriting Model Training")261    print("═"*60)262 263    df   = load_bronze_uw_data()264    X    = extract_uw_features(df)265    y    = df["uw_label"]266 267    FEATURES = X.columns.tolist()268    print(f"\nFeatures ({len(FEATURES)}): {FEATURES}")269 270    X_train, X_test, y_train, y_test = train_test_split(271        X, y, test_size=0.2, stratify=y, random_state=42272    )273 274    model = xgb.XGBClassifier(275        n_estimators          = 300,276        max_depth             = 5,277        learning_rate         = 0.04,278        subsample             = 0.80,279        colsample_bytree      = 0.75,280        reg_alpha             = 0.1,281        reg_lambda            = 1.2,282        eval_metric           = "auc",283        early_stopping_rounds = 20,284        random_state          = 42,285        verbosity             = 0,286    )287    model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)288 289    y_prob = model.predict_proba(X_test)[:, 1]290    y_pred = model.predict(X_test)291    auc    = roc_auc_score(y_test, y_prob)292 293    print(f"\n  ROC-AUC : {auc:.4f}")294    print(f"  Gini    : {2*auc-1:.4f}")295    print("\n  Classification Report:")296    print(classification_report(y_test, y_pred, target_names=["UW_DECLINED", "UW_APPROVED"]))297 298    imp = pd.Series(model.feature_importances_, index=FEATURES).sort_values(ascending=False)299    print("\n  Top Feature Importances:")300    for feat, val in imp.head(8).items():301        print(f"    {feat:<35} {val:.4f}")302 303    artefact = {304        "model":      model,305        "features":   FEATURES,306        "thresholds": {"uw_approve_threshold": 0.50, "referral_band": (0.40, 0.65)},307        "trained_at": datetime.datetime.now().isoformat(),308        "version":    "1.0",309    }310    with open(MODEL_PATH, "wb") as f:311        pickle.dump(artefact, f)312    print(f"\n  Model saved → {MODEL_PATH}")313    return artefact314 315# ─── RULE ENGINE ─────────────────────────────────────────────────────────────316def apply_uw_rules(submission_json: dict, property_risk: dict) -> list:317    """318    Hard underwriting rules — checked BEFORE the ML model.319    Returns a list of rule-violation strings (empty = all rules passed).320    """321    violations = []322    prop    = submission_json.get("property", {})323    policy  = submission_json.get("policy_request", {})324    insured = submission_json.get("insured", {})325 326    limit      = float(policy.get("limit", 0) or 0)327    deductible = float(policy.get("deductible", 0) or 0)328    cov_type   = policy.get("coverage_type", "HO-3")329    prop_type  = prop.get("property_type", "Single Family")330    state      = prop.get("state", "XX").upper()331    credit     = float(insured.get("credit_score", 650) or 650)332    year_built = int(prop.get("year_built", 1990) or 1990)333    roof_year  = int(prop.get("roof_year", 2010) or 2010)334    roof_age   = 2024 - roof_year335 336    # ① Coverage-property type mismatch337    eligible = COVERAGE_ELIGIBILITY.get(cov_type, {})338    eligible_types = eligible.get("eligible_types", [])339    if eligible_types and prop_type not in eligible_types:340        violations.append(341            f"UW-RULE-01: {cov_type} is not eligible for property type '{prop_type}'. "342            f"Eligible types: {eligible_types}"343        )344 345    # ② Minimum credit for coverage type346    min_credit = eligible.get("min_credit", 550)347    if credit < min_credit:348        violations.append(349            f"UW-RULE-02: Credit score {credit:.0f} below minimum {min_credit} for {cov_type}"350        )351 352    # ③ Deductible exceeds 10% of coverage limit353    if limit > 0 and (deductible / limit * 100) > DEDUCTIBLE_MAX_PCT:354        violations.append(355            f"UW-RULE-03: Deductible ${deductible:,.0f} exceeds {DEDUCTIBLE_MAX_PCT}% "356            f"of coverage limit ${limit:,.0f}"357        )358 359    # ④ Reinsurance threshold360    if limit > REINSURANCE_THRESHOLD:361        violations.append(362            f"UW-RULE-04: Coverage limit ${limit:,.0f} exceeds reinsurance threshold "363            f"${REINSURANCE_THRESHOLD:,.0f}. Manual reinsurance sign-off required. → UW_REFERRAL"364        )365 366    # ⑤ Property age > 75 years with Frame construction367    if (2024 - year_built) > 75 and prop.get("construction_type") == "Frame":368        violations.append(369            f"UW-RULE-05: Frame construction property built {year_built} "370            f"({2024-year_built} years old) exceeds 75-year guideline"371        )372 373    # ⑥ Roof age > 25 years374    if roof_age > 25:375        violations.append(376            f"UW-RULE-06: Roof age {roof_age} years exceeds 25-year maximum. "377            f"Roof replacement or inspection report required."378        )379 380    # ⑦ State max limit check381    state_rules = STATE_RULES.get(state, {})382    state_max   = state_rules.get("max_limit", 10_000_000)383    if limit > state_max:384        violations.append(385            f"UW-RULE-07: Coverage limit ${limit:,.0f} exceeds {state} state maximum "386            f"${state_max:,.0f}"387        )388 389    # ⑧ High wind area with wood shake roof390    wind_score = property_risk.get("peril_scores", {}).get("wind_score", 0) if property_risk else 0391    if wind_score > 55 and prop.get("roof_type") == "Wood Shake":392        violations.append(393            f"UW-RULE-08: Wood Shake roof not eligible in high-wind zones "394            f"(wind score {wind_score:.0f}/100)"395        )396 397    return violations398 399# ─── INFERENCE ───────────────────────────────────────────────────────────────400def run_underwriting_agent(property_risk: dict, submission_json: dict) -> dict:401    """402    Parameters403    ----------404    property_risk   : dict  Output from Agent 2 (silver/property_risk/)405    submission_json : dict  Full Bronze JSON payload406 407    Returns408    -------409    dict  UW decision written to silver/uw_decisions/410    """411    sub_id = submission_json.get("submission_id", "UNKNOWN")412 413    # Guard: skip if property risk declined414    if property_risk.get("status") in ("RISK_DECLINED", "SKIPPED"):415        return {"submission_id": sub_id, "status": "SKIPPED",416                "skip_reason": "RISK_DECLINED — pipeline halted at Step 2"}417 418    # ── Rule engine (hard gates first) ──419    rule_violations = apply_uw_rules(submission_json, property_risk)420 421    # Separate referrals (reinsurance) from outright declines422    referrals = [v for v in rule_violations if "REFERRAL" in v]423    declines  = [v for v in rule_violations if "REFERRAL" not in v]424 425    if declines:426        return _build_uw_output(sub_id, "UW_DECLINED", declines[0], rule_violations,427                                 None, None, submission_json, property_risk)428 429    if referrals:430        return _build_uw_output(sub_id, "UW_REFERRAL", referrals[0], rule_violations,431                                 None, None, submission_json, property_risk)432 433    # ── ML model ──434    prop   = submission_json.get("property", {})435    policy = submission_json.get("policy_request", {})436    peril  = property_risk.get("peril_scores", {})437 438    row = pd.DataFrame([{439        "credit_score":           submission_json.get("insured", {}).get("credit_score", 650),440        "requested_coverage_limit": policy.get("limit", 300_000),441        "requested_deductible":   policy.get("deductible", 1_000),442        "coverage_type_code":     policy.get("coverage_type", "HO-3"),443        "year_built":             prop.get("year_built", 1990),444        "roof_year":              prop.get("roof_year",  2010),445        "square_footage":         prop.get("square_footage", 1800),446    }])447    risk_row = pd.DataFrame([peril]) if peril else None448 449    feats = extract_uw_features(row, risk_row)450 451    try:452        with open(MODEL_PATH, "rb") as f:453            art = pickle.load(f)454        uw_prob = float(art["model"].predict_proba(feats[art["features"]])[0, 1])455        thres   = art["thresholds"]["uw_approve_threshold"]456        ref_lo, ref_hi = art["thresholds"]["referral_band"]457    except FileNotFoundError:458        uw_prob, thres, ref_lo, ref_hi = 0.80, 0.50, 0.40, 0.65459 460    if uw_prob >= thres:461        return _build_uw_output(sub_id, "UW_APPROVED", None, [], uw_prob, thres,462                                 submission_json, property_risk)463    elif ref_lo <= uw_prob < thres:464        return _build_uw_output(sub_id, "UW_REFERRAL",465                                 f"ML-UW-009 — Model probability {uw_prob:.2%} in referral band. Manual review required.",466                                 [], uw_prob, thres, submission_json, property_risk)467    else:468        return _build_uw_output(sub_id, "UW_DECLINED",469                                 f"ML-UW-010 — Underwriting model probability {uw_prob:.2%} below threshold {thres:.0%}",470                                 [], uw_prob, thres, submission_json, property_risk)471 472def _build_uw_output(sub_id, status, primary_reason, all_violations, uw_prob, threshold,473                      submission_json, property_risk):474    policy = submission_json.get("policy_request", {}) if submission_json else {}475    cov    = policy.get("coverage_type", "HO-3")476    output = {477        "submission_id":        sub_id,478        "agent":                "Underwriting_Agent",479        "step":                 3,480        "status":               status,         # UW_APPROVED | UW_DECLINED | UW_REFERRAL481        "decision":             "APPROVE" if status == "UW_APPROVED" else ("REFERRAL" if "REFERRAL" in status else "DECLINE"),482        "primary_decline_reason": primary_reason,483        "all_rule_violations":  all_violations,484        "uw_approval_probability": round(uw_prob, 4) if uw_prob is not None else None,485        "decision_threshold":   threshold,486        "coverage_type":        cov,487        "expected_loss_ratio":  EXPECTED_LOSS_RATIO.get(cov, 0.60),488        "reinsurance_required": float(policy.get("limit", 0) or 0) > REINSURANCE_THRESHOLD,489        "processed_at":         datetime.datetime.now().isoformat(),490        "next_step":            "ML_Pricing_Agent" if status == "UW_APPROVED" else "PIPELINE_HALTED",491        "s3_output_uri":        f"s3://pcins-silver/uw_decisions/{sub_id}_uw.json",492    }493    out_file = SILVER_OUT / f"{sub_id}_uw.json"494    with open(out_file, "w") as f:495        json.dump(output, f, indent=2)496    return output497 498# ─── MAIN ────────────────────────────────────────────────────────────────────499if __name__ == "__main__":500    train_uw_model()501 502    print("\n" + "─"*60)503    print("SMOKE TESTS")504    print("─"*60)505 506    prop_risk_pass = {"status": "RISK_ACCEPTABLE", "risk_band": "LOW",507                      "peril_scores": {"wind_score": 30, "flood_score": 25, "fire_score": 20, "overall_risk": 28}}508 509    tests = [510        {   # Should APPROVE — clean profile511            "sub": {"submission_id": "SUB-TEST-001", "insured": {"credit_score": 760},512                    "property": {"state": "PA", "property_type": "Single Family",513                                 "construction_type": "Masonry", "roof_type": "Tile",514                                 "year_built": 2005, "roof_year": 2005, "square_footage": 2200},515                    "policy_request": {"coverage_type": "HO-3", "limit": 380_000, "deductible": 2_500}},516        },517        {   # Should DECLINE — roof age 32 years518            "sub": {"submission_id": "SUB-TEST-002", "insured": {"credit_score": 680},519                    "property": {"state": "TX", "property_type": "Single Family",520                                 "construction_type": "Frame", "roof_type": "Asphalt Shingles",521                                 "year_built": 1960, "roof_year": 1992, "square_footage": 1600},522                    "policy_request": {"coverage_type": "HO-3", "limit": 220_000, "deductible": 1_500}},523        },524        {   # Should REFERRAL — above reinsurance threshold525            "sub": {"submission_id": "SUB-TEST-003", "insured": {"credit_score": 810},526                    "property": {"state": "NY", "property_type": "Single Family",527                                 "construction_type": "Masonry", "roof_type": "Slate",528                                 "year_built": 2018, "roof_year": 2018, "square_footage": 6000},529                    "policy_request": {"coverage_type": "HO-5", "limit": 3_500_000, "deductible": 10_000}},530        },531    ]532 533    for t in tests:534        r = run_underwriting_agent(prop_risk_pass, t["sub"])535        print(f"  {t['sub']['submission_id']} | {t['sub']['policy_request']['coverage_type']} "536              f"| Limit ${t['sub']['policy_request']['limit']:,.0f} "537              f"→ {r['status']}  {r['primary_decline_reason'] or ''}")538