CoolFace
Apppublic

ITNovaML/PCAgentinAI

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
agent6_audit.py420 linesDownload Raw Back to root
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