Download api/main.py from OGrohit/ringguard: direct link, hf CLI and curl.
- Browser
- Download file 21.2 kB
-
https://huggingface.co/spaces/OGrohit/ringguard/resolve/main/api/main.py
- Command line
-
hf download hf://spaces/OGrohit/ringguard/api/main.py
-
curl -L -o main.py https://huggingface.co/spaces/OGrohit/ringguard/resolve/main/api/main.py
21.2 kB
| """ | |
| main.py | |
| ------- | |
| Phase 3 Section 5 — FastAPI dashboard for RingGuard. | |
| Endpoints: | |
| GET / — HTML dashboard: table of all cases | |
| GET /cases — JSON list of all cases (from cases_demo.json) | |
| GET /cases/{case_id} — JSON detail for one case + full audit chain | |
| GET /health — health check | |
| The dashboard reads directly from cases_demo.json and audit_log.jsonl. | |
| No database, no auth — demo only. | |
| Usage: | |
| uvicorn api.main:app --reload --port 7860 | |
| """ | |
| import json | |
| import os | |
| from typing import Optional | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| CASES_PATH = os.path.join(BASE_DIR, "data", "synthetic", "cases_demo.json") | |
| AUDIT_PATH = os.path.join(BASE_DIR, "audit", "audit_log.jsonl") | |
| app = FastAPI( | |
| title="RingGuard Dashboard", | |
| description="Phase 3 — Fraud ring detection results and audit trail", | |
| version="3.0.0", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Data loaders | |
| # --------------------------------------------------------------------------- | |
| def _load_cases() -> list: | |
| if not os.path.exists(CASES_PATH): | |
| return [] | |
| with open(CASES_PATH, encoding="utf-8") as f: | |
| return json.load(f) | |
| def _load_audit() -> list: | |
| if not os.path.exists(AUDIT_PATH): | |
| return [] | |
| records = [] | |
| with open(AUDIT_PATH, encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if line: | |
| records.append(json.loads(line)) | |
| return records | |
| # --------------------------------------------------------------------------- | |
| # API endpoints | |
| # --------------------------------------------------------------------------- | |
| def health(): | |
| return {"status": "ok", "version": "3.0.0"} | |
| def get_cases(): | |
| """Return all cases as a JSON list.""" | |
| cases = _load_cases() | |
| audit_all = _load_audit() | |
| # Build a lookup: case_id -> first audit record | |
| audit_map = {} | |
| for r in audit_all: | |
| cid = r.get("case_id") | |
| if cid and cid not in audit_map: | |
| audit_map[cid] = r | |
| # Return a summary view (without full narrative for list) | |
| summary = [] | |
| for c in cases: | |
| case_id = c.get("case_id") | |
| # Prefer action_taken from audit record when case has null | |
| action = c.get("action_taken") or audit_map.get(case_id, {}).get("action_taken") or {} | |
| summary.append({ | |
| "case_id": case_id, | |
| "cluster_id": c.get("cluster_id"), | |
| "cluster_size": len(c.get("customer_ids", [])), | |
| "risk_score": round(c.get("risk_probability", 0), 4), | |
| "policy_decision": c.get("policy_decision"), | |
| "action_taken": bool(action.get("order_id")), | |
| "timestamp": c.get("timestamp"), | |
| }) | |
| return summary | |
| def get_case_detail(case_id: str): | |
| """Return full detail for one case, including audit chain.""" | |
| cases = _load_cases() | |
| case = next((c for c in cases if c.get("case_id") == case_id), None) | |
| if not case: | |
| raise HTTPException(status_code=404, detail=f"Case {case_id} not found") | |
| # Find audit records for this case | |
| audit_records = [r for r in _load_audit() if r.get("case_id") == case_id] | |
| return { | |
| "case": case, | |
| "audit_chain": audit_records, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # HTML dashboard | |
| # --------------------------------------------------------------------------- | |
| def _decision_badge(decision: str) -> str: | |
| colours = { | |
| "auto_hold": "#c0392b", | |
| "escalate": "#e67e22", | |
| "log_only": "#27ae60", | |
| } | |
| colour = colours.get(decision, "#7f8c8d") | |
| return ( | |
| f'<span style="background:{colour};color:#fff;padding:2px 8px;' | |
| f'border-radius:4px;font-size:11px;font-weight:600;">' | |
| f'{decision.upper()}</span>' | |
| ) | |
| def _build_dashboard(cases: list, audit_map: dict = None) -> str: | |
| if audit_map is None: | |
| audit_map = {} | |
| rows = "" | |
| for c in cases: | |
| decision = c.get("policy_decision", "") | |
| badge = _decision_badge(decision) | |
| risk = round(c.get("risk_probability", 0) * 100, 1) | |
| cluster = c.get("cluster_id", "") | |
| case_id = c.get("case_id", "") | |
| size = len(c.get("customer_ids", [])) | |
| # Prefer action_taken from audit record when case JSON has null | |
| action = c.get("action_taken") or audit_map.get(case_id, {}).get("action_taken") or {} | |
| order_id = action.get("order_id") or "—" | |
| verified_val = action.get("notes_verified") | |
| if verified_val is True: | |
| verified = '<span style="color:#27ae60;font-weight:600">✓ Yes</span>' | |
| elif action: | |
| verified = '<span style="color:#c0392b">No</span>' | |
| else: | |
| verified = "—" | |
| rows += ( | |
| f'<tr onclick="window.location=\'/cases/{case_id}/detail\'" style="cursor:pointer">' | |
| f'<td><a href="/cases/{case_id}/detail" style="color:#1f2328;font-weight:500">{case_id}</a></td>' | |
| f'<td style="color:#57606a">{cluster}</td>' | |
| f'<td style="text-align:center">{size}</td>' | |
| f'<td style="text-align:right;font-weight:600">{risk}%</td>' | |
| f'<td>{badge}</td>' | |
| f'<td style="font-size:11px;font-family:monospace;color:#57606a">{order_id}</td>' | |
| f'<td style="text-align:center">{verified}</td>' | |
| f'</tr>\n' | |
| ) | |
| auto_holds = sum(1 for c in cases if c.get("policy_decision") == "auto_hold") | |
| escalates = sum(1 for c in cases if c.get("policy_decision") == "escalate") | |
| log_only = sum(1 for c in cases if c.get("policy_decision") == "log_only") | |
| return f"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>RingGuard — Fraud Detection Dashboard</title> | |
| <style> | |
| * {{ box-sizing: border-box; margin: 0; padding: 0; }} | |
| body {{ font-family: -apple-system, "Segoe UI", system-ui, sans-serif; | |
| font-size: 14px; line-height: 1.6; background: #f7f8fa; color: #1f2328; }} | |
| .container {{ max-width: 960px; margin: 0 auto; padding: 24px 16px; }} | |
| h1 {{ font-size: 22px; font-weight: 700; margin-bottom: 4px; }} | |
| .subtitle {{ color: #57606a; font-size: 13px; margin-bottom: 24px; }} | |
| .stats {{ display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }} | |
| .stat-card {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; | |
| padding: 12px 20px; flex: 1; min-width: 140px; }} | |
| .stat-card .label {{ font-size: 11px; color: #57606a; text-transform: uppercase; | |
| letter-spacing: 0.05em; }} | |
| .stat-card .value {{ font-size: 24px; font-weight: 700; }} | |
| .stat-card.hold .value {{ color: #c0392b; }} | |
| .stat-card.escalate .value {{ color: #e67e22; }} | |
| .stat-card.log .value {{ color: #27ae60; }} | |
| table {{ width: 100%; border-collapse: collapse; background: #fff; | |
| border: 1px solid #e5e7eb; border-radius: 6px; overflow: hidden; }} | |
| th {{ background: #f7f8fa; border-bottom: 2px solid #e5e7eb; padding: 8px 12px; | |
| text-align: left; font-size: 12px; font-weight: 600; color: #57606a; | |
| text-transform: uppercase; letter-spacing: 0.04em; }} | |
| td {{ padding: 8px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }} | |
| tr:last-child td {{ border-bottom: none; }} | |
| tr:hover td {{ background: #f7f8fa; }} | |
| .footer {{ text-align: center; font-size: 12px; color: #57606a; margin-top: 32px; | |
| padding-top: 16px; border-top: 1px solid #e5e7eb; }} | |
| a {{ color: #3b82d4; text-decoration: none; }} | |
| a:hover {{ text-decoration: underline; }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>RingGuard — Fraud Detection Dashboard</h1> | |
| <p class="subtitle">Phase 3 · {len(cases)} cases · LightGBM + LangGraph + Razorpay test-mode</p> | |
| <div class="stats"> | |
| <div class="stat-card hold"> | |
| <div class="label">Auto Hold</div> | |
| <div class="value">{auto_holds}</div> | |
| </div> | |
| <div class="stat-card escalate"> | |
| <div class="label">Escalate</div> | |
| <div class="value">{escalates}</div> | |
| </div> | |
| <div class="stat-card log"> | |
| <div class="label">Log Only</div> | |
| <div class="value">{log_only}</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="label">Total Cases</div> | |
| <div class="value">{len(cases)}</div> | |
| </div> | |
| </div> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Case</th> | |
| <th>Cluster</th> | |
| <th style="text-align:center">Size</th> | |
| <th style="text-align:right">Risk Score</th> | |
| <th>Decision</th> | |
| <th>Razorpay Order</th> | |
| <th style="text-align:center">Verified</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {rows} </tbody> | |
| </table> | |
| <p style="margin-top:12px;font-size:12px;color:#57606a"> | |
| Click any row to view the full evidence bundle, LLM narrative, and audit chain. | |
| </p> | |
| <div class="footer">Built by <a href="https://github.com/rohitdecodes" target="_blank">Rohit</a> · RingGuard v3.0</div> | |
| </div> | |
| </body> | |
| </html>""" | |
| def dashboard(): | |
| """Serve the HTML dashboard.""" | |
| cases = _load_cases() | |
| audit_all = _load_audit() | |
| audit_map = {} | |
| for r in audit_all: | |
| cid = r.get("case_id") | |
| if cid and cid not in audit_map: | |
| audit_map[cid] = r | |
| html = _build_dashboard(cases, audit_map) | |
| return HTMLResponse(content=html) | |
| def _build_cluster_svg(customer_ids: list, strong_link_count: int, weak_link_count: int, | |
| formation_type: str, decision: str) -> str: | |
| """ | |
| Build a simple SVG diagram of the cluster: | |
| - One circle per customer, arranged in a ring layout | |
| - Red lines for strong links (shared device/instrument), grey dashed for weak (address) | |
| - Node colour reflects decision | |
| """ | |
| import math | |
| n = len(customer_ids) | |
| if n == 0: | |
| return "" | |
| W, H = 420, 260 | |
| cx, cy = W // 2, H // 2 | |
| r = min(cx, cy) - 44 | |
| node_colour = { | |
| "auto_hold": "#c0392b", | |
| "escalate": "#e67e22", | |
| "log_only": "#27ae60", | |
| }.get(decision, "#7c5cd8") | |
| # Place nodes on a circle; single node goes centre | |
| angles = [2 * math.pi * i / n - math.pi / 2 for i in range(n)] | |
| positions = [(cx + r * math.cos(a), cy + r * math.sin(a)) for a in angles] if n > 1 else [(cx, cy)] | |
| lines = [] | |
| # Strong edges between every pair (fully connected if strong_link_count == n*(n-1)/2) | |
| # For simplicity: draw strong edges between consecutive nodes to represent links | |
| # Real topology is cluster-wide; we show it proportionally | |
| total_possible = n * (n - 1) // 2 if n > 1 else 0 | |
| drawn_strong = 0 | |
| drawn_weak = 0 | |
| if total_possible > 0: | |
| for i in range(n): | |
| for j in range(i + 1, n): | |
| x1, y1 = positions[i] | |
| x2, y2 = positions[j] | |
| if drawn_strong < strong_link_count: | |
| lines.append( | |
| f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" ' | |
| f'stroke="#c0392b" stroke-width="2" opacity="0.7"/>' | |
| ) | |
| drawn_strong += 1 | |
| elif drawn_weak < weak_link_count: | |
| lines.append( | |
| f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" ' | |
| f'stroke="#adb5bd" stroke-width="1.5" stroke-dasharray="5,3" opacity="0.6"/>' | |
| ) | |
| drawn_weak += 1 | |
| nodes = [] | |
| for i, (px, py) in enumerate(positions): | |
| label = customer_ids[i].replace("CUST", "C") | |
| nodes.append( | |
| f'<circle cx="{px:.1f}" cy="{py:.1f}" r="20" fill="{node_colour}" ' | |
| f'stroke="#fff" stroke-width="2" opacity="0.92"/>' | |
| f'<text x="{px:.1f}" y="{py + 4:.1f}" text-anchor="middle" ' | |
| f'font-size="9" fill="#fff" font-family="system-ui,sans-serif" font-weight="600">' | |
| f'{label}</text>' | |
| ) | |
| legend = ( | |
| f'<rect x="12" y="{H-38}" width="10" height="3" fill="#c0392b" rx="1"/>' | |
| f'<text x="26" y="{H-32}" font-size="10" fill="#57606a" font-family="system-ui,sans-serif">' | |
| f'Strong link (device/instrument)</text>' | |
| f'<line x1="12" y1="{H-22}" x2="22" y2="{H-22}" stroke="#adb5bd" ' | |
| f'stroke-width="1.5" stroke-dasharray="4,2"/>' | |
| f'<text x="26" y="{H-18}" font-size="10" fill="#57606a" font-family="system-ui,sans-serif">' | |
| f'Weak link (address)</text>' | |
| ) | |
| formation_label = formation_type.replace("_", " ").title() if formation_type else "" | |
| svg = ( | |
| f'<svg width="{W}" height="{H}" viewBox="0 0 {W} {H}" ' | |
| f'xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;">' | |
| f'{"".join(lines)}' | |
| f'{"".join(nodes)}' | |
| f'<text x="{W//2}" y="16" text-anchor="middle" font-size="11" ' | |
| f'fill="#57606a" font-family="system-ui,sans-serif">{formation_label} · ' | |
| f'{strong_link_count} strong · {weak_link_count} weak</text>' | |
| f'{legend}' | |
| f'</svg>' | |
| ) | |
| return svg | |
| def case_detail_html(case_id: str): | |
| """Serve HTML detail page for a single case.""" | |
| cases = _load_cases() | |
| case = next((c for c in cases if c.get("case_id") == case_id), None) | |
| if not case: | |
| raise HTTPException(status_code=404, detail=f"Case {case_id} not found") | |
| audit_records = [r for r in _load_audit() if r.get("case_id") == case_id] | |
| decision = case.get("policy_decision", "") | |
| badge = _decision_badge(decision) | |
| risk = round(case.get("risk_probability", 0) * 100, 2) | |
| # Prefer audit narrative/confidence when case JSON has null | |
| audit_rec0 = audit_records[0] if audit_records else {} | |
| narrative = (case.get("llm_narrative") or audit_rec0.get("llm_narrative") | |
| or "<em>No narrative available for this case.</em>") | |
| confidence = (case.get("llm_confidence_statement") or | |
| audit_rec0.get("llm_confidence_statement") or "—") | |
| members = ", ".join(case.get("customer_ids", [])) | |
| # Prefer action_taken from audit record when case JSON has null | |
| action = case.get("action_taken") or audit_rec0.get("action_taken") or {} | |
| order_id = action.get("order_id") or "—" | |
| verified = "Yes" if action.get("notes_verified") else "No" | |
| tool_used = action.get("tool") or "—" | |
| timestamp = action.get("timestamp") or "—" | |
| # Pull graph score inputs from audit record for the cluster diagram | |
| audit_rec = audit_rec0 | |
| score_inputs = audit_rec.get("graph_score_inputs", {}) | |
| strong_links = int(score_inputs.get("strong_link_count", case.get("strong_link_count", 0))) | |
| weak_links = int(score_inputs.get("weak_link_count", case.get("weak_link_count", 0))) | |
| # Resolve formation type label from strong_link_count | |
| formation_label = "strong_anchored" if strong_links > 0 else "weak_only" | |
| cluster_svg = _build_cluster_svg( | |
| case.get("customer_ids", []), strong_links, weak_links, formation_label, decision | |
| ) | |
| # Feature table rows from audit | |
| feature_keys = [ | |
| ("order_count", "Order Count"), ("total_amount", "Total Amount (Rs.)"), | |
| ("avg_order_amount", "Avg Order Amount (Rs.)"), ("return_rate", "Return Rate"), | |
| ("chargeback_rate", "Chargeback Rate"), ("shared_coupon_count", "Shared Coupons"), | |
| ("order_velocity", "Order Velocity (orders/day)"), ("time_span_days", "Time Span (days)"), | |
| ] | |
| feature_rows_html = "" | |
| if score_inputs: | |
| for key, label in feature_keys: | |
| val = score_inputs.get(key) | |
| if val is not None: | |
| if isinstance(val, float): | |
| display = f"{val:.3f}" if val < 100 else f"{val:,.0f}" | |
| else: | |
| display = str(val) | |
| feature_rows_html += f'<tr><td style="color:#57606a">{label}</td><td style="font-weight:500">{display}</td></tr>' | |
| audit_rows_html = "" | |
| for rec in audit_records: | |
| ts = rec.get("timestamp", "")[:19].replace("T", " ") | |
| audit_rows_html += ( | |
| f'<tr>' | |
| f'<td>{ts}</td>' | |
| f'<td>{rec.get("policy_decision","")}</td>' | |
| f'<td style="font-size:11px">{rec.get("reason_if_not_auto_actioned") or "—"}</td>' | |
| f'</tr>\n' | |
| ) | |
| html = f"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>RingGuard — {case_id}</title> | |
| <style> | |
| * {{ box-sizing: border-box; margin: 0; padding: 0; }} | |
| body {{ font-family: -apple-system, "Segoe UI", system-ui, sans-serif; | |
| font-size: 14px; line-height: 1.6; background: #f7f8fa; color: #1f2328; }} | |
| .container {{ max-width: 800px; margin: 0 auto; padding: 24px 16px; }} | |
| h1 {{ font-size: 20px; font-weight: 700; margin-bottom: 4px; }} | |
| .back {{ font-size: 13px; color: #3b82d4; margin-bottom: 16px; display: block; }} | |
| .card {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; | |
| padding: 16px; margin-bottom: 16px; }} | |
| .card h2 {{ font-size: 12px; font-weight: 600; color: #57606a; text-transform: uppercase; | |
| letter-spacing: 0.06em; margin-bottom: 10px; border-bottom: 1px solid #f0f0f0; | |
| padding-bottom: 6px; }} | |
| .row {{ display: flex; gap: 24px; margin-bottom: 6px; flex-wrap: wrap; }} | |
| .kv {{ flex: 1; min-width: 160px; }} | |
| .kv .k {{ font-size: 11px; color: #57606a; margin-bottom: 2px; }} | |
| .kv .v {{ font-weight: 500; }} | |
| .narrative {{ background: #f7f8fa; border-left: 3px solid #3b82d4; | |
| padding: 10px 14px; border-radius: 0 4px 4px 0; font-style: italic; | |
| line-height: 1.7; }} | |
| .graph-wrap {{ display: flex; justify-content: center; padding: 8px 0 4px; }} | |
| table {{ width: 100%; border-collapse: collapse; }} | |
| th {{ text-align: left; font-size: 11px; color: #57606a; padding: 5px 8px; | |
| border-bottom: 1px solid #e5e7eb; text-transform: uppercase; letter-spacing: 0.04em; }} | |
| td {{ padding: 5px 8px; border-bottom: 1px solid #f0f0f0; font-size: 13px; }} | |
| tr:last-child td {{ border-bottom: none; }} | |
| .footer {{ text-align: center; font-size: 12px; color: #57606a; margin-top: 32px; | |
| padding-top: 16px; border-top: 1px solid #e5e7eb; }} | |
| span.badge {{ display: inline-block; }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <a class="back" href="/">← Back to all cases</a> | |
| <h1>{case_id} — {case.get("cluster_id","")}</h1> | |
| <div class="card"> | |
| <h2>Summary</h2> | |
| <div class="row"> | |
| <div class="kv"><div class="k">Risk Score</div><div class="v">{risk}%</div></div> | |
| <div class="kv"><div class="k">Decision</div><div class="v">{badge}</div></div> | |
| <div class="kv"><div class="k">Cluster Size</div><div class="v">{len(case.get("customer_ids",[]))} accounts</div></div> | |
| <div class="kv"><div class="k">Formation</div><div class="v">{formation_label.replace("_"," ")}</div></div> | |
| </div> | |
| <div style="margin-top:6px;font-size:12px;color:#57606a"> | |
| <strong>Members:</strong> {members} | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>Cluster Graph</h2> | |
| <div class="graph-wrap">{cluster_svg}</div> | |
| </div> | |
| <div class="card"> | |
| <h2>Evidence — Key Features</h2> | |
| {f'<table><tbody>{feature_rows_html}</tbody></table>' if feature_rows_html else '<p style="color:#57606a;font-size:13px">No feature data available.</p>'} | |
| </div> | |
| <div class="card"> | |
| <h2>LLM Investigator Narrative</h2> | |
| <div class="narrative">{narrative}</div> | |
| <div style="margin-top:8px;font-size:12px;color:#57606a"> | |
| <strong>Confidence:</strong> {confidence} | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>Razorpay Action (Test Mode)</h2> | |
| <div class="row"> | |
| <div class="kv"><div class="k">Order ID</div><div class="v" style="font-size:12px;font-family:monospace">{order_id}</div></div> | |
| <div class="kv"><div class="k">Notes Verified</div><div class="v">{verified}</div></div> | |
| </div> | |
| <div class="row" style="margin-top:4px"> | |
| <div class="kv"><div class="k">Tool Used</div><div class="v" style="font-size:11px">{tool_used}</div></div> | |
| <div class="kv"><div class="k">Timestamp</div><div class="v" style="font-size:12px">{timestamp[:19] if len(timestamp)>=19 else timestamp}</div></div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>Audit Chain</h2> | |
| {f'<table><thead><tr><th>Timestamp</th><th>Decision</th><th>Reason</th></tr></thead><tbody>{audit_rows_html}</tbody></table>' if audit_rows_html else '<p style="color:#57606a;font-size:13px">No audit records found for this case.</p>'} | |
| </div> | |
| <div class="footer">Built by <a href="https://github.com/rohitdecodes" target="_blank">Rohit</a> · RingGuard v3.0</div> | |
| </div> | |
| </body> | |
| </html>""" | |
| return HTMLResponse(content=html) | |