"""
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''
f'{decision.upper()}'
)
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 = '✓ Yes'
elif action:
verified = 'No'
else:
verified = "—"
rows += (
f'
\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"""
RingGuard — Fraud Detection Dashboard
Click any row to view the full evidence bundle, LLM narrative, and audit chain.
"""
@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''
)
drawn_strong += 1
elif drawn_weak < weak_link_count:
lines.append(
f''
)
drawn_weak += 1
nodes = []
for i, (px, py) in enumerate(positions):
label = customer_ids[i].replace("CUST", "C")
nodes.append(
f''
f''
f'{label}'
)
legend = (
f''
f''
f'Strong link (device/instrument)'
f''
f''
f'Weak link (address)'
)
formation_label = formation_type.replace("_", " ").title() if formation_type else ""
svg = (
f''
)
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 "No narrative available for this case.")
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'
{label}
{display}
'
audit_rows_html = ""
for rec in audit_records:
ts = rec.get("timestamp", "")[:19].replace("T", " ")
audit_rows_html += (
f'