ringguard / api /main.py
Rohit Patil
fix(dashboard): resolve action_taken from audit log, improve UI
09a4647
Raw History Blame Contribute Delete
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
# ---------------------------------------------------------------------------
@app.get("/health")
def health():
return {"status": "ok", "version": "3.0.0"}
@app.get("/cases", response_class=JSONResponse)
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
@app.get("/cases/{case_id}", response_class=JSONResponse)
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> &middot; RingGuard v3.0</div>
</div>
</body>
</html>"""
@app.get("/", response_class=HTMLResponse)
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
@app.get("/cases/{case_id}/detail", response_class=HTMLResponse)
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="/">&#8592; Back to all cases</a>
<h1>{case_id} &mdash; {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> &middot; RingGuard v3.0</div>
</div>
</body>
</html>"""
return HTMLResponse(content=html)