"""Natural-language explanation of a scored transaction. Tries an LLM through Hugging Face Inference Providers when an ``HF_TOKEN`` secret is configured on the Space; otherwise falls back to a deterministic rule-based narrative built from the same SHAP values, so the app always works. """ import os from model import FEATURE_FMT, FEATURE_LABELS LLM_MODEL = os.environ.get("LLM_MODEL", "meta-llama/Llama-3.1-8B-Instruct") def risk_band(p: float) -> tuple[str, str]: """Return (band name, recommended action).""" if p < 0.05: return "LOW", "Approve normally." if p < 0.30: return "ELEVATED", "Approve, but flag the account for passive monitoring." if p < 0.70: return "HIGH", "Trigger step-up authentication (3-D Secure / OTP) before approving." return "CRITICAL", "Decline and route to manual fraud review immediately." def _ranked(shap_values: dict) -> list[tuple[str, float]]: return sorted(shap_values.items(), key=lambda kv: abs(kv[1]), reverse=True) def _describe(feature: str, inputs: dict) -> str: return f"{FEATURE_LABELS[feature].lower()} = {FEATURE_FMT[feature](inputs[feature])}" def template_explanation(result: dict) -> str: """Deterministic analyst-style narrative from the SHAP ranking.""" p = result["probability"] band, action = risk_band(p) ranked = _ranked(result["shap_values"]) inputs = result["inputs"] drivers = [(f, v) for f, v in ranked if v > 0.05][:3] mitigators = [(f, v) for f, v in ranked if v < -0.05][:2] lines = [ f"**Verdict:** this transaction scores **{p:.1%}** fraud probability — **{band}** risk." ] if drivers: parts = [_describe(f, inputs) for f, _ in drivers] lines.append( "The score is driven mainly by " + (", ".join(parts[:-1]) + " and " + parts[-1] if len(parts) > 1 else parts[0]) + "." ) if mitigators: parts = [_describe(f, inputs) for f, _ in mitigators] lines.append( "Working in the customer's favour: " + (" and ".join(parts)) + "." ) lines.append(f"**Recommended action:** {action}") return "\n\n".join(lines) def llm_explanation(result: dict) -> tuple[str, str]: """Return (markdown_text, source) where source is 'llm' or 'rules'.""" token = os.environ.get("HF_TOKEN") if not token: return template_explanation(result), "rules" try: from huggingface_hub import InferenceClient p = result["probability"] band, action = risk_band(p) ranked = _ranked(result["shap_values"]) inputs = result["inputs"] shap_lines = "\n".join( f"- {FEATURE_LABELS[f]} = {FEATURE_FMT[f](inputs[f])}: SHAP {v:+.3f}" for f, v in ranked ) prompt = ( f"A gradient-boosted fraud model scored a card transaction at " f"{p:.1%} fraud probability ({band} risk; policy action: {action}).\n" f"SHAP contributions in log-odds (positive pushes toward fraud):\n" f"{shap_lines}\n\n" "Write a fraud analyst's explanation in 3–5 sentences: state the " "verdict, explain the top risk drivers in plain language, mention " "any mitigating factors, and end with the recommended action. " "Do not mention SHAP or log-odds; talk about the transaction itself." ) client = InferenceClient(api_key=token) out = client.chat_completion( model=LLM_MODEL, messages=[ { "role": "system", "content": "You are a senior fraud analyst writing concise, factual case notes.", }, {"role": "user", "content": prompt}, ], max_tokens=350, temperature=0.3, ) text = out.choices[0].message.content.strip() if not text: raise ValueError("empty completion") return text, "llm" except Exception: return template_explanation(result), "rules"