PCAgentinAI / agent3_underwriting.py
ITNovaML's picture
initial deploy - PolicyBridge multi-agent pipeline
b5d2527
Raw
History Blame Contribute Delete
27.1 kB
"""
══════════════════════════════════════════════════════════════════════════════
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 ''}")