ITNovaML/PCAgentinAI
0
1"""2agent6_audit.py — AXIOM: The Audit Agent3=========================================4Agent 6 in the underwriting pipeline.5Reads all 5 agent outputs for a given submission and generates:6 - A human-readable plain-English audit summary (ACCEPT or DECLINE)7 - Key decision factors (what went right / what triggered decline)8 - A structured audit record stored in the Gold layer DB table9 10Runs AFTER NOVA (Agent 5) has issued or declined the policy.11Called by: app.py pipeline orchestrator (or independently via /audit/<submission_id>)12 13DB Table: gold_audit_log14"""15 16import os17import json18import logging19from datetime import datetime20from sqlalchemy import text21from db import get_engine # reuse the singleton DB engine from your existing db.py22 23logger = logging.getLogger(__name__)24 25# ──────────────────────────────────────────────────────────────────────────────26# DB HELPERS27# ──────────────────────────────────────────────────────────────────────────────28 29def _fetch_submission(engine, submission_id: int) -> dict | None:30 """Pull the full submission row from bronze layer."""31 with engine.connect() as conn:32 row = conn.execute(33 text("SELECT * FROM bronze_submissions WHERE id = :sid"),34 {"sid": submission_id}35 ).mappings().fetchone()36 return dict(row) if row else None37 38 39def _fetch_kyc(engine, submission_id: int) -> dict | None:40 """AURA output — silver_kyc_results."""41 with engine.connect() as conn:42 row = conn.execute(43 text("SELECT * FROM silver_kyc_results WHERE submission_id = :sid ORDER BY id DESC LIMIT 1"),44 {"sid": submission_id}45 ).mappings().fetchone()46 return dict(row) if row else None47 48 49def _fetch_property_risk(engine, submission_id: int) -> dict | None:50 """TERRA output — silver_property_risk."""51 with engine.connect() as conn:52 row = conn.execute(53 text("SELECT * FROM silver_property_risk WHERE submission_id = :sid ORDER BY id DESC LIMIT 1"),54 {"sid": submission_id}55 ).mappings().fetchone()56 return dict(row) if row else None57 58 59def _fetch_underwriting(engine, submission_id: int) -> dict | None:60 """KARMA output — silver_underwriting_decisions."""61 with engine.connect() as conn:62 row = conn.execute(63 text("SELECT * FROM silver_underwriting_decisions WHERE submission_id = :sid ORDER BY id DESC LIMIT 1"),64 {"sid": submission_id}65 ).mappings().fetchone()66 return dict(row) if row else None67 68 69def _fetch_pricing(engine, submission_id: int) -> dict | None:70 """AURUM output — silver_pricing_results."""71 with engine.connect() as conn:72 row = conn.execute(73 text("SELECT * FROM silver_pricing_results WHERE submission_id = :sid ORDER BY id DESC LIMIT 1"),74 {"sid": submission_id}75 ).mappings().fetchone()76 return dict(row) if row else None77 78 79def _fetch_issuance(engine, submission_id: int) -> dict | None:80 """NOVA output — gold_policies."""81 with engine.connect() as conn:82 row = conn.execute(83 text("SELECT * FROM gold_policies WHERE submission_id = :sid ORDER BY id DESC LIMIT 1"),84 {"sid": submission_id}85 ).mappings().fetchone()86 return dict(row) if row else None87 88 89# ──────────────────────────────────────────────────────────────────────────────90# AUDIT SUMMARY BUILDER91# ──────────────────────────────────────────────────────────────────────────────92 93def _build_audit_summary(94 submission: dict,95 kyc: dict | None,96 risk: dict | None,97 uw: dict | None,98 pricing: dict | None,99 issuance: dict | None,100) -> dict:101 """102 Generates the plain-English audit summary and decision factors.103 Returns a dict with:104 - decision : "APPROVED" | "DECLINED"105 - overall_summary : 2-3 sentence plain-English explanation106 - decision_factors : list of factor dicts {agent, factor, outcome, detail}107 - decline_reasons : list of plain-English decline reasons (empty if approved)108 - risk_band : LOW / MEDIUM / HIGH / DECLINED109 - final_premium : numeric or None110 - policy_number : string or None111 - audit_score : 0-100 composite confidence score112 """113 factors = []114 decline_reasons = []115 116 # ── AURA: KYC & Compliance ────────────────────────────────────────────────117 if kyc:118 kyc_pass = kyc.get("kyc_passed", True)119 ofac_clear = kyc.get("ofac_clear", True)120 fraud_score = float(kyc.get("fraud_score", 0.0))121 credit_score = int(kyc.get("credit_score", 700))122 123 kyc_outcome = "PASS" if kyc_pass else "FAIL"124 factors.append({125 "agent": "Document Validation Agent",126 "factor": "Identity & Compliance Check",127 "outcome": kyc_outcome,128 "detail": (129 f"SSN verified. Credit score: {credit_score}. "130 f"OFAC: {'Clear' if ofac_clear else 'HIT — FLAGGED'}. "131 f"Fraud signal score: {fraud_score:.2f} "132 f"({'Low risk' if fraud_score < 0.3 else 'Elevated risk' if fraud_score < 0.6 else 'HIGH RISK'})."133 )134 })135 136 if not kyc_pass:137 decline_reasons.append(138 f"Document validation failed: applicant did not pass identity or compliance screening "139 f"(fraud signal: {fraud_score:.2f}, OFAC clear: {ofac_clear})."140 )141 if not ofac_clear:142 decline_reasons.append(143 "OFAC/sanctions screening returned a hit. Policy cannot be issued under US federal compliance rules."144 )145 146 # ── TERRA: Property Risk ──────────────────────────────────────────────────147 if risk:148 risk_band = risk.get("risk_band", "MEDIUM")149 wind_score = float(risk.get("wind_score", 0.5))150 flood_score = float(risk.get("flood_score", 0.5))151 fire_score = float(risk.get("fire_score", 0.5))152 overall_risk = float(risk.get("overall_risk_score", 0.5))153 154 risk_outcome = "PASS" if risk_band not in ("HIGH", "DECLINED") else "REFER" if risk_band == "HIGH" else "DECLINE"155 factors.append({156 "agent": "Property Risk Agent",157 "factor": "Peril Risk Assessment",158 "outcome": risk_outcome,159 "detail": (160 f"Overall risk band: {risk_band}. "161 f"Wind: {wind_score:.2f} | Flood: {flood_score:.2f} | Fire: {fire_score:.2f}. "162 f"Composite risk score: {overall_risk:.2f}/1.00."163 )164 })165 166 if risk_band == "DECLINED":167 decline_reasons.append(168 f"Property risk score ({overall_risk:.2f}) exceeds maximum threshold. "169 f"One or more perils (wind: {wind_score:.2f}, flood: {flood_score:.2f}, fire: {fire_score:.2f}) "170 f"are outside acceptable underwriting parameters."171 )172 173 # ── KARMA: Underwriting Decision ─────────────────────────────────────────174 if uw:175 uw_decision = uw.get("decision", "APPROVED")176 uw_confidence = float(uw.get("confidence", 0.8))177 uw_reason = uw.get("reason_code", "")178 shap_top = uw.get("shap_top_feature", "credit_score")179 180 uw_outcome = "APPROVED" if uw_decision == "APPROVED" else "DECLINED"181 factors.append({182 "agent": "Underwriting Agent",183 "factor": "AI Underwriting Decision",184 "outcome": uw_outcome,185 "detail": (186 f"Decision: {uw_decision} (confidence: {uw_confidence:.1%}). "187 f"Primary decision driver (SHAP): {shap_top}. "188 f"Reason code: {uw_reason if uw_reason else 'Standard approval criteria met'}."189 )190 })191 192 if uw_decision != "APPROVED":193 decline_reasons.append(194 f"Underwriting model returned a DECLINE decision with {uw_confidence:.1%} confidence. "195 f"Primary factor: {shap_top}. Reason: {uw_reason or 'risk profile outside binding authority guidelines'}."196 )197 198 # ── AURUM: Pricing ────────────────────────────────────────────────────────199 if pricing:200 base_premium = float(pricing.get("base_premium", 0))201 final_premium = float(pricing.get("final_premium", 0))202 credit_mod = float(pricing.get("credit_modifier", 1.0))203 risk_mod = float(pricing.get("risk_band_modifier", 1.0))204 confidence_low = float(pricing.get("confidence_low", final_premium * 0.9))205 confidence_high = float(pricing.get("confidence_high", final_premium * 1.1))206 207 factors.append({208 "agent": "Pricing Agent",209 "factor": "Actuarial Premium Calculation",210 "outcome": "CALCULATED",211 "detail": (212 f"Base premium: ${base_premium:,.2f}. "213 f"Credit modifier: {credit_mod:.2f}x | Risk band modifier: {risk_mod:.2f}x. "214 f"Final premium: ${final_premium:,.2f} "215 f"(95% CI: ${confidence_low:,.2f} – ${confidence_high:,.2f})."216 )217 })218 else:219 final_premium = None220 221 # ── NOVA: Issuance ────────────────────────────────────────────────────────222 policy_number = None223 if issuance:224 policy_number = issuance.get("policy_number")225 issued_at = issuance.get("issued_at", "")226 coverage_amount = issuance.get("coverage_amount", 0)227 228 factors.append({229 "agent": "Issuance Agent",230 "factor": "Policy Issuance",231 "outcome": "ISSUED" if policy_number else "NOT ISSUED",232 "detail": (233 f"Policy number: {policy_number or 'N/A'}. "234 f"Coverage: ${float(coverage_amount or 0):,.2f}. "235 f"Issued at: {issued_at}."236 )237 })238 239 # ── Final decision ────────────────────────────────────────────────────────240 decision = "APPROVED" if not decline_reasons else "DECLINED"241 if uw and uw.get("decision") != "APPROVED":242 decision = "DECLINED"243 244 # ── Audit confidence score (0-100) ────────────────────────────────────────245 score_components = []246 if kyc:247 score_components.append(100 if kyc.get("kyc_passed") else 0)248 if risk:249 band_scores = {"LOW": 100, "MEDIUM": 70, "HIGH": 30, "DECLINED": 0}250 score_components.append(band_scores.get(risk.get("risk_band", "MEDIUM"), 50))251 if uw:252 score_components.append(int(float(uw.get("confidence", 0.5)) * 100))253 audit_score = int(sum(score_components) / len(score_components)) if score_components else 50254 255 # ── Plain-English overall summary ─────────────────────────────────────────256 insured_name = submission.get("insured_name", "the applicant") if submission else "the applicant"257 property_addr = submission.get("property_address", "the insured property") if submission else "the insured property"258 risk_band_str = risk.get("risk_band", "MEDIUM") if risk else "UNKNOWN"259 260 if decision == "APPROVED":261 overall_summary = (262 f"The application from {insured_name} for {property_addr} has been approved. "263 f"The property was assessed as {risk_band_str} risk across all perils, "264 f"identity and compliance screening passed with no flags, "265 f"and the underwriting model approved the risk at {uw.get('confidence', 0.8):.1%} confidence. "266 f"A final premium of ${final_premium:,.2f} has been calculated and the policy has been issued "267 f"under policy number {policy_number}."268 ) if final_premium and policy_number else (269 f"The application from {insured_name} has been approved with a {risk_band_str} risk classification. "270 f"All compliance, risk and underwriting checks passed successfully."271 )272 else:273 reason_summary = " ".join(decline_reasons[:2]) # top 2 reasons274 overall_summary = (275 f"The application from {insured_name} for {property_addr} has been declined. "276 f"{reason_summary} "277 f"The pipeline processed all {len(factors)} agent checks before reaching this decision."278 )279 280 return {281 "decision": decision,282 "overall_summary": overall_summary,283 "decision_factors": factors,284 "decline_reasons": decline_reasons,285 "risk_band": risk_band_str,286 "final_premium": final_premium,287 "policy_number": policy_number,288 "audit_score": audit_score,289 }290 291 292# ──────────────────────────────────────────────────────────────────────────────293# DB WRITE — gold_audit_log294# ──────────────────────────────────────────────────────────────────────────────295 296def _save_audit_record(engine, submission_id: int, audit: dict) -> int:297 """298 Insert audit record into gold_audit_log.299 Returns the new audit record ID.300 """301 with engine.begin() as conn:302 result = conn.execute(303 text("""304 INSERT INTO gold_audit_log (305 submission_id,306 decision,307 overall_summary,308 decision_factors_json,309 decline_reasons_json,310 risk_band,311 final_premium,312 policy_number,313 audit_score,314 audited_at315 ) VALUES (316 :submission_id,317 :decision,318 :overall_summary,319 :decision_factors_json,320 :decline_reasons_json,321 :risk_band,322 :final_premium,323 :policy_number,324 :audit_score,325 :audited_at326 )327 """),328 {329 "submission_id": submission_id,330 "decision": audit["decision"],331 "overall_summary": audit["overall_summary"],332 "decision_factors_json": json.dumps(audit["decision_factors"]),333 "decline_reasons_json": json.dumps(audit["decline_reasons"]),334 "risk_band": audit.get("risk_band"),335 "final_premium": audit.get("final_premium"),336 "policy_number": audit.get("policy_number"),337 "audit_score": audit.get("audit_score", 0),338 "audited_at": datetime.utcnow(),339 }340 )341 return result.lastrowid342 343 344# ──────────────────────────────────────────────────────────────────────────────345# MAIN ENTRY POINT346# ──────────────────────────────────────────────────────────────────────────────347 348def run_audit_agent(submission_id: int) -> dict:349 """350 Main function. Call this from app.py after NOVA completes.351 352 Usage in app.py pipeline:353 from agent6_audit import run_audit_agent354 audit_result = run_audit_agent(submission_id)355 356 Returns full audit dict including plain-English summary,357 decision factors, and the saved audit_log_id.358 """359 logger.info(f"[AXIOM] Starting audit for submission_id={submission_id}")360 361 engine = get_engine()362 363 # Fetch all agent outputs364 submission = _fetch_submission(engine, submission_id)365 kyc = _fetch_kyc(engine, submission_id)366 risk = _fetch_property_risk(engine, submission_id)367 uw = _fetch_underwriting(engine, submission_id)368 pricing = _fetch_pricing(engine, submission_id)369 issuance = _fetch_issuance(engine, submission_id)370 371 if not submission:372 logger.warning(f"[AXIOM] Submission {submission_id} not found.")373 return {"error": f"Submission {submission_id} not found."}374 375 # Build the audit summary376 audit = _build_audit_summary(submission, kyc, risk, uw, pricing, issuance)377 378 # Save to gold_audit_log379 try:380 audit_log_id = _save_audit_record(engine, submission_id, audit)381 audit["audit_log_id"] = audit_log_id382 logger.info(f"[AXIOM] Audit saved — id={audit_log_id}, decision={audit['decision']}")383 except Exception as e:384 logger.error(f"[AXIOM] Failed to save audit record: {e}")385 audit["audit_log_id"] = None386 audit["save_error"] = str(e)387 388 return audit389 390 391# ──────────────────────────────────────────────────────────────────────────────392# FETCH EXISTING AUDIT (for UI display)393# ──────────────────────────────────────────────────────────────────────────────394 395def get_audit_for_submission(submission_id: int) -> dict | None:396 """397 Fetch the most recent audit record for a submission.398 Use this in the Flask route to display the audit panel in the UI.399 """400 engine = get_engine()401 with engine.connect() as conn:402 row = conn.execute(403 text("""404 SELECT * FROM gold_audit_log405 WHERE submission_id = :sid406 ORDER BY audited_at DESC407 LIMIT 1408 """),409 {"sid": submission_id}410 ).mappings().fetchone()411 412 if not row:413 return None414 415 record = dict(row)416 # Parse JSON columns back417 record["decision_factors"] = json.loads(record.get("decision_factors_json") or "[]")418 record["decline_reasons"] = json.loads(record.get("decline_reasons_json") or "[]")419 return record420 