ITNovaML/PCAgentinAI
0
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 