Spaces:
Sleeping
Sleeping
| """ | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 ''}") | |