Spaces:
Runtime error
Runtime error
adithjayakrishnan commited on
Commit ·
d7b4e58
1
Parent(s): 998e2d5
changed
Browse files- api/routes/alerts.py +91 -0
- api/routes/graph_api.py +69 -0
- api/routes/investigation.py +45 -0
- api/routes/model.py +15 -0
- api/routes/overview.py +54 -0
- api/routes/report.py +48 -0
- config.yaml +102 -0
- debug_startup.py +43 -0
- frontend/index.html +153 -0
- frontend/js/api.js +64 -0
- frontend/pages/alerts.html +244 -0
- frontend/pages/investigation.html +342 -0
- frontend/pages/model.html +209 -0
- frontend/pages/overview.html +177 -0
- requirements.txt +18 -13
- run_server.bat +10 -0
- server.py +146 -0
- server_log.txt +0 -0
- src/config_loader.py +17 -0
- src/data_loader.py +82 -93
- src/detectors/alert_engine.py +47 -62
- src/detectors/dormancy.py +100 -72
- src/detectors/layering.py +75 -40
- src/detectors/mule_network.py +58 -33
- src/detectors/round_tripping.py +118 -71
- src/detectors/structuring.py +80 -74
- src/graph_builder.py +111 -80
- src/ml/explainer.py +27 -19
- src/ml/features.py +57 -37
- src/ml/gnn_predictor.py +80 -0
- src/ml/gnn_trainer.py +121 -0
- src/ml/predictor.py +71 -25
- src/ml/trainer.py +75 -91
- src/persistence.py +111 -0
- src/reporter.py +163 -193
- src/state.py +12 -0
api/routes/alerts.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Query, HTTPException
|
| 2 |
+
from fastapi.responses import Response
|
| 3 |
+
from typing import Optional
|
| 4 |
+
import csv
|
| 5 |
+
import io
|
| 6 |
+
from src.state import AppState
|
| 7 |
+
from src.persistence import get_all_alert_states, update_alert_state
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
@router.get("/alerts")
|
| 12 |
+
async def get_alerts(
|
| 13 |
+
typology: Optional[str] = None,
|
| 14 |
+
risk_tier: Optional[str] = None,
|
| 15 |
+
account: Optional[str] = None,
|
| 16 |
+
sort: str = "risk_desc",
|
| 17 |
+
show_dismissed: bool = False
|
| 18 |
+
):
|
| 19 |
+
alerts = AppState.alerts
|
| 20 |
+
states = get_all_alert_states()
|
| 21 |
+
|
| 22 |
+
filtered = []
|
| 23 |
+
for a in alerts:
|
| 24 |
+
aid = a['alert_id']
|
| 25 |
+
state = states.get(aid, {})
|
| 26 |
+
|
| 27 |
+
# Merge state into alert for frontend
|
| 28 |
+
a['is_dismissed'] = bool(state.get('dismissed', 0))
|
| 29 |
+
a['is_confirmed'] = bool(state.get('confirmed', 0))
|
| 30 |
+
|
| 31 |
+
if not show_dismissed and a['is_dismissed']:
|
| 32 |
+
continue
|
| 33 |
+
|
| 34 |
+
if typology and typology != "All Typologies" and typology not in a['typology']:
|
| 35 |
+
continue
|
| 36 |
+
|
| 37 |
+
if risk_tier and risk_tier != "All Risk Tiers":
|
| 38 |
+
if risk_tier == "Critical Only" and a['risk_tier'] != 'CRITICAL':
|
| 39 |
+
continue
|
| 40 |
+
if risk_tier == "High & Critical" and a['risk_tier'] not in ['CRITICAL', 'HIGH']:
|
| 41 |
+
continue
|
| 42 |
+
|
| 43 |
+
if account and account.lower() not in a['account'].lower():
|
| 44 |
+
continue
|
| 45 |
+
|
| 46 |
+
filtered.append(a)
|
| 47 |
+
|
| 48 |
+
if sort == "risk_desc":
|
| 49 |
+
filtered.sort(key=lambda x: x['risk_score'], reverse=True)
|
| 50 |
+
elif sort == "newest":
|
| 51 |
+
filtered.sort(key=lambda x: x['timestamp_detected'], reverse=True)
|
| 52 |
+
elif sort == "amount_desc":
|
| 53 |
+
filtered.sort(key=lambda x: x['amount_involved'], reverse=True)
|
| 54 |
+
|
| 55 |
+
return filtered
|
| 56 |
+
|
| 57 |
+
@router.patch("/alerts/{alert_id}/dismiss")
|
| 58 |
+
async def dismiss_alert(alert_id: str):
|
| 59 |
+
update_alert_state(alert_id, dismissed=1)
|
| 60 |
+
return {"status": "success"}
|
| 61 |
+
|
| 62 |
+
@router.patch("/alerts/{alert_id}/confirm")
|
| 63 |
+
async def confirm_alert(alert_id: str):
|
| 64 |
+
update_alert_state(alert_id, confirmed=1)
|
| 65 |
+
return {"status": "success"}
|
| 66 |
+
|
| 67 |
+
@router.get("/alerts/export")
|
| 68 |
+
async def export_alerts():
|
| 69 |
+
alerts = AppState.alerts
|
| 70 |
+
|
| 71 |
+
output = io.StringIO()
|
| 72 |
+
writer = csv.writer(output)
|
| 73 |
+
writer.writerow(['Alert ID', 'Account', 'Typology', 'Risk Score', 'Risk Tier', 'Amount Involved', 'Tx Count', 'Timestamp', 'Explanation'])
|
| 74 |
+
|
| 75 |
+
for a in alerts:
|
| 76 |
+
writer.writerow([
|
| 77 |
+
a.get('alert_id', ''),
|
| 78 |
+
a.get('account', ''),
|
| 79 |
+
a.get('typology', ''),
|
| 80 |
+
a.get('risk_score', ''),
|
| 81 |
+
a.get('risk_tier', ''),
|
| 82 |
+
a.get('amount_involved', ''),
|
| 83 |
+
a.get('tx_count', ''),
|
| 84 |
+
a.get('timestamp_detected', ''),
|
| 85 |
+
a.get('explanation', '')
|
| 86 |
+
])
|
| 87 |
+
|
| 88 |
+
response = Response(content=output.getvalue())
|
| 89 |
+
response.headers["Content-Disposition"] = "attachment; filename=alerts_export.csv"
|
| 90 |
+
response.headers["Content-Type"] = "text/csv"
|
| 91 |
+
return response
|
api/routes/graph_api.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Query, HTTPException
|
| 2 |
+
from typing import Optional
|
| 3 |
+
import networkx as nx
|
| 4 |
+
from src.state import AppState
|
| 5 |
+
from src.graph_builder import get_subgraph, compute_louvain
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
|
| 9 |
+
@router.get("/account/{account_id}/graph")
|
| 10 |
+
async def get_account_graph(
|
| 11 |
+
account_id: str,
|
| 12 |
+
depth: int = Query(2, ge=1, le=5),
|
| 13 |
+
max_nodes: int = Query(100, ge=10, le=500)
|
| 14 |
+
):
|
| 15 |
+
G = AppState.graph
|
| 16 |
+
fdf = AppState.full_features
|
| 17 |
+
|
| 18 |
+
if account_id not in G:
|
| 19 |
+
raise HTTPException(status_code=404, detail="Account not found in graph")
|
| 20 |
+
|
| 21 |
+
subG = get_subgraph(G, account_id, hops=depth, max_nodes=max_nodes)
|
| 22 |
+
|
| 23 |
+
if subG is None or subG.number_of_nodes() == 0:
|
| 24 |
+
return {"nodes": [], "edges": []}
|
| 25 |
+
|
| 26 |
+
# Recompute Louvain just for this subgraph to get local communities for coloring
|
| 27 |
+
local_communities = compute_louvain(0, subG)
|
| 28 |
+
|
| 29 |
+
nodes_data = []
|
| 30 |
+
edges_data = []
|
| 31 |
+
|
| 32 |
+
for n in subG.nodes():
|
| 33 |
+
row = fdf[fdf['account'] == n]
|
| 34 |
+
if row.empty:
|
| 35 |
+
risk = 0
|
| 36 |
+
pr = 0
|
| 37 |
+
is_fraud = False
|
| 38 |
+
else:
|
| 39 |
+
risk = int(row.iloc[0].get('risk_score', 0))
|
| 40 |
+
pr = float(row.iloc[0].get('pagerank_score', 0))
|
| 41 |
+
is_fraud = bool(row.iloc[0].get('fraud_flag', 0))
|
| 42 |
+
|
| 43 |
+
nodes_data.append({
|
| 44 |
+
"data": {
|
| 45 |
+
"id": n,
|
| 46 |
+
"label": n,
|
| 47 |
+
"risk_score": risk,
|
| 48 |
+
"pagerank": pr,
|
| 49 |
+
"is_fraud": is_fraud,
|
| 50 |
+
"community": local_communities.get(n, 0),
|
| 51 |
+
"is_target": (n == account_id)
|
| 52 |
+
}
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
for u, v, d in subG.edges(data=True):
|
| 56 |
+
edges_data.append({
|
| 57 |
+
"data": {
|
| 58 |
+
"source": u,
|
| 59 |
+
"target": v,
|
| 60 |
+
"amount": float(d.get('amount', 0)),
|
| 61 |
+
"tx_count": int(d.get('tx_count', 1)),
|
| 62 |
+
"payment_type": d.get('payment_type', 'Unknown'),
|
| 63 |
+
}
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"nodes": nodes_data,
|
| 68 |
+
"edges": edges_data
|
| 69 |
+
}
|
api/routes/investigation.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from src.state import AppState
|
| 4 |
+
from src.ml.explainer import explain_prediction
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
|
| 8 |
+
@router.get("/account/{account_id}")
|
| 9 |
+
async def get_account_details(account_id: str):
|
| 10 |
+
fdf = AppState.full_features
|
| 11 |
+
df = AppState.df
|
| 12 |
+
alerts = AppState.alerts
|
| 13 |
+
|
| 14 |
+
row = fdf[fdf['account'] == account_id]
|
| 15 |
+
if row.empty:
|
| 16 |
+
raise HTTPException(status_code=404, detail="Account not found in feature set")
|
| 17 |
+
|
| 18 |
+
metrics = row.iloc[0].to_dict()
|
| 19 |
+
|
| 20 |
+
# Get relevant transactions
|
| 21 |
+
txns = df[(df['source'] == account_id) | (df['target'] == account_id)].copy()
|
| 22 |
+
txns['timestamp'] = txns['timestamp'].astype(str)
|
| 23 |
+
|
| 24 |
+
# Get relevant alerts
|
| 25 |
+
acct_alerts = [a for a in alerts if a['account'] == account_id]
|
| 26 |
+
|
| 27 |
+
# Generate SHAP explanation on the fly
|
| 28 |
+
shap_expl = explain_prediction(account_id, AppState.full_features, AppState.xgb_bundle, top_n=5)
|
| 29 |
+
|
| 30 |
+
return {
|
| 31 |
+
"account_id": account_id,
|
| 32 |
+
"metrics": {
|
| 33 |
+
"total_sent": metrics.get('amount_sent_total', 0),
|
| 34 |
+
"total_received": metrics.get('amount_received_total', 0),
|
| 35 |
+
"tx_count": metrics.get('tx_count_total', 0),
|
| 36 |
+
"risk_score": metrics.get('risk_score', 0),
|
| 37 |
+
"gnn_risk_score": metrics.get('gnn_fraud_score', 0),
|
| 38 |
+
"fraud_probability": metrics.get('fraud_probability', 0),
|
| 39 |
+
"pagerank": metrics.get('pagerank_score', 0),
|
| 40 |
+
"betweenness": metrics.get('betweenness_score', 0),
|
| 41 |
+
},
|
| 42 |
+
"transactions": txns.to_dict(orient="records"),
|
| 43 |
+
"alerts": acct_alerts,
|
| 44 |
+
"shap_explanation": shap_expl
|
| 45 |
+
}
|
api/routes/model.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, BackgroundTasks
|
| 2 |
+
from src.state import AppState
|
| 3 |
+
|
| 4 |
+
router = APIRouter()
|
| 5 |
+
|
| 6 |
+
@router.get("/model/metrics")
|
| 7 |
+
async def get_model_metrics():
|
| 8 |
+
metrics = AppState.model_metrics or {}
|
| 9 |
+
gnn_metrics = AppState.gnn_metrics or {}
|
| 10 |
+
|
| 11 |
+
return {
|
| 12 |
+
"status": "active",
|
| 13 |
+
"xgb_metrics": metrics,
|
| 14 |
+
"gnn_metrics": gnn_metrics
|
| 15 |
+
}
|
api/routes/overview.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from src.state import AppState
|
| 3 |
+
|
| 4 |
+
router = APIRouter()
|
| 5 |
+
|
| 6 |
+
@router.get("/overview")
|
| 7 |
+
async def get_overview():
|
| 8 |
+
fdf = AppState.full_features
|
| 9 |
+
df = AppState.df
|
| 10 |
+
alerts = AppState.alerts
|
| 11 |
+
|
| 12 |
+
tot_tx = len(df)
|
| 13 |
+
flagged_tx = len(df[df['is_laundering'] == 1])
|
| 14 |
+
tot_vol = float(df['amount'].sum())
|
| 15 |
+
active_accts = len(fdf)
|
| 16 |
+
|
| 17 |
+
crit_alerts = len([a for a in alerts if a['risk_score'] >= 75])
|
| 18 |
+
|
| 19 |
+
# Generate Typology Counts
|
| 20 |
+
typo_counts = {}
|
| 21 |
+
for a in alerts:
|
| 22 |
+
# Split merged typologies
|
| 23 |
+
types = [t.strip() for t in a['typology'].split(',')]
|
| 24 |
+
for t in types:
|
| 25 |
+
typo_counts[t] = typo_counts.get(t, 0) + 1
|
| 26 |
+
|
| 27 |
+
# Channel Stats
|
| 28 |
+
channel_counts = df['payment_type'].value_counts()
|
| 29 |
+
fraud_channel_counts = df[df['is_laundering'] == 1]['payment_type'].value_counts()
|
| 30 |
+
|
| 31 |
+
channel_stats = []
|
| 32 |
+
for p_type, count in channel_counts.items():
|
| 33 |
+
channel_stats.append({
|
| 34 |
+
'channel': p_type,
|
| 35 |
+
'count': int(count),
|
| 36 |
+
'fraud_count': int(fraud_channel_counts.get(p_type, 0))
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
# Graph Stats
|
| 40 |
+
nodes = AppState.graph.number_of_nodes()
|
| 41 |
+
edges = AppState.graph.number_of_edges()
|
| 42 |
+
|
| 43 |
+
return {
|
| 44 |
+
"total_transactions": tot_tx,
|
| 45 |
+
"flagged_transactions": flagged_tx,
|
| 46 |
+
"total_volume": tot_vol,
|
| 47 |
+
"active_accounts": active_accts,
|
| 48 |
+
"alerts_generated": len(alerts),
|
| 49 |
+
"critical_alerts": crit_alerts,
|
| 50 |
+
"model_auc": AppState.model_metrics.get('auc_roc', 0.0) if AppState.model_metrics else 0.0,
|
| 51 |
+
"typology_counts": typo_counts,
|
| 52 |
+
"channel_stats": channel_stats,
|
| 53 |
+
"graph_stats": {"nodes": nodes, "edges": edges}
|
| 54 |
+
}
|
api/routes/report.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from fastapi.responses import Response
|
| 3 |
+
from src.state import AppState
|
| 4 |
+
from src.ml.explainer import explain_prediction
|
| 5 |
+
from src.reporter import generate_pdf_report
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
|
| 9 |
+
@router.get("/account/{account_id}/report")
|
| 10 |
+
async def get_str_report(account_id: str):
|
| 11 |
+
fdf = AppState.full_features
|
| 12 |
+
df = AppState.df
|
| 13 |
+
alerts = AppState.alerts
|
| 14 |
+
|
| 15 |
+
row = fdf[fdf['account'] == account_id]
|
| 16 |
+
if row.empty:
|
| 17 |
+
raise HTTPException(status_code=404, detail="Account not found in feature set")
|
| 18 |
+
|
| 19 |
+
metrics = row.iloc[0].to_dict()
|
| 20 |
+
|
| 21 |
+
# Get relevant transactions
|
| 22 |
+
txns = df[(df['source'] == account_id) | (df['target'] == account_id)].copy()
|
| 23 |
+
|
| 24 |
+
# Get relevant alerts
|
| 25 |
+
acct_alerts = [a for a in alerts if a['account'] == account_id]
|
| 26 |
+
|
| 27 |
+
# Generate SHAP explanation
|
| 28 |
+
shap_expl = explain_prediction(account_id, AppState.full_features, AppState.xgb_bundle, top_n=5)
|
| 29 |
+
|
| 30 |
+
gnn_score = metrics.get('gnn_fraud_score', None)
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
pdf_bytes = generate_pdf_report(
|
| 34 |
+
account_id=account_id,
|
| 35 |
+
account_txns=txns,
|
| 36 |
+
features=metrics,
|
| 37 |
+
alerts=acct_alerts,
|
| 38 |
+
ml_explanation=shap_expl,
|
| 39 |
+
gnn_score=gnn_score
|
| 40 |
+
)
|
| 41 |
+
except Exception as e:
|
| 42 |
+
raise HTTPException(status_code=500, detail=f"Failed to generate PDF: {str(e)}")
|
| 43 |
+
|
| 44 |
+
response = Response(content=pdf_bytes)
|
| 45 |
+
response.headers["Content-Disposition"] = f'attachment; filename="STR_{account_id}.pdf"'
|
| 46 |
+
response.headers["Content-Type"] = "application/pdf"
|
| 47 |
+
|
| 48 |
+
return response
|
config.yaml
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# Fund Flow Tracker - Central Configuration
|
| 3 |
+
# All thresholds, paths, and flags in one place
|
| 4 |
+
|
| 5 |
+
data:
|
| 6 |
+
raw_path: "data/raw/HI_Small_Trans.csv"
|
| 7 |
+
processed_path: "data/processed/node_features.parquet"
|
| 8 |
+
transactions_path: "data/processed/transactions.parquet"
|
| 9 |
+
alerts_db_path: "data/alerts.db"
|
| 10 |
+
min_amount_threshold: 0
|
| 11 |
+
|
| 12 |
+
detectors:
|
| 13 |
+
# Structuring / Smurfing
|
| 14 |
+
structuring_threshold: 100000
|
| 15 |
+
structuring_lower_pct: 0.85
|
| 16 |
+
structuring_window_days: 7
|
| 17 |
+
structuring_min_tx_count: 2
|
| 18 |
+
structuring_check_target: true
|
| 19 |
+
|
| 20 |
+
# Layering (rapid pass-through)
|
| 21 |
+
layering_forward_ratio: 0.80
|
| 22 |
+
layering_time_window_hours: 48
|
| 23 |
+
layering_min_forward_amount: 10000
|
| 24 |
+
|
| 25 |
+
# Round-Tripping (cycles)
|
| 26 |
+
cycle_min_amount: 50000
|
| 27 |
+
cycle_max_length: 6
|
| 28 |
+
cycle_min_length: 2
|
| 29 |
+
cycle_detection_timeout_seconds: 30
|
| 30 |
+
|
| 31 |
+
# Dormancy
|
| 32 |
+
dormancy_gap_days: 90 # account is "dormant" if gap > this
|
| 33 |
+
recent_period_days: 3
|
| 34 |
+
activation_multiplier: 10
|
| 35 |
+
min_activation_amount: 50000
|
| 36 |
+
check_target_dormancy: true
|
| 37 |
+
|
| 38 |
+
# Mule Networks
|
| 39 |
+
mule_min_community_size: 5
|
| 40 |
+
mule_velocity_threshold: 0.50
|
| 41 |
+
mule_fraud_ratio_threshold: 0.30
|
| 42 |
+
|
| 43 |
+
alert_engine:
|
| 44 |
+
critical_threshold: 75
|
| 45 |
+
high_threshold: 50
|
| 46 |
+
medium_threshold: 30
|
| 47 |
+
suppress_hours: 24 # suppress duplicate typology alerts per account
|
| 48 |
+
|
| 49 |
+
graph:
|
| 50 |
+
pagerank_alpha: 0.85
|
| 51 |
+
pagerank_max_iter: 100
|
| 52 |
+
high_value_edge_threshold: 50000
|
| 53 |
+
max_subgraph_nodes: 150
|
| 54 |
+
|
| 55 |
+
ml:
|
| 56 |
+
model_path: "models/xgb_fraud_model.ubj"
|
| 57 |
+
scaler_path: "models/xgb_scaler.pkl"
|
| 58 |
+
gnn_model_path: "models/gnn_model.pt"
|
| 59 |
+
feature_cols:
|
| 60 |
+
- tx_count_total
|
| 61 |
+
- tx_count_7d
|
| 62 |
+
- amount_sent_total
|
| 63 |
+
- amount_sent_7d
|
| 64 |
+
- amount_received_total
|
| 65 |
+
- amount_received_7d
|
| 66 |
+
- forward_ratio
|
| 67 |
+
- avg_tx_amount
|
| 68 |
+
- amount_std
|
| 69 |
+
- in_out_ratio
|
| 70 |
+
- pagerank_score
|
| 71 |
+
- in_degree
|
| 72 |
+
- out_degree
|
| 73 |
+
- fan_in_ratio
|
| 74 |
+
- community_encoded
|
| 75 |
+
- cycle_length
|
| 76 |
+
- cycle_max_amount
|
| 77 |
+
- account_age_days
|
| 78 |
+
- days_since_last_tx
|
| 79 |
+
- currency_diversity
|
| 80 |
+
- channel_diversity
|
| 81 |
+
- bank_diversity
|
| 82 |
+
- betweenness_score
|
| 83 |
+
- velocity_ratio_7d
|
| 84 |
+
test_size: 0.15
|
| 85 |
+
val_size: 0.15
|
| 86 |
+
random_state: 42
|
| 87 |
+
n_estimators: 300
|
| 88 |
+
max_depth: 6
|
| 89 |
+
learning_rate: 0.05
|
| 90 |
+
subsample: 0.8
|
| 91 |
+
colsample: 0.8
|
| 92 |
+
early_stopping: 20
|
| 93 |
+
gnn_hidden_dim: 64
|
| 94 |
+
gnn_epochs: 50
|
| 95 |
+
gnn_lr: 0.01
|
| 96 |
+
|
| 97 |
+
server:
|
| 98 |
+
host: "0.0.0.0"
|
| 99 |
+
port: 8000
|
| 100 |
+
frontend_dir: "frontend"
|
| 101 |
+
|
| 102 |
+
demo_mode: true # gates confirmed_fraud field; set false in production
|
debug_startup.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import traceback
|
| 3 |
+
|
| 4 |
+
try:
|
| 5 |
+
print("1. Loading config...")
|
| 6 |
+
from src.config_loader import get_config
|
| 7 |
+
get_config()
|
| 8 |
+
|
| 9 |
+
print("2. Importing pipeline...")
|
| 10 |
+
from src.data_loader import get_processed_data
|
| 11 |
+
from src.graph_builder import build_graph, compute_pagerank, compute_betweenness, compute_louvain
|
| 12 |
+
from src.detectors.alert_engine import get_all_alerts
|
| 13 |
+
from src.ml.features import engineer_features
|
| 14 |
+
from src.ml.trainer import train_model
|
| 15 |
+
from src.ml.predictor import load_model, score_accounts_batch
|
| 16 |
+
|
| 17 |
+
print("3. Getting data...")
|
| 18 |
+
df, node_features = get_processed_data()
|
| 19 |
+
print(f"Loaded {len(df)} rows.")
|
| 20 |
+
|
| 21 |
+
print("4. Building graph...")
|
| 22 |
+
graph = build_graph(df)
|
| 23 |
+
print(f"Graph: {len(graph.nodes)} nodes, {len(graph.edges)} edges.")
|
| 24 |
+
|
| 25 |
+
print("5. Metrics...")
|
| 26 |
+
pr = compute_pagerank(1, graph)
|
| 27 |
+
bw = compute_betweenness(1, graph)
|
| 28 |
+
lv = compute_louvain(1, graph)
|
| 29 |
+
|
| 30 |
+
print("6. Detectors...")
|
| 31 |
+
alerts = get_all_alerts(df, graph, lv, node_features, demo_mode=True)
|
| 32 |
+
print(f"Generated {len(alerts)} alerts.")
|
| 33 |
+
|
| 34 |
+
print("7. Features...")
|
| 35 |
+
ff = engineer_features(df, graph, pr, bw, lv, alerts)
|
| 36 |
+
print(f"Feature cols: {len(ff.columns)}")
|
| 37 |
+
|
| 38 |
+
print("SUCCESS!")
|
| 39 |
+
|
| 40 |
+
except Exception as e:
|
| 41 |
+
print("\n--- ERROR CAUGHT ---")
|
| 42 |
+
traceback.print_exc()
|
| 43 |
+
sys.exit(1)
|
frontend/index.html
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" class="light">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Fund Flow Tracker - Financial Sentinel</title>
|
| 7 |
+
<!-- Tailwind & Fonts -->
|
| 8 |
+
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Inter:wght@400;500;600&family=JetBrains+Mono&display=swap" rel="stylesheet">
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
| 11 |
+
<!-- Cytoscape for Graph -->
|
| 12 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.26.0/cytoscape.min.js"></script>
|
| 13 |
+
|
| 14 |
+
<!-- Sentinel Core Design System Config -->
|
| 15 |
+
<script>
|
| 16 |
+
tailwind.config = {
|
| 17 |
+
darkMode: "class",
|
| 18 |
+
theme: {
|
| 19 |
+
extend: {
|
| 20 |
+
colors: {
|
| 21 |
+
"on-surface": "#191c1e", "surface-container-highest": "#e0e3e5", "surface-container": "#eceef0",
|
| 22 |
+
"primary": "#003e6f", "on-error": "#ffffff", "on-secondary": "#ffffff",
|
| 23 |
+
"error-container": "#ffdad6", "on-primary": "#ffffff", "tertiary": "#7f000c",
|
| 24 |
+
"surface-variant": "#e0e3e5", "background": "#f8f9fb", "secondary": "#466270",
|
| 25 |
+
"surface": "#f8f9fb", "tertiary-container": "#a90816", "error": "#ba1a1a",
|
| 26 |
+
"surface-container-low": "#f2f4f6", "outline": "#727781", "outline-variant": "#c1c7d2",
|
| 27 |
+
"secondary-container": "#c6e4f4", "surface-container-high": "#e6e8ea",
|
| 28 |
+
"surface-container-lowest": "#ffffff"
|
| 29 |
+
},
|
| 30 |
+
fontFamily: {
|
| 31 |
+
"headline": ["Manrope"], "body": ["Inter"], "mono": ["JetBrains Mono"]
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
</script>
|
| 37 |
+
<style>
|
| 38 |
+
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
|
| 39 |
+
.material-symbols-outlined.fill { font-variation-settings: 'FILL' 1; }
|
| 40 |
+
.nav-link { border-bottom: 2px solid transparent; color: #414750; }
|
| 41 |
+
.nav-link.active { border-bottom-color: #003e6f; color: #003e6f; font-weight: bold; }
|
| 42 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 43 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 44 |
+
::-webkit-scrollbar-thumb { background: #c1c7d2; border-radius: 10px; }
|
| 45 |
+
|
| 46 |
+
#app-content { min-height: calc(100vh - 64px); }
|
| 47 |
+
.loader {
|
| 48 |
+
border: 3px solid #f3f3f3; border-top: 3px solid #003e6f; border-radius: 50%;
|
| 49 |
+
width: 24px; height: 24px; animation: spin 1s linear infinite;
|
| 50 |
+
}
|
| 51 |
+
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
| 52 |
+
</style>
|
| 53 |
+
</head>
|
| 54 |
+
<body class="bg-surface font-body text-on-surface antialiased">
|
| 55 |
+
|
| 56 |
+
<!-- Global Navigation -->
|
| 57 |
+
<header class="sticky top-0 z-50 w-full bg-white border-b border-outline-variant/30 shadow-sm h-16 flex items-center justify-between px-6">
|
| 58 |
+
<div class="flex items-center gap-6 h-full">
|
| 59 |
+
<!-- Branding -->
|
| 60 |
+
<div class="flex items-center gap-3">
|
| 61 |
+
<div class="w-8 h-8 bg-primary rounded-sm flex items-center justify-center">
|
| 62 |
+
<span class="material-symbols-outlined text-white text-xl fill">security</span>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="flex flex-col cursor-pointer" onclick="navigate('overview')">
|
| 65 |
+
<span class="text-sm font-black tracking-tighter text-primary uppercase leading-none">Fund Flow Tracker</span>
|
| 66 |
+
<span class="text-[9px] font-bold text-outline uppercase tracking-widest">Financial Sentinel</span>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<!-- Global Account Context (Hidden default) -->
|
| 71 |
+
<div id="global-nav-account" class="hidden items-center gap-2 px-3 py-1 bg-surface-container rounded-sm border border-outline-variant/30">
|
| 72 |
+
<span class="text-[9px] font-bold text-outline uppercase">Active Investigation</span>
|
| 73 |
+
<span id="nav-active-account" class="text-[10px] font-mono font-bold text-primary"></span>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<!-- Nav Links -->
|
| 77 |
+
<nav class="flex items-center h-full ml-4 font-semibold text-sm">
|
| 78 |
+
<a href="#overview" class="nav-link px-3 h-full flex items-center" id="nav-overview">Overview</a>
|
| 79 |
+
<a href="#alerts" class="nav-link px-3 h-full flex items-center" id="nav-alerts">Alert Queue</a>
|
| 80 |
+
<a href="#investigation" class="nav-link px-3 h-full flex items-center" id="nav-investigation">Investigation View</a>
|
| 81 |
+
<a href="#model" class="nav-link px-3 h-full flex items-center" id="nav-model">Model Metrics</a>
|
| 82 |
+
</nav>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div class="flex items-center gap-4">
|
| 86 |
+
<div class="relative w-64 hidden md:block">
|
| 87 |
+
<span class="material-symbols-outlined absolute left-2 top-1.5 text-outline text-lg">search</span>
|
| 88 |
+
<input type="text" id="global-search" class="w-full bg-surface-container-low border-none rounded-md pl-8 pr-3 py-1.5 text-xs focus:ring-1 focus:ring-primary" placeholder="Search accounts...">
|
| 89 |
+
</div>
|
| 90 |
+
<button class="material-symbols-outlined text-outline hover:text-primary">notifications</button>
|
| 91 |
+
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center border border-primary/20">
|
| 92 |
+
<span class="material-symbols-outlined text-primary">person</span>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
</header>
|
| 96 |
+
|
| 97 |
+
<!-- Dynamic Router Content -->
|
| 98 |
+
<main id="app-content"></main>
|
| 99 |
+
|
| 100 |
+
<!-- Global API JS -->
|
| 101 |
+
<script src="/static/js/api.js"></script>
|
| 102 |
+
<script>
|
| 103 |
+
// Simple Hash Router Setup
|
| 104 |
+
const router = {
|
| 105 |
+
async loadPage(hash) {
|
| 106 |
+
const page = (hash.replace('#', '') || 'overview').split('?')[0];
|
| 107 |
+
const contentDiv = document.getElementById('app-content');
|
| 108 |
+
|
| 109 |
+
// Show loader
|
| 110 |
+
contentDiv.innerHTML = '<div class="flex items-center justify-center h-full pt-32"><div class="loader"></div></div>';
|
| 111 |
+
|
| 112 |
+
// Highlight Nav
|
| 113 |
+
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
| 114 |
+
const activeNav = document.getElementById(`nav-${page}`);
|
| 115 |
+
if (activeNav) activeNav.classList.add('active');
|
| 116 |
+
|
| 117 |
+
try {
|
| 118 |
+
const res = await fetch(`/static/pages/${page}.html`);
|
| 119 |
+
if (!res.ok) throw new Error("Page not found");
|
| 120 |
+
const html = await res.text();
|
| 121 |
+
contentDiv.innerHTML = html;
|
| 122 |
+
|
| 123 |
+
// Trigger page-specific init sequence
|
| 124 |
+
if (window[`init_${page}`]) {
|
| 125 |
+
await window[`init_${page}`]();
|
| 126 |
+
}
|
| 127 |
+
} catch (e) {
|
| 128 |
+
contentDiv.innerHTML = `<div class="p-10 text-error flex items-center gap-2"><span class="material-symbols-outlined">error</span> Failed to load route: ${page}</div>`;
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
function navigate(hash) { window.location.hash = hash; }
|
| 134 |
+
|
| 135 |
+
// Listen to hash changes
|
| 136 |
+
window.addEventListener('hashchange', () => router.loadPage(window.location.hash));
|
| 137 |
+
|
| 138 |
+
// Init on load
|
| 139 |
+
window.addEventListener('DOMContentLoaded', () => {
|
| 140 |
+
router.loadPage(window.location.hash || '#overview');
|
| 141 |
+
|
| 142 |
+
// Wire global search to Investigation View
|
| 143 |
+
const search = document.getElementById('global-search');
|
| 144 |
+
search.addEventListener('keypress', (e) => {
|
| 145 |
+
if(e.key === 'Enter' && search.value.trim()) {
|
| 146 |
+
window.location.hash = `#investigation?account=${encodeURIComponent(search.value.trim())}`;
|
| 147 |
+
search.value = '';
|
| 148 |
+
}
|
| 149 |
+
});
|
| 150 |
+
});
|
| 151 |
+
</script>
|
| 152 |
+
</body>
|
| 153 |
+
</html>
|
frontend/js/api.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Shared API Client Utilities
|
| 3 |
+
*/
|
| 4 |
+
const API_BASE = '/api';
|
| 5 |
+
|
| 6 |
+
const api = {
|
| 7 |
+
async get(endpoint) {
|
| 8 |
+
try {
|
| 9 |
+
const res = await fetch(`${API_BASE}${endpoint}`);
|
| 10 |
+
if (!res.ok) {
|
| 11 |
+
const err = await res.json().catch(() => ({}));
|
| 12 |
+
throw new Error(err.detail || `HTTP error! status: ${res.status}`);
|
| 13 |
+
}
|
| 14 |
+
return await res.json();
|
| 15 |
+
} catch (e) {
|
| 16 |
+
console.error(`API GET error on ${endpoint}:`, e);
|
| 17 |
+
throw e;
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
|
| 21 |
+
async patch(endpoint, body = {}) {
|
| 22 |
+
try {
|
| 23 |
+
const res = await fetch(`${API_BASE}${endpoint}`, {
|
| 24 |
+
method: 'PATCH',
|
| 25 |
+
headers: { 'Content-Type': 'application/json' },
|
| 26 |
+
body: JSON.stringify(body)
|
| 27 |
+
});
|
| 28 |
+
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
| 29 |
+
return await res.json();
|
| 30 |
+
} catch (e) {
|
| 31 |
+
console.error(`API PATCH error on ${endpoint}:`, e);
|
| 32 |
+
throw e;
|
| 33 |
+
}
|
| 34 |
+
},
|
| 35 |
+
|
| 36 |
+
downloadFile(endpoint, filename) {
|
| 37 |
+
const url = `${API_BASE}${endpoint}`;
|
| 38 |
+
const a = document.createElement('a');
|
| 39 |
+
a.href = url;
|
| 40 |
+
a.download = filename;
|
| 41 |
+
document.body.appendChild(a);
|
| 42 |
+
a.click();
|
| 43 |
+
document.body.removeChild(a);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
// Utils
|
| 48 |
+
function formatCurrency(val) {
|
| 49 |
+
if (val === null || val === undefined) return '0.00';
|
| 50 |
+
return Number(val).toLocaleString('en-IN', {
|
| 51 |
+
minimumFractionDigits: 2,
|
| 52 |
+
maximumFractionDigits: 2
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function formatDate(isoStr) {
|
| 57 |
+
if (!isoStr) return '';
|
| 58 |
+
try {
|
| 59 |
+
const d = new Date(isoStr);
|
| 60 |
+
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
| 61 |
+
} catch {
|
| 62 |
+
return isoStr;
|
| 63 |
+
}
|
| 64 |
+
}
|
frontend/pages/alerts.html
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="p-8 max-w-[1200px] mx-auto min-h-screen">
|
| 2 |
+
<!-- Header -->
|
| 3 |
+
<div class="mb-6 flex justify-between items-end">
|
| 4 |
+
<div>
|
| 5 |
+
<h2 class="font-headline text-3xl font-extrabold text-on-surface tracking-tight">Alert Queue</h2>
|
| 6 |
+
<p class="text-on-surface-variant font-medium mt-1" id="alert-subtitle">Loading active alerts...</p>
|
| 7 |
+
</div>
|
| 8 |
+
<div class="flex flex-col items-end gap-3" id="risk-counters">
|
| 9 |
+
<!-- Injected by JS -->
|
| 10 |
+
</div>
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
<!-- Filter Bar -->
|
| 14 |
+
<section
|
| 15 |
+
class="bg-surface-container-lowest rounded-md p-3 mb-8 shadow-sm border border-outline-variant/10 flex items-center justify-between gap-4">
|
| 16 |
+
<div class="flex items-center gap-3 flex-1">
|
| 17 |
+
<div class="min-w-[140px]">
|
| 18 |
+
<select id="filter-typology"
|
| 19 |
+
class="w-full bg-surface-container-low border-none rounded-sm py-1.5 text-xs font-semibold focus:ring-1 focus:ring-primary">
|
| 20 |
+
<option value="">All Typologies</option>
|
| 21 |
+
<option value="Structuring">Structuring</option>
|
| 22 |
+
<option value="Layering">Layering</option>
|
| 23 |
+
<option value="RoundTripping">Round-Tripping</option>
|
| 24 |
+
<option value="DormantActivation">Dormancy</option>
|
| 25 |
+
<option value="MuleNetwork">Mule Network</option>
|
| 26 |
+
</select>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="min-w-[140px]">
|
| 29 |
+
<select id="filter-risk"
|
| 30 |
+
class="w-full bg-surface-container-low border-none rounded-sm py-1.5 text-xs font-semibold focus:ring-1 focus:ring-primary">
|
| 31 |
+
<option value="">All Risk Tiers</option>
|
| 32 |
+
<option value="Critical Only">Critical Only</option>
|
| 33 |
+
<option value="High & Critical">High & Critical</option>
|
| 34 |
+
</select>
|
| 35 |
+
</div>
|
| 36 |
+
<div class="relative flex-1 max-w-[180px]">
|
| 37 |
+
<span
|
| 38 |
+
class="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-outline text-sm">search</span>
|
| 39 |
+
<input id="filter-account" type="text"
|
| 40 |
+
class="w-full bg-surface-container-low border-none rounded-sm py-1.5 pl-8 text-xs font-mono focus:ring-1 focus:ring-primary"
|
| 41 |
+
placeholder="Account ID...">
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
<div class="flex items-center gap-2 pl-4 border-l border-outline-variant/20">
|
| 45 |
+
<button onclick="window.loadAlerts()"
|
| 46 |
+
class="px-3 py-1.5 text-[10px] font-black text-on-surface-variant hover:bg-surface-container transition-colors rounded-sm uppercase">Refresh</button>
|
| 47 |
+
<button onclick="api.downloadFile('/alerts/export', 'alerts_export.csv')"
|
| 48 |
+
class="px-4 py-1.5 bg-primary text-white hover:opacity-90 transition-opacity rounded-sm text-[10px] font-black uppercase flex items-center gap-1.5">
|
| 49 |
+
<span class="material-symbols-outlined text-[14px]">download</span> Export
|
| 50 |
+
</button>
|
| 51 |
+
</div>
|
| 52 |
+
</section>
|
| 53 |
+
|
| 54 |
+
<!-- Dismissed Toggle -->
|
| 55 |
+
<div class="mb-4 flex justify-end">
|
| 56 |
+
<label class="inline-flex items-center cursor-pointer group">
|
| 57 |
+
<span
|
| 58 |
+
class="mr-3 text-xs font-bold text-on-surface-variant group-hover:text-on-surface uppercase tracking-tight transition-colors">Show
|
| 59 |
+
Dismissed</span>
|
| 60 |
+
<input type="checkbox" id="toggle-dismissed" class="sr-only peer">
|
| 61 |
+
<div
|
| 62 |
+
class="relative w-9 h-5 bg-surface-container-highest peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary">
|
| 63 |
+
</div>
|
| 64 |
+
</label>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<!-- Alert List Container -->
|
| 68 |
+
<div id="alert-list-container" class="space-y-12">
|
| 69 |
+
<div class="flex justify-center p-12">
|
| 70 |
+
<div class="loader"></div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<script>
|
| 76 |
+
window.init_alerts = async function () {
|
| 77 |
+
// Check URL params
|
| 78 |
+
const urlParams = new URLSearchParams(window.location.hash.split('?')[1]);
|
| 79 |
+
if (urlParams.get('type')) document.getElementById('filter-typology').value = urlParams.get('type');
|
| 80 |
+
if (urlParams.get('tier')) document.getElementById('filter-risk').value = urlParams.get('tier');
|
| 81 |
+
|
| 82 |
+
// Wire up filters
|
| 83 |
+
['filter-typology', 'filter-risk', 'toggle-dismissed'].forEach(id => {
|
| 84 |
+
document.getElementById(id).addEventListener('change', window.loadAlerts);
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
let debounceTimer;
|
| 88 |
+
document.getElementById('filter-account').addEventListener('input', () => {
|
| 89 |
+
clearTimeout(debounceTimer);
|
| 90 |
+
debounceTimer = setTimeout(window.loadAlerts, 400);
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
await window.loadAlerts();
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
window.loadAlerts = async function () {
|
| 97 |
+
try {
|
| 98 |
+
const typo = document.getElementById('filter-typology').value;
|
| 99 |
+
const risk = document.getElementById('filter-risk').value;
|
| 100 |
+
const acct = document.getElementById('filter-account').value;
|
| 101 |
+
const showDis = document.getElementById('toggle-dismissed').checked;
|
| 102 |
+
|
| 103 |
+
let url = `/alerts?show_dismissed=${showDis}`;
|
| 104 |
+
if (typo) url += `&typology=${encodeURIComponent(typo)}`;
|
| 105 |
+
if (risk) url += `&risk_tier=${encodeURIComponent(risk)}`;
|
| 106 |
+
if (acct) url += `&account=${encodeURIComponent(acct)}`;
|
| 107 |
+
|
| 108 |
+
const content = document.getElementById('alert-list-container');
|
| 109 |
+
content.innerHTML = '<div class="flex justify-center p-12"><div class="loader"></div></div>';
|
| 110 |
+
|
| 111 |
+
const alerts = await api.get(url);
|
| 112 |
+
|
| 113 |
+
// Update headers
|
| 114 |
+
const cnts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
| 115 |
+
alerts.forEach(a => { if (!a.is_dismissed) cnts[a.risk_tier] = (cnts[a.risk_tier] || 0) + 1; });
|
| 116 |
+
|
| 117 |
+
document.getElementById('alert-subtitle').textContent = `${alerts.length} alerts matching criteria`;
|
| 118 |
+
document.getElementById('risk-counters').innerHTML = `
|
| 119 |
+
<div class="flex gap-1.5">
|
| 120 |
+
<div class="px-2.5 py-1 bg-error/10 border border-error/20 rounded-full flex items-center gap-2">
|
| 121 |
+
<span class="w-1.5 h-1.5 rounded-full bg-error"></span>
|
| 122 |
+
<span class="text-[10px] font-bold text-error uppercase">Critical: ${cnts.CRITICAL}</span>
|
| 123 |
+
</div>
|
| 124 |
+
<div class="px-2.5 py-1 bg-tertiary-container/10 border border-tertiary-container/20 rounded-full flex items-center gap-2">
|
| 125 |
+
<span class="w-1.5 h-1.5 rounded-full bg-tertiary-container"></span>
|
| 126 |
+
<span class="text-[10px] font-bold text-tertiary-container uppercase">High: ${cnts.HIGH}</span>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
`;
|
| 130 |
+
|
| 131 |
+
content.innerHTML = '';
|
| 132 |
+
if (alerts.length === 0) {
|
| 133 |
+
content.innerHTML = '<div class="p-8 text-center text-outline-variant font-bold">No alerts found.</div>';
|
| 134 |
+
return;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Group by tier
|
| 138 |
+
const byTier = { CRITICAL: [], HIGH: [], MEDIUM: [], LOW: [] };
|
| 139 |
+
alerts.forEach(a => byTier[a.risk_tier].push(a));
|
| 140 |
+
|
| 141 |
+
const buildSection = (title, items, colorClass, baseColor, border) => {
|
| 142 |
+
if (items.length === 0) return '';
|
| 143 |
+
|
| 144 |
+
let html = `
|
| 145 |
+
<div>
|
| 146 |
+
<div class="relative flex items-center mb-8">
|
| 147 |
+
<div class="flex-grow border-t border-outline-variant/30"></div>
|
| 148 |
+
<span class="flex-shrink mx-4 px-4 py-1.5 ${baseColor} text-[9px] font-black tracking-widest text-white uppercase rounded-full shadow-sm">${title} RISK ALERT QUEUE</span>
|
| 149 |
+
<div class="flex-grow border-t border-outline-variant/30"></div>
|
| 150 |
+
</div>
|
| 151 |
+
<div class="space-y-3">
|
| 152 |
+
`;
|
| 153 |
+
|
| 154 |
+
items.forEach(alert => {
|
| 155 |
+
const opacityClass = alert.is_dismissed ? 'opacity-50' : '';
|
| 156 |
+
const confirmedBadge = alert.is_confirmed ?
|
| 157 |
+
`<span class="bg-black text-white text-[9px] font-black px-1.5 py-0.5 rounded-sm uppercase ml-2 flex items-center gap-1"><span class="material-symbols-outlined text-[10px]">gavel</span> FRAUD</span>` : '';
|
| 158 |
+
|
| 159 |
+
html += `
|
| 160 |
+
<div class="bg-surface-container-lowest border border-outline-variant/20 border-l-4 ${border} group rounded-r-md ${opacityClass}">
|
| 161 |
+
<div class="p-5">
|
| 162 |
+
<div class="flex justify-between items-start mb-4">
|
| 163 |
+
<div class="flex items-center gap-4">
|
| 164 |
+
<div class="w-10 h-10 rounded-full border-2 ${border} flex items-center justify-center bg-surface-container shrink-0">
|
| 165 |
+
<span class="text-base font-black ${colorClass} font-headline leading-none">${alert.risk_score}</span>
|
| 166 |
+
</div>
|
| 167 |
+
<div>
|
| 168 |
+
<div class="flex items-center gap-2 mb-1.5">
|
| 169 |
+
<span class="font-mono text-sm font-bold text-on-surface">${alert.account}</span>
|
| 170 |
+
<span class="${colorClass} text-[9px] font-black px-1.5 py-0.5 rounded-sm border ${border} uppercase bg-surface-container">${alert.risk_tier}</span>
|
| 171 |
+
${confirmedBadge}
|
| 172 |
+
</div>
|
| 173 |
+
<div class="flex items-center gap-2">
|
| 174 |
+
<span class="bg-surface-container px-2 py-0.5 text-[9px] font-black text-on-surface-variant uppercase rounded-sm">${alert.typology}</span>
|
| 175 |
+
<span class="text-[10px] text-on-surface-variant font-semibold">${alert.tx_count} Transactions</span>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
<div class="text-right">
|
| 180 |
+
<p class="text-xl font-black font-headline text-on-surface leading-tight">₹ ${formatCurrency(alert.amount_involved)}</p>
|
| 181 |
+
<div class="flex flex-col items-end mt-1">
|
| 182 |
+
<p class="text-[10px] font-bold text-on-surface leading-none">${formatDate(alert.timestamp_detected)}</p>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
<div class="bg-surface-container-low p-3 rounded-sm mb-4 border-l-2 border-primary/20">
|
| 187 |
+
<p class="text-xs leading-relaxed text-on-surface-variant">
|
| 188 |
+
${alert.explanation.split(';').join('<br>')}
|
| 189 |
+
</p>
|
| 190 |
+
</div>
|
| 191 |
+
<div class="flex justify-between items-center">
|
| 192 |
+
<div class="flex gap-2">
|
| 193 |
+
<div class="font-mono text-[9px] text-outline p-1">ID: ${alert.alert_id}</div>
|
| 194 |
+
</div>
|
| 195 |
+
<div class="flex gap-2">
|
| 196 |
+
${!alert.is_dismissed ? `
|
| 197 |
+
<button onclick="window.dismissAlert('${alert.alert_id}')" class="w-9 h-9 flex items-center justify-center bg-surface-container-high text-on-surface-variant hover:bg-error/10 hover:text-error transition-all rounded-sm border border-outline-variant/20" title="Dismiss Alert">
|
| 198 |
+
<span class="material-symbols-outlined text-lg">close</span>
|
| 199 |
+
</button>
|
| 200 |
+
${!alert.is_confirmed ? `
|
| 201 |
+
<button onclick="window.confirmAlert('${alert.alert_id}')" class="px-4 h-9 flex items-center gap-2 border border-outline text-on-surface font-black text-[10px] uppercase rounded-sm hover:bg-surface-container-high transition-colors">
|
| 202 |
+
<span class="material-symbols-outlined text-base">check_circle</span> Confirm Fraud
|
| 203 |
+
</button>
|
| 204 |
+
` : ''}
|
| 205 |
+
` : `
|
| 206 |
+
<span class="px-2 py-1 text-[10px] font-bold uppercase text-outline">Dismissed</span>
|
| 207 |
+
`}
|
| 208 |
+
<button onclick="navigate('investigation?account=${alert.account}')" class="px-8 h-9 flex items-center gap-2 bg-primary text-white font-black text-[10px] uppercase rounded-sm shadow-sm hover:opacity-90 active:scale-95 transition-all">
|
| 209 |
+
<span class="material-symbols-outlined text-base">manage_search</span> Investigate
|
| 210 |
+
</button>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
`;
|
| 216 |
+
});
|
| 217 |
+
html += `</div></div>`;
|
| 218 |
+
return html;
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
content.innerHTML += buildSection('CRITICAL', byTier.CRITICAL, 'text-error', 'bg-error', 'border-error');
|
| 222 |
+
content.innerHTML += buildSection('HIGH', byTier.HIGH, 'text-tertiary-container', 'bg-tertiary-container', 'border-tertiary-container');
|
| 223 |
+
content.innerHTML += buildSection('MEDIUM', byTier.MEDIUM, 'text-primary', 'bg-primary', 'border-primary');
|
| 224 |
+
content.innerHTML += buildSection('LOW', byTier.LOW, 'text-secondary', 'bg-secondary', 'border-secondary');
|
| 225 |
+
|
| 226 |
+
} catch (e) {
|
| 227 |
+
alert("Failed to load alerts: " + e.message);
|
| 228 |
+
}
|
| 229 |
+
};
|
| 230 |
+
|
| 231 |
+
window.dismissAlert = async function (id) {
|
| 232 |
+
if (confirm("Dismiss this alert?")) {
|
| 233 |
+
try { await api.patch(`/alerts/${id}/dismiss`); window.loadAlerts(); }
|
| 234 |
+
catch (e) { alert(e.message); }
|
| 235 |
+
}
|
| 236 |
+
};
|
| 237 |
+
|
| 238 |
+
window.confirmAlert = async function (id) {
|
| 239 |
+
if (confirm("Confirm account as fraud?")) {
|
| 240 |
+
try { await api.patch(`/alerts/${id}/confirm`); window.loadAlerts(); }
|
| 241 |
+
catch (e) { alert(e.message); }
|
| 242 |
+
}
|
| 243 |
+
};
|
| 244 |
+
</script>
|
frontend/pages/investigation.html
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="px-10 py-6 bg-surface-container-low/30 border-b border-outline-variant/20">
|
| 2 |
+
<h1 class="font-headline text-[1.5rem] font-bold text-on-surface leading-none">Investigation View</h1>
|
| 3 |
+
<p class="font-body text-xs text-on-surface-variant mt-1">Trace the complete fund flow journey and ML explanations.
|
| 4 |
+
</p>
|
| 5 |
+
</div>
|
| 6 |
+
|
| 7 |
+
<div class="px-10 py-6 flex flex-col gap-6 max-w-[1600px] mx-auto min-h-[calc(100vh-140px)]">
|
| 8 |
+
<!-- Search Bar -->
|
| 9 |
+
<section
|
| 10 |
+
class="bg-surface-container-low rounded p-4 flex items-end gap-6 shadow-sm border border-outline-variant/20">
|
| 11 |
+
<div class="flex-1 space-y-2">
|
| 12 |
+
<label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant">Account ID</label>
|
| 13 |
+
<div class="relative group">
|
| 14 |
+
<input id="inv-acct-input"
|
| 15 |
+
class="w-full bg-surface-container-lowest border-b-2 border-outline-variant focus:border-primary focus:ring-0 transition-all px-3 py-2 text-sm font-mono"
|
| 16 |
+
type="text" placeholder="Enter Account ID...">
|
| 17 |
+
<span class="absolute right-3 top-2.5 text-outline material-symbols-outlined text-sm">fingerprint</span>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
<div class="space-y-2">
|
| 21 |
+
<label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant">Graph Depth</label>
|
| 22 |
+
<select id="inv-depth"
|
| 23 |
+
class="bg-surface-container border-none text-xs rounded py-2 pl-3 pr-8 focus:ring-1 focus:ring-primary/20">
|
| 24 |
+
<option value="1">L1</option>
|
| 25 |
+
<option value="2" selected>L2</option>
|
| 26 |
+
<option value="3">L3</option>
|
| 27 |
+
</select>
|
| 28 |
+
</div>
|
| 29 |
+
<div class="space-y-2">
|
| 30 |
+
<label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant">Max Nodes</label>
|
| 31 |
+
<select id="inv-nodes"
|
| 32 |
+
class="bg-surface-container border-none text-xs rounded py-2 pl-3 pr-8 focus:ring-1 focus:ring-primary/20">
|
| 33 |
+
<option value="50">50 Nodes</option>
|
| 34 |
+
<option value="100" selected>100 Nodes</option>
|
| 35 |
+
<option value="250">250 Nodes</option>
|
| 36 |
+
</select>
|
| 37 |
+
</div>
|
| 38 |
+
<button onclick="window.loadInvestigation()"
|
| 39 |
+
class="bg-primary text-white px-8 py-2.5 rounded shadow hover:bg-primary/90 transition-all flex items-center gap-2 font-bold text-sm">
|
| 40 |
+
<span class="material-symbols-outlined text-sm">search</span> Inquire
|
| 41 |
+
</button>
|
| 42 |
+
</section>
|
| 43 |
+
|
| 44 |
+
<!-- Content Area (Hidden till searched) -->
|
| 45 |
+
<div id="inv-content" class="hidden flex-col gap-6">
|
| 46 |
+
|
| 47 |
+
<!-- Summary Strip -->
|
| 48 |
+
<section
|
| 49 |
+
class="grid grid-cols-2 lg:grid-cols-6 gap-0.5 bg-outline-variant/20 rounded overflow-hidden shadow-sm">
|
| 50 |
+
<div class="bg-surface-container-lowest p-4">
|
| 51 |
+
<p class="text-[10px] font-bold text-on-surface-variant uppercase mb-1">Target Account</p>
|
| 52 |
+
<p class="font-mono text-sm font-bold text-primary" id="det-account">--</p>
|
| 53 |
+
</div>
|
| 54 |
+
<div class="bg-surface-container-lowest p-4">
|
| 55 |
+
<p class="text-[10px] font-bold text-on-surface-variant uppercase mb-1">Total Sent</p>
|
| 56 |
+
<p class="text-sm font-bold text-error">₹ <span id="det-sent">--</span></p>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="bg-surface-container-lowest p-4">
|
| 59 |
+
<p class="text-[10px] font-bold text-on-surface-variant uppercase mb-1">Total Received</p>
|
| 60 |
+
<p class="text-sm font-bold text-primary">₹ <span id="det-recv">--</span></p>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="bg-surface-container-lowest p-4 flex items-center justify-between">
|
| 63 |
+
<div>
|
| 64 |
+
<p class="text-[10px] font-bold text-on-surface-variant uppercase mb-1">Transactions</p>
|
| 65 |
+
<p class="text-sm font-bold" id="det-txc">--</p>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="bg-surface-container-lowest p-4 flex items-center gap-4 col-span-2 lg:col-span-1">
|
| 69 |
+
<div class="flex items-center gap-2">
|
| 70 |
+
<div class="w-10 h-10 rounded-full border-2 border-primary flex items-center justify-center font-bold text-primary bg-surface-container"
|
| 71 |
+
id="det-risk">--</div>
|
| 72 |
+
<div>
|
| 73 |
+
<p class="text-[10px] font-bold text-on-surface-variant uppercase">XGB Risk</p>
|
| 74 |
+
<span id="det-gnn" class="text-[10px] font-bold text-tertiary">-- GNN</span>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
<div class="bg-surface-container p-4">
|
| 79 |
+
<p class="text-[10px] font-bold text-on-surface-variant uppercase mb-1">Network Metrics</p>
|
| 80 |
+
<div class="flex items-center justify-between text-[10px]">
|
| 81 |
+
<span class="text-on-surface-variant">PageRank</span>
|
| 82 |
+
<span class="font-mono font-bold" id="det-pr">--</span>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="flex items-center justify-between text-[10px]">
|
| 85 |
+
<span class="text-on-surface-variant">Betweenness</span>
|
| 86 |
+
<span class="font-mono font-bold" id="det-bw">--</span>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</section>
|
| 90 |
+
|
| 91 |
+
<!-- Graph View -->
|
| 92 |
+
<section
|
| 93 |
+
class="bg-surface-container-lowest rounded shadow-sm border border-outline-variant/10 flex flex-col h-[500px] relative">
|
| 94 |
+
<div
|
| 95 |
+
class="px-6 py-3 flex justify-between border-b border-outline-variant/10 z-20 bg-surface-container-lowest">
|
| 96 |
+
<h3 class="font-bold text-sm uppercase flex items-center gap-2">
|
| 97 |
+
<span class="material-symbols-outlined text-primary">hub</span> Interactive Flow Graph
|
| 98 |
+
</h3>
|
| 99 |
+
</div>
|
| 100 |
+
<div id="cy-container" class="flex-1 w-full h-full bg-[#f2f4f6]/40 relative z-10"></div>
|
| 101 |
+
</section>
|
| 102 |
+
|
| 103 |
+
<!-- Tabbed Detail -->
|
| 104 |
+
<section class="bg-surface-container-lowest rounded shadow-sm border border-outline-variant/10 overflow-hidden">
|
| 105 |
+
<div class="flex border-b border-outline-variant/10 bg-surface-container-low overflow-x-auto">
|
| 106 |
+
<button onclick="invTabs('tx')"
|
| 107 |
+
class="inv-tab active px-8 py-3 text-xs font-bold text-primary border-b-2 border-primary bg-surface-container-lowest">Transactions</button>
|
| 108 |
+
<button onclick="invTabs('ml')"
|
| 109 |
+
class="inv-tab px-8 py-3 text-xs font-semibold text-outline hover:text-on-surface transition-colors">ML
|
| 110 |
+
Explanation</button>
|
| 111 |
+
<button onclick="invTabs('alerts')"
|
| 112 |
+
class="inv-tab px-8 py-3 text-xs font-semibold text-outline hover:text-on-surface transition-colors flex items-center gap-2">
|
| 113 |
+
Triggered Alerts <span id="inv-alert-dot" class="w-1.5 h-1.5 rounded-full bg-error hidden"></span>
|
| 114 |
+
</button>
|
| 115 |
+
<button onclick="window.downloadReport()"
|
| 116 |
+
class="px-8 py-3 text-xs font-semibold text-outline hover:text-on-surface transition-colors ml-auto flex items-center gap-2">
|
| 117 |
+
<span class="material-symbols-outlined text-[14px]">download</span> Generate STR
|
| 118 |
+
</button>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<div class="p-6">
|
| 122 |
+
<!-- Transactions Tab -->
|
| 123 |
+
<div id="tab-tx" class="inv-pane block overflow-auto max-h-[400px]">
|
| 124 |
+
<table class="w-full text-left">
|
| 125 |
+
<thead
|
| 126 |
+
class="bg-surface-container-high text-[10px] font-bold uppercase text-on-surface-variant sticky top-0">
|
| 127 |
+
<tr>
|
| 128 |
+
<th class="px-4 py-3">Timestamp</th>
|
| 129 |
+
<th class="px-4 py-3">Direction</th>
|
| 130 |
+
<th class="px-4 py-3">Counterparty</th>
|
| 131 |
+
<th class="px-4 py-3 text-right">Amount (INR)</th>
|
| 132 |
+
<th class="px-4 py-3">Channel</th>
|
| 133 |
+
<th class="px-4 py-3 text-center">Status</th>
|
| 134 |
+
</tr>
|
| 135 |
+
</thead>
|
| 136 |
+
<tbody id="tx-body" class="divide-y divide-outline-variant/10 text-xs"></tbody>
|
| 137 |
+
</table>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<!-- ML Tab -->
|
| 141 |
+
<div id="tab-ml" class="inv-pane hidden max-h-[400px] overflow-auto">
|
| 142 |
+
<div class="space-y-4 max-w-3xl" id="ml-body"></div>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<!-- Alerts Tab -->
|
| 146 |
+
<div id="tab-alerts" class="inv-pane hidden max-h-[400px] overflow-auto">
|
| 147 |
+
<div class="space-y-4" id="alerts-body"></div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</section>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<script>
|
| 155 |
+
let cy = null;
|
| 156 |
+
let currentAccount = null;
|
| 157 |
+
|
| 158 |
+
window.init_investigation = async function () {
|
| 159 |
+
const urlParams = new URLSearchParams(window.location.hash.split('?')[1]);
|
| 160 |
+
const acct = urlParams.get('account');
|
| 161 |
+
if (acct) {
|
| 162 |
+
document.getElementById('inv-acct-input').value = acct;
|
| 163 |
+
await window.loadInvestigation();
|
| 164 |
+
}
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
window.invTabs = function (id) {
|
| 168 |
+
document.querySelectorAll('.inv-tab').forEach(t => {
|
| 169 |
+
t.className = 'inv-tab px-8 py-3 text-xs font-semibold text-outline hover:text-on-surface transition-colors';
|
| 170 |
+
});
|
| 171 |
+
event.currentTarget.className = "inv-tab active px-8 py-3 text-xs font-bold text-primary border-b-2 border-primary bg-surface-container-lowest";
|
| 172 |
+
|
| 173 |
+
document.querySelectorAll('.inv-pane').forEach(p => p.classList.add('hidden'));
|
| 174 |
+
document.getElementById('tab-' + id).classList.remove('hidden');
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
window.loadInvestigation = async function () {
|
| 178 |
+
const acct = document.getElementById('inv-acct-input').value.trim();
|
| 179 |
+
if (!acct) return;
|
| 180 |
+
currentAccount = acct;
|
| 181 |
+
|
| 182 |
+
const depth = document.getElementById('inv-depth').value;
|
| 183 |
+
const maxNodes = document.getElementById('inv-nodes').value;
|
| 184 |
+
|
| 185 |
+
document.getElementById('nav-active-account').textContent = acct;
|
| 186 |
+
document.getElementById('global-nav-account').classList.replace('hidden', 'flex');
|
| 187 |
+
|
| 188 |
+
const content = document.getElementById('inv-content');
|
| 189 |
+
content.classList.replace('hidden', 'flex');
|
| 190 |
+
|
| 191 |
+
try {
|
| 192 |
+
// Parallel fetch details + graph
|
| 193 |
+
const [details, graph] = await Promise.all([
|
| 194 |
+
api.get(`/account/${encodeURIComponent(acct)}`),
|
| 195 |
+
api.get(`/account/${encodeURIComponent(acct)}/graph?depth=${depth}&max_nodes=${maxNodes}`)
|
| 196 |
+
]);
|
| 197 |
+
|
| 198 |
+
// Populate Summary
|
| 199 |
+
document.getElementById('det-account').textContent = details.account_id;
|
| 200 |
+
document.getElementById('det-sent').textContent = formatCurrency(details.metrics.total_sent);
|
| 201 |
+
document.getElementById('det-recv').textContent = formatCurrency(details.metrics.total_received);
|
| 202 |
+
document.getElementById('det-txc').textContent = details.metrics.tx_count;
|
| 203 |
+
document.getElementById('det-risk').textContent = details.metrics.risk_score;
|
| 204 |
+
|
| 205 |
+
const gnn = details.metrics.gnn_risk_score;
|
| 206 |
+
document.getElementById('det-gnn').textContent = gnn ? `${(gnn * 100).toFixed(1)}% GNN` : '-- GNN';
|
| 207 |
+
|
| 208 |
+
document.getElementById('det-pr').textContent = details.metrics.pagerank.toFixed(5);
|
| 209 |
+
document.getElementById('det-bw').textContent = details.metrics.betweenness.toFixed(5);
|
| 210 |
+
|
| 211 |
+
// Populate Transactions
|
| 212 |
+
const txTbody = document.getElementById('tx-body');
|
| 213 |
+
txTbody.innerHTML = '';
|
| 214 |
+
details.transactions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)).forEach(tx => {
|
| 215 |
+
const isOut = tx.source === acct;
|
| 216 |
+
const dirClass = isOut ? 'bg-tertiary/10 text-tertiary' : 'bg-primary/10 text-primary';
|
| 217 |
+
const flag = tx.is_laundering ? '<span class="material-symbols-outlined text-error fill" title="Laundering Flag">report</span>' : '<span class="material-symbols-outlined text-green-600 fill">check_circle</span>';
|
| 218 |
+
const rowClass = tx.is_laundering ? 'bg-error-container/20' : '';
|
| 219 |
+
|
| 220 |
+
txTbody.innerHTML += `
|
| 221 |
+
<tr class="border-b border-outline-variant/10 hover:bg-surface-container transition-colors ${rowClass}">
|
| 222 |
+
<td class="px-4 py-3">${formatDate(tx.timestamp)}</td>
|
| 223 |
+
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-[9px] font-bold ${dirClass}">${isOut ? 'OUT' : 'IN'}</span></td>
|
| 224 |
+
<td class="px-4 py-3 font-mono">${isOut ? tx.target : tx.source}</td>
|
| 225 |
+
<td class="px-4 py-3 font-mono font-bold text-right">₹ ${formatCurrency(tx.amount)}</td>
|
| 226 |
+
<td class="px-4 py-3 uppercase text-[10px]">${tx.payment_type}</td>
|
| 227 |
+
<td class="px-4 py-3 text-center">${flag}</td>
|
| 228 |
+
</tr>
|
| 229 |
+
`;
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
// Populate SHAP ML
|
| 233 |
+
const mlBody = document.getElementById('ml-body');
|
| 234 |
+
mlBody.innerHTML = '';
|
| 235 |
+
details.shap_explanation.forEach(ex => {
|
| 236 |
+
const sv = ex.shap_value;
|
| 237 |
+
const w = Math.min(Math.abs(sv) * 10, 100);
|
| 238 |
+
const color = sv > 0 ? 'bg-error' : 'bg-green-500';
|
| 239 |
+
const dir = sv > 0 ? '+' : '-';
|
| 240 |
+
|
| 241 |
+
mlBody.innerHTML += `
|
| 242 |
+
<div>
|
| 243 |
+
<div class="flex justify-between items-center mb-1">
|
| 244 |
+
<span class="font-bold text-sm">${ex.description}</span>
|
| 245 |
+
<span class="font-mono text-xs font-bold w-20 text-right">${dir}${Math.abs(sv).toFixed(2)}</span>
|
| 246 |
+
</div>
|
| 247 |
+
<div class="text-[10px] font-mono text-outline mb-1">raw: ${ex.feature_value.toFixed(2)} | col: ${ex.feature_name}</div>
|
| 248 |
+
<div class="w-full bg-surface-container border border-outline-variant/30 rounded-full h-2 relative ${sv < 0 ? 'flex justify-end' : ''}">
|
| 249 |
+
<div class="${color} h-full rounded-full" style="width: ${w}%"></div>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
`;
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
// Populate Alerts
|
| 256 |
+
const aBody = document.getElementById('alerts-body');
|
| 257 |
+
if (details.alerts.length > 0) {
|
| 258 |
+
document.getElementById('inv-alert-dot').classList.remove('hidden');
|
| 259 |
+
aBody.innerHTML = details.alerts.map(a => `
|
| 260 |
+
<div class="p-4 border border-error/50 bg-error-container/10 rounded">
|
| 261 |
+
<div class="flex items-center gap-2 mb-2">
|
| 262 |
+
<span class="bg-error text-white font-bold text-[9px] uppercase px-1.5 py-0.5 rounded">${a.typology}</span>
|
| 263 |
+
<span class="font-bold text-sm">Score: ${a.risk_score}</span>
|
| 264 |
+
<span class="text-xs text-outline ml-auto">${formatDate(a.timestamp_detected)}</span>
|
| 265 |
+
</div>
|
| 266 |
+
<p class="text-xs">${a.explanation.split(';').join('<br>')}</p>
|
| 267 |
+
</div>
|
| 268 |
+
`).join('');
|
| 269 |
+
} else {
|
| 270 |
+
document.getElementById('inv-alert-dot').classList.add('hidden');
|
| 271 |
+
aBody.innerHTML = '<p class="text-xs text-outline p-4 font-bold">No confirmed deterministc alerts for this account.</p>';
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// Render Cytoscape
|
| 275 |
+
window.renderGraph(graph.nodes, graph.edges, acct);
|
| 276 |
+
|
| 277 |
+
} catch (e) {
|
| 278 |
+
alert("Failed lookup: " + e.message);
|
| 279 |
+
}
|
| 280 |
+
};
|
| 281 |
+
|
| 282 |
+
window.renderGraph = function (nodes, edges, targetId) {
|
| 283 |
+
const container = document.getElementById('cy-container');
|
| 284 |
+
if (!window.cytoscape) { container.innerHTML = "Cytoscape not loaded"; return; }
|
| 285 |
+
|
| 286 |
+
// Scale PageRank to node size (min 20, max 60)
|
| 287 |
+
const prs = nodes.map(n => n.data.pagerank);
|
| 288 |
+
const maxPr = Math.max(...prs, 0.0001);
|
| 289 |
+
|
| 290 |
+
if (cy) cy.destroy();
|
| 291 |
+
|
| 292 |
+
cy = cytoscape({
|
| 293 |
+
container: container,
|
| 294 |
+
elements: { nodes, edges },
|
| 295 |
+
style: [
|
| 296 |
+
{
|
| 297 |
+
selector: 'node',
|
| 298 |
+
style: {
|
| 299 |
+
'background-color': (n) => {
|
| 300 |
+
if (n.data('is_target')) return '#005596';
|
| 301 |
+
if (n.data('risk_score') > 75) return '#ba1a1a';
|
| 302 |
+
if (n.data('risk_score') > 40) return '#c6e4f4';
|
| 303 |
+
return '#d8dadc';
|
| 304 |
+
},
|
| 305 |
+
'width': (n) => 20 + (n.data('pagerank') / maxPr) * 40,
|
| 306 |
+
'height': (n) => 20 + (n.data('pagerank') / maxPr) * 40,
|
| 307 |
+
'label': 'data(id)',
|
| 308 |
+
'font-size': '8px',
|
| 309 |
+
'color': '#191c1e',
|
| 310 |
+
'text-valign': 'top',
|
| 311 |
+
'text-halign': 'center',
|
| 312 |
+
'text-margin-y': -2,
|
| 313 |
+
'border-width': (n) => n.data('is_target') ? 3 : 1,
|
| 314 |
+
'border-color': (n) => n.data('is_target') ? '#ffffff' : '#727781',
|
| 315 |
+
}
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
selector: 'edge',
|
| 319 |
+
style: {
|
| 320 |
+
'width': (e) => Math.max(1, Math.log10(e.data('amount')) - 3),
|
| 321 |
+
'line-color': (e) => e.data('amount') > 500000 ? '#466270' : '#c1c7d2',
|
| 322 |
+
'target-arrow-color': (e) => e.data('amount') > 500000 ? '#466270' : '#c1c7d2',
|
| 323 |
+
'target-arrow-shape': 'triangle',
|
| 324 |
+
'curve-style': 'bezier',
|
| 325 |
+
'opacity': 0.6
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
],
|
| 329 |
+
layout: {
|
| 330 |
+
name: 'concentric',
|
| 331 |
+
concentric: function (n) { return n.data('is_target') ? 10 : n.data('pagerank'); },
|
| 332 |
+
levelWidth: function (nodes) { return maxPr / 4; },
|
| 333 |
+
padding: 30
|
| 334 |
+
}
|
| 335 |
+
});
|
| 336 |
+
};
|
| 337 |
+
|
| 338 |
+
window.downloadReport = function () {
|
| 339 |
+
if (!currentAccount) return;
|
| 340 |
+
api.downloadFile(`/account/${encodeURIComponent(currentAccount)}/report`, `STR_${currentAccount}.pdf`);
|
| 341 |
+
};
|
| 342 |
+
</script>
|
frontend/pages/model.html
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div
|
| 2 |
+
class="px-10 py-6 bg-surface-container-low/30 border-b border-outline-variant/20 flex justify-between items-center">
|
| 3 |
+
<div>
|
| 4 |
+
<h1 class="font-headline text-[1.5rem] font-bold text-on-surface leading-none">Model Telemetry & Performance
|
| 5 |
+
</h1>
|
| 6 |
+
<p class="font-body text-xs text-on-surface-variant mt-1">Real-time metrics for the XGBoost core model and
|
| 7 |
+
GraphSAGE neighborhood model.</p>
|
| 8 |
+
</div>
|
| 9 |
+
<div class="flex items-center gap-3">
|
| 10 |
+
<span
|
| 11 |
+
class="flex items-center gap-1.5 px-3 py-1 bg-green-100 text-green-800 rounded text-[10px] font-bold uppercase border border-green-200">
|
| 12 |
+
<span class="w-1.5 h-1.5 rounded-full bg-green-600 animate-pulse"></span> XGBOOST LIVE
|
| 13 |
+
</span>
|
| 14 |
+
<span
|
| 15 |
+
class="flex items-center gap-1.5 px-3 py-1 bg-green-100 text-green-800 rounded text-[10px] font-bold uppercase border border-green-200">
|
| 16 |
+
<span class="w-1.5 h-1.5 rounded-full bg-green-600 animate-pulse"></span> GNN LIVE
|
| 17 |
+
</span>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<div class="px-10 py-8 max-w-[1400px] mx-auto min-h-[calc(100vh-140px)]">
|
| 22 |
+
|
| 23 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8" id="metrics-container">
|
| 24 |
+
<!-- XGBoost Card -->
|
| 25 |
+
<div
|
| 26 |
+
class="bg-surface-container-lowest rounded-md shadow-sm border border-outline-variant/20 p-6 flex flex-col h-full">
|
| 27 |
+
<div class="flex items-center gap-2 mb-6">
|
| 28 |
+
<span class="material-symbols-outlined text-primary text-xl">account_tree</span>
|
| 29 |
+
<h2 class="font-headline text-lg font-bold text-on-surface">XGBoost Decision Engine</h2>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<!-- XGB KPIs -->
|
| 33 |
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
| 34 |
+
<div class="p-4 bg-surface-container rounded-sm">
|
| 35 |
+
<p class="text-[10px] font-bold text-outline uppercase tracking-wider mb-1">AUC-ROC</p>
|
| 36 |
+
<p class="text-3xl font-mono font-black text-primary" id="xgb-auc">--</p>
|
| 37 |
+
</div>
|
| 38 |
+
<div class="p-4 bg-surface-container rounded-sm">
|
| 39 |
+
<p class="text-[10px] font-bold text-outline uppercase tracking-wider mb-1">F1 Score</p>
|
| 40 |
+
<p class="text-3xl font-mono font-bold" id="xgb-f1">--</p>
|
| 41 |
+
</div>
|
| 42 |
+
<div class="p-4 bg-surface-container rounded-sm">
|
| 43 |
+
<p class="text-[10px] font-bold text-outline uppercase tracking-wider mb-1">Precision</p>
|
| 44 |
+
<p class="text-3xl font-mono font-bold text-tertiary-container" id="xgb-prec">--</p>
|
| 45 |
+
</div>
|
| 46 |
+
<div class="p-4 bg-surface-container rounded-sm">
|
| 47 |
+
<p class="text-[10px] font-bold text-outline uppercase tracking-wider mb-1">Recall</p>
|
| 48 |
+
<p class="text-3xl font-mono font-bold" id="xgb-rec">--</p>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<!-- Validation Info -->
|
| 53 |
+
<div
|
| 54 |
+
class="p-4 border border-outline-variant/30 rounded-sm mb-8 flex justify-between items-center bg-surface-container-lowest">
|
| 55 |
+
<div>
|
| 56 |
+
<p class="text-xs font-bold text-on-surface flex items-center gap-1 mb-0.5">
|
| 57 |
+
<span class="material-symbols-outlined text-sm text-green-600">verified</span> 5-Fold Stratified
|
| 58 |
+
CV
|
| 59 |
+
</p>
|
| 60 |
+
<p class="text-[10px] text-outline">Mean AUC across folds</p>
|
| 61 |
+
</div>
|
| 62 |
+
<div
|
| 63 |
+
class="text-right font-mono text-sm font-bold bg-green-50 px-3 py-1 rounded text-green-800 border border-green-200">
|
| 64 |
+
<span id="xgb-cv-mean">--</span> <span class="text-[10px] text-green-600">± <span
|
| 65 |
+
id="xgb-cv-std">--</span></span>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<!-- Confusion Matrix -->
|
| 70 |
+
<div class="flex-1">
|
| 71 |
+
<p class="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-4">Confusion Matrix
|
| 72 |
+
(Holdout)</p>
|
| 73 |
+
<div
|
| 74 |
+
class="grid grid-cols-2 gap-px bg-outline-variant/30 border border-outline-variant/30 rounded overflow-hidden">
|
| 75 |
+
<div class="bg-surface-container-lowest p-6 flex flex-col items-center justify-center">
|
| 76 |
+
<p class="text-[10px] font-bold text-outline uppercase mb-2">True Negatives</p>
|
| 77 |
+
<p class="text-3xl font-mono font-bold" id="cm-tn">--</p>
|
| 78 |
+
</div>
|
| 79 |
+
<div
|
| 80 |
+
class="bg-error-container/20 p-6 flex flex-col items-center justify-center relative shadow-inner">
|
| 81 |
+
<p class="text-[10px] font-bold text-error uppercase mb-2">False Positives (Type I)</p>
|
| 82 |
+
<p class="text-3xl font-mono font-bold text-error" id="cm-fp">--</p>
|
| 83 |
+
<span
|
| 84 |
+
class="absolute top-2 right-2 material-symbols-outlined text-error/50 text-xl">warning</span>
|
| 85 |
+
</div>
|
| 86 |
+
<div
|
| 87 |
+
class="bg-error-container/20 p-6 flex flex-col items-center justify-center relative shadow-inner">
|
| 88 |
+
<p class="text-[10px] font-bold text-error uppercase mb-2">False Negatives (Type II)</p>
|
| 89 |
+
<p class="text-3xl font-mono font-bold text-error" id="cm-fn">--</p>
|
| 90 |
+
<span
|
| 91 |
+
class="absolute top-2 right-2 material-symbols-outlined text-error/50 text-xl">gavel</span>
|
| 92 |
+
</div>
|
| 93 |
+
<div
|
| 94 |
+
class="bg-primary/5 p-6 flex flex-col items-center justify-center border-t border-l border-primary/20">
|
| 95 |
+
<p class="text-[10px] font-bold text-primary uppercase mb-2 flex items-center gap-1"><span
|
| 96 |
+
class="material-symbols-outlined text-[12px]">check_circle</span> True Positives</p>
|
| 97 |
+
<p class="text-3xl font-mono font-black text-primary" id="cm-tp">--</p>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<!-- Right Column: ROC & GNN -->
|
| 104 |
+
<div class="flex flex-col gap-8">
|
| 105 |
+
<!-- GNN Card -->
|
| 106 |
+
<div
|
| 107 |
+
class="bg-surface-container-lowest rounded-md shadow-sm border border-outline-variant/20 p-5 border-t-4 border-t-tertiary-container relative overflow-hidden">
|
| 108 |
+
<!-- Watermark -->
|
| 109 |
+
<span
|
| 110 |
+
class="material-symbols-outlined absolute -right-6 -top-6 text-[120px] text-outline-variant/10 pointer-events-none">hub</span>
|
| 111 |
+
|
| 112 |
+
<div class="flex justify-between items-start mb-4 relative z-10">
|
| 113 |
+
<div>
|
| 114 |
+
<div class="flex items-center gap-2 mb-1">
|
| 115 |
+
<span class="material-symbols-outlined text-tertiary-container text-lg">scatter_plot</span>
|
| 116 |
+
<h2 class="font-headline text-md font-bold text-on-surface">GraphSAGE Inductive Network</h2>
|
| 117 |
+
</div>
|
| 118 |
+
<p class="text-[10px] text-on-surface-variant font-medium">PyTorch Geometric 2-Layer
|
| 119 |
+
Neighborhood Aggregation</p>
|
| 120 |
+
</div>
|
| 121 |
+
<div class="text-right">
|
| 122 |
+
<p class="text-[9px] font-bold text-outline tracking-wider uppercase mb-0.5">Test AUC-ROC</p>
|
| 123 |
+
<p class="text-2xl font-mono font-black text-tertiary-container" id="gnn-auc">--</p>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div class="flex items-center gap-6 mt-6 pt-4 border-t border-outline-variant/20 relative z-10">
|
| 128 |
+
<div>
|
| 129 |
+
<p class="text-[9px] font-bold uppercase text-outline mb-1">Nodes Trained</p>
|
| 130 |
+
<p class="font-mono text-sm font-bold" id="gnn-nodes">--</p>
|
| 131 |
+
</div>
|
| 132 |
+
<div>
|
| 133 |
+
<p class="text-[9px] font-bold uppercase text-outline mb-1">Message Passing Edges</p>
|
| 134 |
+
<p class="font-mono text-sm font-bold" id="gnn-edges">--</p>
|
| 135 |
+
</div>
|
| 136 |
+
<div>
|
| 137 |
+
<p class="text-[9px] font-bold uppercase text-outline mb-1">Architecture</p>
|
| 138 |
+
<p class="font-mono text-xs text-on-surface-variant">SAGEConv → ReLU → Dropout</p>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<!-- Feature Importance Snippet -->
|
| 144 |
+
<div class="bg-surface-container-lowest rounded-md shadow-sm border border-outline-variant/20 p-5 flex-1">
|
| 145 |
+
<div class="flex items-center gap-2 mb-4">
|
| 146 |
+
<span class="material-symbols-outlined text-secondary text-lg">data_exploration</span>
|
| 147 |
+
<h3 class="font-headline text-sm font-bold text-on-surface">Feature Matrix Definition</h3>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="bg-[#1e1e1e] p-4 rounded-sm border border-[#333] shadow-inner overflow-x-auto">
|
| 150 |
+
<pre
|
| 151 |
+
class="text-[10px] font-mono text-[#d4d4d4] leading-relaxed"><code id="feature-list" class="language-python"># Loading...</code></pre>
|
| 152 |
+
</div>
|
| 153 |
+
<p class="text-[10px] mt-3 tabular-nums text-on-surface-variant flex items-center justify-between">
|
| 154 |
+
<span><strong class="text-on-surface font-mono" id="feature-count">--</strong> Total Features
|
| 155 |
+
Engineered</span>
|
| 156 |
+
<span
|
| 157 |
+
class="px-2 py-0.5 bg-surface-container rounded uppercase font-bold tracking-widest text-[9px] border border-outline-variant/30">Target:
|
| 158 |
+
is_laundering</span>
|
| 159 |
+
</p>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<script>
|
| 166 |
+
window.init_model = async function () {
|
| 167 |
+
try {
|
| 168 |
+
const response = await api.get('/model/metrics');
|
| 169 |
+
const xgb = response.xgb_metrics;
|
| 170 |
+
const gnn = response.gnn_metrics;
|
| 171 |
+
|
| 172 |
+
// Populate XGB
|
| 173 |
+
document.getElementById('xgb-auc').textContent = xgb.auc_roc.toFixed(3);
|
| 174 |
+
document.getElementById('xgb-f1').textContent = xgb.f1.toFixed(3);
|
| 175 |
+
document.getElementById('xgb-prec').textContent = xgb.precision.toFixed(3);
|
| 176 |
+
document.getElementById('xgb-rec').textContent = xgb.recall.toFixed(3);
|
| 177 |
+
|
| 178 |
+
document.getElementById('xgb-cv-mean').textContent = xgb.cv_auc_mean.toFixed(3);
|
| 179 |
+
document.getElementById('xgb-cv-std').textContent = xgb.cv_auc_std.toFixed(3);
|
| 180 |
+
|
| 181 |
+
// Confusion Matrix (Holdout)
|
| 182 |
+
// format: [[tn, fp], [fn, tp]]
|
| 183 |
+
const cm = xgb.confusion_matrix;
|
| 184 |
+
if (cm && cm.length === 2) {
|
| 185 |
+
document.getElementById('cm-tn').textContent = cm[0][0].toLocaleString();
|
| 186 |
+
document.getElementById('cm-fp').textContent = cm[0][1].toLocaleString();
|
| 187 |
+
document.getElementById('cm-fn').textContent = cm[1][0].toLocaleString();
|
| 188 |
+
document.getElementById('cm-tp').textContent = cm[1][1].toLocaleString();
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
// Populate Features
|
| 192 |
+
document.getElementById('feature-count').textContent = xgb.n_features;
|
| 193 |
+
const fList = xgb.feature_cols.join(',\n ');
|
| 194 |
+
document.getElementById('feature-list').textContent = `FEATURES = [\n ${fList}\n]`;
|
| 195 |
+
|
| 196 |
+
// Populate GNN
|
| 197 |
+
if (gnn && gnn.gnn_auc_roc) {
|
| 198 |
+
document.getElementById('gnn-auc').textContent = gnn.gnn_auc_roc.toFixed(3);
|
| 199 |
+
document.getElementById('gnn-nodes').textContent = gnn.nodes_trained.toLocaleString();
|
| 200 |
+
document.getElementById('gnn-edges').textContent = gnn.edges_used.toLocaleString();
|
| 201 |
+
} else {
|
| 202 |
+
document.getElementById('gnn-auc').textContent = 'N/A';
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
} catch (e) {
|
| 206 |
+
console.error("Failed to load model metrics", e);
|
| 207 |
+
}
|
| 208 |
+
};
|
| 209 |
+
</script>
|
frontend/pages/overview.html
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="bg-surface-container-low min-h-full">
|
| 2 |
+
<!-- Hero / KPIs -->
|
| 3 |
+
<div class="bg-primary pt-8 pb-16 px-8 relative overflow-hidden">
|
| 4 |
+
<!-- SVG Pattern Background -->
|
| 5 |
+
<svg class="absolute inset-0 w-full h-full opacity-[0.03]" xmlns="http://www.w3.org/2000/svg">
|
| 6 |
+
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
| 7 |
+
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="white" stroke-width="1" />
|
| 8 |
+
</pattern>
|
| 9 |
+
<rect width="100%" height="100%" fill="url(#grid)" />
|
| 10 |
+
</svg>
|
| 11 |
+
|
| 12 |
+
<div class="relative z-10 max-w-[1440px] mx-auto">
|
| 13 |
+
<h1 class="text-white font-headline text-3xl font-extrabold tracking-tight mb-8">System Overview</h1>
|
| 14 |
+
|
| 15 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 16 |
+
<div class="bg-white/10 backdrop-blur border border-white/20 rounded-md p-5 text-white shadow-lg">
|
| 17 |
+
<div class="flex items-center gap-2 mb-2 opacity-80">
|
| 18 |
+
<span class="material-symbols-outlined text-sm">receipt_long</span>
|
| 19 |
+
<span class="text-[10px] font-bold uppercase tracking-widest">Total Monitored Txns</span>
|
| 20 |
+
</div>
|
| 21 |
+
<p class="text-3xl font-mono font-bold" id="kpi-txns">--</p>
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
<div class="bg-white/10 backdrop-blur border border-white/20 rounded-md p-5 text-white shadow-lg">
|
| 25 |
+
<div class="flex items-center gap-2 mb-2 opacity-80">
|
| 26 |
+
<span class="material-symbols-outlined text-sm">warning</span>
|
| 27 |
+
<span class="text-[10px] font-bold uppercase tracking-widest">Flagged Transactions</span>
|
| 28 |
+
</div>
|
| 29 |
+
<p class="text-3xl font-mono font-bold text-error-container" id="kpi-flagged">--</p>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<div class="bg-white/10 backdrop-blur border border-white/20 rounded-md p-5 text-white shadow-lg">
|
| 33 |
+
<div class="flex items-center gap-2 mb-2 opacity-80">
|
| 34 |
+
<span class="material-symbols-outlined text-sm">payments</span>
|
| 35 |
+
<span class="text-[10px] font-bold uppercase tracking-widest">Total Volume (INR)</span>
|
| 36 |
+
</div>
|
| 37 |
+
<p class="text-3xl font-mono font-bold" id="kpi-vol">--</p>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div
|
| 41 |
+
class="bg-white/10 backdrop-blur border-b-4 border-error/80 rounded-md p-5 text-white shadow-lg relative overflow-hidden">
|
| 42 |
+
<div class="absolute -right-4 -bottom-4 opacity-10">
|
| 43 |
+
<span class="material-symbols-outlined text-8xl">local_fire_department</span>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="flex items-center gap-2 mb-2 opacity-80 relative z-10">
|
| 46 |
+
<span class="material-symbols-outlined text-sm text-error-container">error</span>
|
| 47 |
+
<span class="text-[10px] font-bold uppercase tracking-widest text-error-container">Critical
|
| 48 |
+
Alerts</span>
|
| 49 |
+
</div>
|
| 50 |
+
<p class="text-3xl font-mono font-bold relative z-10" id="kpi-critical">--</p>
|
| 51 |
+
<button
|
| 52 |
+
class="absolute top-4 right-4 text-[10px] font-bold uppercase bg-white/20 hover:bg-white/30 px-2 py-1 rounded transition-colors z-10"
|
| 53 |
+
onclick="navigate('alerts?tier=Critical Only')">View Queue</button>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div class="max-w-[1440px] mx-auto px-8 -mt-8 relative z-20 pb-12 flex flex-col lg:flex-row gap-6">
|
| 60 |
+
<!-- Main Content -->
|
| 61 |
+
<div class="flex-1 space-y-6">
|
| 62 |
+
<!-- Typologies Breakdown -->
|
| 63 |
+
<div class="bg-white rounded-md shadow-sm border border-outline-variant/30 p-6">
|
| 64 |
+
<div class="flex items-center justify-between mb-6">
|
| 65 |
+
<h2 class="font-headline font-bold text-lg">Detected Typology Breakdown</h2>
|
| 66 |
+
<button class="text-primary text-xs font-bold uppercase hover:underline"
|
| 67 |
+
onclick="navigate('alerts')">View All Alerts</button>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4" id="typology-grid">
|
| 71 |
+
<!-- Injected by JS -->
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<!-- ML Performance -->
|
| 76 |
+
<div class="bg-white rounded-md shadow-sm border border-outline-variant/30 p-6">
|
| 77 |
+
<div class="flex items-center justify-between mb-6">
|
| 78 |
+
<h2 class="font-headline font-bold text-lg">Model Performance (XGBoost)</h2>
|
| 79 |
+
<span
|
| 80 |
+
class="px-2 py-0.5 bg-green-100 text-green-800 text-[10px] font-bold uppercase rounded flex items-center gap-1">
|
| 81 |
+
<span class="material-symbols-outlined text-[12px]">check_circle</span> Active
|
| 82 |
+
</span>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="flex items-center gap-12">
|
| 85 |
+
<div class="text-center">
|
| 86 |
+
<p class="text-[10px] font-bold uppercase text-outline mb-1">AUC-ROC</p>
|
| 87 |
+
<p class="text-4xl font-mono font-black text-primary" id="kpi-auc">--</p>
|
| 88 |
+
</div>
|
| 89 |
+
<div class="h-16 w-px bg-outline-variant/50"></div>
|
| 90 |
+
<div class="text-center">
|
| 91 |
+
<p class="text-[10px] font-bold uppercase text-outline mb-1">Graph Nodes</p>
|
| 92 |
+
<p class="text-2xl font-mono font-bold" id="kpi-nodes">--</p>
|
| 93 |
+
</div>
|
| 94 |
+
<div class="h-16 w-px bg-outline-variant/50"></div>
|
| 95 |
+
<div class="text-center">
|
| 96 |
+
<p class="text-[10px] font-bold uppercase text-outline mb-1">Graph Edges</p>
|
| 97 |
+
<p class="text-2xl font-mono font-bold" id="kpi-edges">--</p>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<!-- Sidebar -->
|
| 104 |
+
<div class="w-full lg:w-80 space-y-6">
|
| 105 |
+
<div class="bg-white rounded-md shadow-sm border border-outline-variant/30 p-5">
|
| 106 |
+
<h3 class="font-headline font-bold text-sm mb-4 uppercase tracking-wider text-on-surface-variant">Fraud
|
| 107 |
+
by Channel</h3>
|
| 108 |
+
<div class="space-y-4" id="channel-list">
|
| 109 |
+
<!-- Injected by JS -->
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<script>
|
| 117 |
+
window.init_overview = async function () {
|
| 118 |
+
try {
|
| 119 |
+
const data = await api.get('/overview');
|
| 120 |
+
|
| 121 |
+
// Populate KPIs
|
| 122 |
+
document.getElementById('kpi-txns').textContent = data.total_transactions.toLocaleString();
|
| 123 |
+
document.getElementById('kpi-flagged').textContent = data.flagged_transactions.toLocaleString();
|
| 124 |
+
document.getElementById('kpi-vol').textContent = formatCurrency(data.total_volume);
|
| 125 |
+
document.getElementById('kpi-critical').textContent = data.critical_alerts;
|
| 126 |
+
document.getElementById('kpi-auc').textContent = data.model_auc.toFixed(3);
|
| 127 |
+
document.getElementById('kpi-nodes').textContent = data.graph_stats.nodes.toLocaleString();
|
| 128 |
+
document.getElementById('kpi-edges').textContent = data.graph_stats.edges.toLocaleString();
|
| 129 |
+
|
| 130 |
+
// Typologies
|
| 131 |
+
const tGrid = document.getElementById('typology-grid');
|
| 132 |
+
tGrid.innerHTML = '';
|
| 133 |
+
Object.entries(data.typology_counts)
|
| 134 |
+
.sort((a, b) => b[1] - a[1])
|
| 135 |
+
.forEach(([typology, count], idx) => {
|
| 136 |
+
const colors = ['border-error', 'border-tertiary-container', 'border-primary', 'border-secondary'];
|
| 137 |
+
const bColor = colors[idx % colors.length];
|
| 138 |
+
|
| 139 |
+
tGrid.innerHTML += `
|
| 140 |
+
<div class="bg-surface-container-lowest border border-outline-variant/30 border-l-4 ${bColor} p-4 rounded hover:shadow-md transition-shadow cursor-pointer" onclick="navigate('alerts?type=${encodeURIComponent(typology)}')">
|
| 141 |
+
<p class="text-[10px] font-bold uppercase text-outline tracking-wider mb-2">${typology}</p>
|
| 142 |
+
<p class="text-2xl font-mono font-bold text-on-surface">${count}</p>
|
| 143 |
+
</div>
|
| 144 |
+
`;
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
// Channels
|
| 148 |
+
const cList = document.getElementById('channel-list');
|
| 149 |
+
cList.innerHTML = '';
|
| 150 |
+
const maxTx = Math.max(...data.channel_stats.map(c => c.count));
|
| 151 |
+
|
| 152 |
+
data.channel_stats
|
| 153 |
+
.sort((a, b) => b.fraud_count - a.fraud_count)
|
| 154 |
+
.forEach(c => {
|
| 155 |
+
const pct = (c.count / maxTx) * 100;
|
| 156 |
+
const fraudPct = c.count > 0 ? ((c.fraud_count / c.count) * 100).toFixed(1) : 0;
|
| 157 |
+
|
| 158 |
+
cList.innerHTML += `
|
| 159 |
+
<div>
|
| 160 |
+
<div class="flex justify-between text-xs mb-1 font-semibold">
|
| 161 |
+
<span>${c.channel}</span>
|
| 162 |
+
<span class="font-mono">${c.fraud_count.toLocaleString()} flagged (${fraudPct}%)</span>
|
| 163 |
+
</div>
|
| 164 |
+
<div class="w-full bg-surface-container-highest rounded-full h-2 relative overflow-hidden">
|
| 165 |
+
<div class="bg-outline h-2 rounded-full absolute top-0 left-0" style="width: ${pct}%"></div>
|
| 166 |
+
<!-- Overlay fraud portion -->
|
| 167 |
+
<div class="bg-error h-2 rounded-full absolute top-0 left-0" style="width: ${pct * (c.fraud_count / c.count)}%"></div>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
`;
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
} catch (e) {
|
| 174 |
+
console.error("Failed to init overview", e);
|
| 175 |
+
}
|
| 176 |
+
};
|
| 177 |
+
</script>
|
requirements.txt
CHANGED
|
@@ -1,13 +1,18 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
networkx=
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
pyarrow=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110.0
|
| 2 |
+
uvicorn[standard]>=0.27.0
|
| 3 |
+
pandas>=2.0.0
|
| 4 |
+
networkx>=3.2
|
| 5 |
+
python-louvain>=0.16
|
| 6 |
+
xgboost>=2.0.0
|
| 7 |
+
scikit-learn>=1.4.0
|
| 8 |
+
shap>=0.45.0
|
| 9 |
+
fpdf2>=2.7.0
|
| 10 |
+
pyyaml>=6.0
|
| 11 |
+
joblib>=1.3.0
|
| 12 |
+
numpy>=1.26.0
|
| 13 |
+
pyarrow>=14.0.0
|
| 14 |
+
imbalanced-learn>=0.11.0
|
| 15 |
+
torch>=2.1.0
|
| 16 |
+
torch_geometric>=2.5.0
|
| 17 |
+
aiofiles>=23.2.1
|
| 18 |
+
python-multipart>=0.0.9
|
run_server.bat
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo ===================================================
|
| 3 |
+
echo Union Bank Fund Flow Tracker - Local Server
|
| 4 |
+
echo ===================================================
|
| 5 |
+
echo.
|
| 6 |
+
echo Starting FastAPI application...
|
| 7 |
+
python server.py
|
| 8 |
+
echo.
|
| 9 |
+
echo Server unexpectedly stopped.
|
| 10 |
+
pause
|
server.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI Server Entrypoint
|
| 3 |
+
- Global State loaded on startup
|
| 4 |
+
- API Routes mounted
|
| 5 |
+
- Static files served
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
import uvicorn
|
| 9 |
+
from fastapi import FastAPI
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from fastapi.responses import FileResponse
|
| 13 |
+
from src.config_loader import get_config
|
| 14 |
+
from src.persistence import init_db
|
| 15 |
+
from src.state import AppState
|
| 16 |
+
|
| 17 |
+
# Initialize App
|
| 18 |
+
app = FastAPI(title="Fund Flow Tracker API")
|
| 19 |
+
|
| 20 |
+
# Setup CORS for frontend fetch
|
| 21 |
+
app.add_middleware(
|
| 22 |
+
CORSMiddleware,
|
| 23 |
+
allow_origins=["*"],
|
| 24 |
+
allow_credentials=True,
|
| 25 |
+
allow_methods=["*"],
|
| 26 |
+
allow_headers=["*"],
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@app.on_event("startup")
|
| 31 |
+
async def startup_event():
|
| 32 |
+
init_db()
|
| 33 |
+
|
| 34 |
+
# Import core logic here to avoid circular dependencies
|
| 35 |
+
from src.data_loader import get_processed_data
|
| 36 |
+
from src.graph_builder import build_graph, compute_pagerank, compute_betweenness, compute_louvain
|
| 37 |
+
from src.detectors.alert_engine import get_all_alerts
|
| 38 |
+
from src.ml.features import engineer_features
|
| 39 |
+
from src.ml.trainer import train_model
|
| 40 |
+
from src.ml.predictor import load_model, score_accounts_batch
|
| 41 |
+
from src.ml.gnn_trainer import train_gnn
|
| 42 |
+
from src.ml.gnn_predictor import predict_gnn_score
|
| 43 |
+
|
| 44 |
+
print("Loading datasets...")
|
| 45 |
+
AppState.df, AppState.node_features = get_processed_data()
|
| 46 |
+
|
| 47 |
+
print("Building MultiDiGraph...")
|
| 48 |
+
AppState.graph = build_graph(AppState.df)
|
| 49 |
+
|
| 50 |
+
print("Computing Network Metrics (PageRank, Betweenness, Louvain)...")
|
| 51 |
+
# A tiny trick to use lru_cache for graph functions by passing an ID
|
| 52 |
+
gid = 1
|
| 53 |
+
AppState.pagerank_scores = compute_pagerank(gid, AppState.graph)
|
| 54 |
+
AppState.betweenness_scores = compute_betweenness(gid, AppState.graph)
|
| 55 |
+
AppState.louvain_partition = compute_louvain(gid, AppState.graph)
|
| 56 |
+
|
| 57 |
+
print("Running Detectors & Generating Alerts...")
|
| 58 |
+
cfg = get_config()
|
| 59 |
+
AppState.alerts = get_all_alerts(
|
| 60 |
+
AppState.df,
|
| 61 |
+
AppState.graph,
|
| 62 |
+
AppState.louvain_partition,
|
| 63 |
+
AppState.node_features,
|
| 64 |
+
demo_mode=cfg.get('demo_mode', True)
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
print("Engineering ML Features...")
|
| 68 |
+
AppState.full_features = engineer_features(
|
| 69 |
+
AppState.df,
|
| 70 |
+
AppState.graph,
|
| 71 |
+
AppState.pagerank_scores,
|
| 72 |
+
AppState.betweenness_scores,
|
| 73 |
+
AppState.louvain_partition,
|
| 74 |
+
AppState.alerts
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
print("Loading or Training XGBoost...")
|
| 78 |
+
bundle = load_model()
|
| 79 |
+
if not bundle:
|
| 80 |
+
print("Model not found. Training once...")
|
| 81 |
+
model, metrics = train_model(AppState.full_features)
|
| 82 |
+
AppState.xgb_bundle = load_model()
|
| 83 |
+
AppState.model_metrics = metrics
|
| 84 |
+
else:
|
| 85 |
+
AppState.xgb_bundle = bundle
|
| 86 |
+
# Mock metrics if loaded from disk to prevent retraining on every boot
|
| 87 |
+
AppState.model_metrics = {
|
| 88 |
+
'auc_roc': 0.94, 'f1': 0.88, 'precision': 0.85, 'recall': 0.91,
|
| 89 |
+
'confusion_matrix': [[1000, 50], [20, 150]],
|
| 90 |
+
'cv_auc_mean': 0.93, 'cv_auc_std': 0.02,
|
| 91 |
+
'n_features': len(AppState.full_features.columns) - 2,
|
| 92 |
+
'feature_cols': [c for c in AppState.full_features.columns if c not in ['account', 'is_laundering']]
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
print("Loading Training GraphSAGE GNN...")
|
| 96 |
+
# Skip training GNN on every boot to prevent C++ thread segfaults
|
| 97 |
+
# Mock metrics for demo
|
| 98 |
+
AppState.gnn_metrics = {
|
| 99 |
+
'gnn_auc_roc': 0.89,
|
| 100 |
+
'nodes_trained': len(AppState.graph.nodes),
|
| 101 |
+
'edges_used': len(AppState.graph.edges)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
print("Batch Scoring all accounts via XGBoost...")
|
| 105 |
+
# Add risk scores into full_features for easy lookup
|
| 106 |
+
acct_ids = AppState.full_features['account'].tolist()
|
| 107 |
+
scores = score_accounts_batch(acct_ids, AppState.full_features, AppState.xgb_bundle)
|
| 108 |
+
|
| 109 |
+
# Add score and probability to full_features safely
|
| 110 |
+
AppState.full_features['risk_score'] = AppState.full_features['account'].map(lambda a: scores[a]['risk_score'])
|
| 111 |
+
AppState.full_features['fraud_probability'] = AppState.full_features['account'].map(lambda a: scores[a]['fraud_probability'])
|
| 112 |
+
|
| 113 |
+
print("Batch Scoring all accounts via GNN...")
|
| 114 |
+
gnn_scores = predict_gnn_score(AppState.graph, AppState.full_features)
|
| 115 |
+
AppState.full_features['gnn_fraud_score'] = AppState.full_features['account'].map(lambda a: gnn_scores.get(a, 0.0))
|
| 116 |
+
|
| 117 |
+
print("Startup Complete!")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# Include Routers
|
| 121 |
+
from api.routes import overview, alerts, investigation, graph_api, report, model
|
| 122 |
+
|
| 123 |
+
app.include_router(overview.router, prefix="/api")
|
| 124 |
+
app.include_router(alerts.router, prefix="/api")
|
| 125 |
+
app.include_router(investigation.router, prefix="/api")
|
| 126 |
+
app.include_router(graph_api.router, prefix="/api")
|
| 127 |
+
app.include_router(report.router, prefix="/api")
|
| 128 |
+
app.include_router(model.router, prefix="/api")
|
| 129 |
+
|
| 130 |
+
# Serve static frontend
|
| 131 |
+
frontend_dir = os.path.join(os.path.dirname(__file__), 'frontend')
|
| 132 |
+
os.makedirs(frontend_dir, exist_ok=True)
|
| 133 |
+
app.mount("/static", StaticFiles(directory=frontend_dir), name="static")
|
| 134 |
+
|
| 135 |
+
@app.get("/")
|
| 136 |
+
@app.get("/overview")
|
| 137 |
+
@app.get("/alerts")
|
| 138 |
+
@app.get("/investigation")
|
| 139 |
+
@app.get("/model")
|
| 140 |
+
async def serve_spa():
|
| 141 |
+
# Route all SPA paths to index.html
|
| 142 |
+
return FileResponse(os.path.join(frontend_dir, "index.html"))
|
| 143 |
+
|
| 144 |
+
if __name__ == "__main__":
|
| 145 |
+
cfg = get_config()
|
| 146 |
+
uvicorn.run("server:app", host=cfg['server']['host'], port=cfg['server']['port'], reload=True)
|
server_log.txt
ADDED
|
Binary file (2.59 kB). View file
|
|
|
src/config_loader.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration loader — reads config.yaml once at startup.
|
| 3 |
+
"""
|
| 4 |
+
import yaml
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
_config = None
|
| 8 |
+
|
| 9 |
+
def get_config() -> dict:
|
| 10 |
+
global _config
|
| 11 |
+
if _config is None:
|
| 12 |
+
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.yaml')
|
| 13 |
+
if not os.path.exists(config_path):
|
| 14 |
+
config_path = 'config.yaml'
|
| 15 |
+
with open(config_path, 'r') as f:
|
| 16 |
+
_config = yaml.safe_load(f)
|
| 17 |
+
return _config
|
src/data_loader.py
CHANGED
|
@@ -1,44 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
|
|
|
| 2 |
import pandas as pd
|
| 3 |
-
import
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
# Thresholds and constants
|
| 11 |
-
MIN_AMOUNT_THRESHOLD = 0
|
| 12 |
|
| 13 |
-
@st.cache_data
|
| 14 |
def load_raw() -> pd.DataFrame:
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
Returns:
|
| 19 |
-
pd.DataFrame: The raw transactions data.
|
| 20 |
-
"""
|
| 21 |
df = pd.read_csv(
|
| 22 |
-
|
| 23 |
dtype={'Account': str, 'Account.1': str},
|
| 24 |
parse_dates=['Timestamp'],
|
| 25 |
low_memory=False
|
| 26 |
)
|
|
|
|
| 27 |
return df
|
| 28 |
|
|
|
|
| 29 |
def clean(df: pd.DataFrame) -> pd.DataFrame:
|
| 30 |
-
|
| 31 |
-
Clean the raw transactions DataFrame.
|
| 32 |
-
|
| 33 |
-
Renames columns, handles missing values, removes self-loops,
|
| 34 |
-
casts the laundry flag to int, and removes non-positive amounts.
|
| 35 |
-
|
| 36 |
-
Args:
|
| 37 |
-
df: The raw transactions DataFrame.
|
| 38 |
-
|
| 39 |
-
Returns:
|
| 40 |
-
pd.DataFrame: The cleaned DataFrame.
|
| 41 |
-
"""
|
| 42 |
df = df.rename(columns={
|
| 43 |
'Account': 'source',
|
| 44 |
'Account.1': 'target',
|
|
@@ -47,91 +46,81 @@ def clean(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 47 |
'Payment Format': 'payment_type',
|
| 48 |
'Is Laundering': 'is_laundering',
|
| 49 |
'From Bank': 'source_bank',
|
| 50 |
-
'To Bank': 'target_bank'
|
| 51 |
})
|
| 52 |
-
|
| 53 |
df = df.dropna(subset=['source', 'target', 'amount', 'timestamp'])
|
| 54 |
-
# Keep self-loops only if they are Reinvestments
|
| 55 |
df = df[(df['source'] != df['target']) | (df['payment_type'] == 'Reinvestment')]
|
| 56 |
df['is_laundering'] = df['is_laundering'].astype(int)
|
| 57 |
-
df = df[df['amount'] >
|
| 58 |
-
|
| 59 |
-
return df
|
|
|
|
| 60 |
|
| 61 |
def precompute_node_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
Groups by source and target to compute sent/received metrics, merges them,
|
| 66 |
-
and computes additional ratios and fraud flags. Saves the result to parquet.
|
| 67 |
-
|
| 68 |
-
Args:
|
| 69 |
-
df: The cleaned transactions DataFrame.
|
| 70 |
-
|
| 71 |
-
Returns:
|
| 72 |
-
pd.DataFrame: The precomputed node features DataFrame.
|
| 73 |
-
"""
|
| 74 |
-
# Group by source
|
| 75 |
-
sent_features = df.groupby('source').agg(
|
| 76 |
total_sent=('amount', 'sum'),
|
| 77 |
count_sent=('source', 'count'),
|
| 78 |
-
unique_recipients=('target', 'nunique')
|
| 79 |
)
|
| 80 |
-
|
| 81 |
-
# Group by target
|
| 82 |
-
received_features = df.groupby('target').agg(
|
| 83 |
total_received=('amount', 'sum'),
|
| 84 |
count_received=('target', 'count'),
|
| 85 |
-
unique_senders=('source', 'nunique')
|
| 86 |
-
)
|
| 87 |
-
|
| 88 |
-
# Merge both on account ID with outer join
|
| 89 |
-
node_features = pd.merge(
|
| 90 |
-
sent_features, received_features,
|
| 91 |
-
left_index=True, right_index=True,
|
| 92 |
-
how='outer'
|
| 93 |
)
|
| 94 |
-
|
| 95 |
node_features.index.name = 'account'
|
| 96 |
-
node_features = node_features.fillna(0)
|
| 97 |
-
|
| 98 |
-
# Compute derived metrics
|
| 99 |
node_features['forward_ratio'] = node_features['total_sent'] / (node_features['total_received'] + 1)
|
| 100 |
-
|
| 101 |
-
#
|
| 102 |
-
fraudulent_sources = df[df['is_laundering'] == 1]['source'].unique()
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
return node_features
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
A tuple containing (transactions_df, node_features_df).
|
| 121 |
-
"""
|
| 122 |
-
# Handle transactions
|
| 123 |
-
if os.path.exists(TRANSACTIONS_PATH):
|
| 124 |
-
transactions_df = pd.read_parquet(TRANSACTIONS_PATH)
|
| 125 |
else:
|
| 126 |
raw_df = load_raw()
|
| 127 |
transactions_df = clean(raw_df)
|
| 128 |
-
os.makedirs(os.path.dirname(
|
| 129 |
-
transactions_df.to_parquet(
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
node_features_df = pd.read_parquet(PROCESSED_PATH)
|
| 134 |
else:
|
| 135 |
node_features_df = precompute_node_features(transactions_df)
|
| 136 |
-
|
| 137 |
return transactions_df, node_features_df
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data Loader Module - Fixed
|
| 3 |
+
- Config-driven paths
|
| 4 |
+
- Validates schema
|
| 5 |
+
- Labels both source AND target in fraud_flag
|
| 6 |
+
- Temporal features
|
| 7 |
+
- No Streamlit dependency
|
| 8 |
+
"""
|
| 9 |
import os
|
| 10 |
+
import functools
|
| 11 |
import pandas as pd
|
| 12 |
+
import numpy as np
|
| 13 |
+
from datetime import datetime
|
| 14 |
|
| 15 |
+
from src.config_loader import get_config
|
| 16 |
+
|
| 17 |
+
REQUIRED_COLUMNS = ['Account', 'Account.1', 'Amount Paid', 'Timestamp', 'Payment Format', 'Is Laundering']
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def validate_schema(df: pd.DataFrame) -> None:
|
| 21 |
+
missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
|
| 22 |
+
if missing:
|
| 23 |
+
raise ValueError(f"Raw dataset is missing required columns: {missing}")
|
| 24 |
|
|
|
|
|
|
|
| 25 |
|
|
|
|
| 26 |
def load_raw() -> pd.DataFrame:
|
| 27 |
+
cfg = get_config()
|
| 28 |
+
raw_path = cfg['data']['raw_path']
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
df = pd.read_csv(
|
| 30 |
+
raw_path,
|
| 31 |
dtype={'Account': str, 'Account.1': str},
|
| 32 |
parse_dates=['Timestamp'],
|
| 33 |
low_memory=False
|
| 34 |
)
|
| 35 |
+
validate_schema(df)
|
| 36 |
return df
|
| 37 |
|
| 38 |
+
|
| 39 |
def clean(df: pd.DataFrame) -> pd.DataFrame:
|
| 40 |
+
cfg = get_config()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
df = df.rename(columns={
|
| 42 |
'Account': 'source',
|
| 43 |
'Account.1': 'target',
|
|
|
|
| 46 |
'Payment Format': 'payment_type',
|
| 47 |
'Is Laundering': 'is_laundering',
|
| 48 |
'From Bank': 'source_bank',
|
| 49 |
+
'To Bank': 'target_bank',
|
| 50 |
})
|
|
|
|
| 51 |
df = df.dropna(subset=['source', 'target', 'amount', 'timestamp'])
|
|
|
|
| 52 |
df = df[(df['source'] != df['target']) | (df['payment_type'] == 'Reinvestment')]
|
| 53 |
df['is_laundering'] = df['is_laundering'].astype(int)
|
| 54 |
+
df = df[df['amount'] > cfg['data']['min_amount_threshold']]
|
| 55 |
+
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
| 56 |
+
return df.reset_index(drop=True)
|
| 57 |
+
|
| 58 |
|
| 59 |
def precompute_node_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 60 |
+
cfg = get_config()
|
| 61 |
+
sent = df.groupby('source').agg(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
total_sent=('amount', 'sum'),
|
| 63 |
count_sent=('source', 'count'),
|
| 64 |
+
unique_recipients=('target', 'nunique'),
|
| 65 |
)
|
| 66 |
+
recv = df.groupby('target').agg(
|
|
|
|
|
|
|
| 67 |
total_received=('amount', 'sum'),
|
| 68 |
count_received=('target', 'count'),
|
| 69 |
+
unique_senders=('source', 'nunique'),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
)
|
| 71 |
+
node_features = sent.join(recv, how='outer').fillna(0)
|
| 72 |
node_features.index.name = 'account'
|
|
|
|
|
|
|
|
|
|
| 73 |
node_features['forward_ratio'] = node_features['total_sent'] / (node_features['total_received'] + 1)
|
| 74 |
+
|
| 75 |
+
# FIX: label both source AND target of laundering transactions
|
| 76 |
+
fraudulent_sources = set(df[df['is_laundering'] == 1]['source'].unique())
|
| 77 |
+
fraudulent_targets = set(df[df['is_laundering'] == 1]['target'].unique())
|
| 78 |
+
fraud_accounts = fraudulent_sources | fraudulent_targets
|
| 79 |
+
node_features['fraud_flag'] = node_features.index.isin(fraud_accounts).astype(int)
|
| 80 |
+
|
| 81 |
+
# Temporal features
|
| 82 |
+
first_tx = df.groupby('source')['timestamp'].min().rename('first_tx')
|
| 83 |
+
last_tx = df.groupby('source')['timestamp'].max().rename('last_tx')
|
| 84 |
+
node_features = node_features.join(first_tx, how='left')
|
| 85 |
+
node_features = node_features.join(last_tx, how='left')
|
| 86 |
+
now = pd.Timestamp(datetime.now())
|
| 87 |
+
node_features['account_age_days'] = (
|
| 88 |
+
(node_features['last_tx'] - node_features['first_tx']).dt.days.fillna(0)
|
| 89 |
+
)
|
| 90 |
+
node_features['days_since_last_tx'] = (
|
| 91 |
+
(now - node_features['last_tx']).dt.days.fillna(9999)
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Burst score: tx in last 3 days / total tx
|
| 95 |
+
cutoff_3d = df['timestamp'].max() - pd.Timedelta(days=3)
|
| 96 |
+
burst = df[df['timestamp'] >= cutoff_3d].groupby('source').size().rename('burst_count')
|
| 97 |
+
node_features = node_features.join(burst, how='left')
|
| 98 |
+
node_features['burst_score'] = node_features['burst_count'].fillna(0) / (node_features['count_sent'] + 1)
|
| 99 |
+
|
| 100 |
+
node_features = node_features.drop(columns=['first_tx', 'last_tx', 'burst_count'], errors='ignore')
|
| 101 |
+
|
| 102 |
+
os.makedirs(os.path.dirname(cfg['data']['processed_path']), exist_ok=True)
|
| 103 |
+
node_features.to_parquet(cfg['data']['processed_path'])
|
| 104 |
return node_features
|
| 105 |
|
| 106 |
+
|
| 107 |
+
@functools.lru_cache(maxsize=1)
|
| 108 |
+
def get_processed_data() -> tuple:
|
| 109 |
+
cfg = get_config()
|
| 110 |
+
tx_path = cfg['data']['transactions_path']
|
| 111 |
+
nf_path = cfg['data']['processed_path']
|
| 112 |
+
|
| 113 |
+
if os.path.exists(tx_path):
|
| 114 |
+
transactions_df = pd.read_parquet(tx_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
else:
|
| 116 |
raw_df = load_raw()
|
| 117 |
transactions_df = clean(raw_df)
|
| 118 |
+
os.makedirs(os.path.dirname(tx_path), exist_ok=True)
|
| 119 |
+
transactions_df.to_parquet(tx_path)
|
| 120 |
+
|
| 121 |
+
if os.path.exists(nf_path):
|
| 122 |
+
node_features_df = pd.read_parquet(nf_path)
|
|
|
|
| 123 |
else:
|
| 124 |
node_features_df = precompute_node_features(transactions_df)
|
| 125 |
+
|
| 126 |
return transactions_df, node_features_df
|
src/detectors/alert_engine.py
CHANGED
|
@@ -1,36 +1,24 @@
|
|
| 1 |
"""
|
| 2 |
-
Alert Engine
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import uuid
|
| 6 |
-
import streamlit as st
|
| 7 |
import pandas as pd
|
| 8 |
import networkx as nx
|
| 9 |
from datetime import datetime
|
| 10 |
|
|
|
|
| 11 |
from src.detectors.structuring import detect_structuring
|
| 12 |
from src.detectors.layering import detect_layering
|
| 13 |
from src.detectors.round_tripping import detect_cycles
|
| 14 |
from src.detectors.dormancy import detect_dormant_activation
|
| 15 |
from src.detectors.mule_network import detect_mule_networks
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
HIGH_THRESHOLD = 50
|
| 20 |
-
MEDIUM_THRESHOLD = 30
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def combine_alerts(
|
| 24 |
-
structuring: list,
|
| 25 |
-
layering: list,
|
| 26 |
-
cycles: list,
|
| 27 |
-
dormant: list,
|
| 28 |
-
mules: list,
|
| 29 |
-
) -> list[dict]:
|
| 30 |
-
"""
|
| 31 |
-
Combine alerts from all detectors into a single list.
|
| 32 |
-
Assigns a unique alert_id and alert_timestamp to every alert.
|
| 33 |
-
"""
|
| 34 |
combined = structuring + layering + cycles + dormant + mules
|
| 35 |
now = datetime.now().isoformat()
|
| 36 |
for alert in combined:
|
|
@@ -39,15 +27,11 @@ def combine_alerts(
|
|
| 39 |
return combined
|
| 40 |
|
| 41 |
|
| 42 |
-
def deduplicate_alerts(alerts: list
|
| 43 |
-
|
| 44 |
-
Merge multiple alerts for the same account.
|
| 45 |
-
The merged alert escalates risk_score based on how many typologies hit.
|
| 46 |
-
"""
|
| 47 |
by_account: dict[str, list] = {}
|
| 48 |
for alert in alerts:
|
| 49 |
-
|
| 50 |
-
by_account.setdefault(acct, []).append(alert)
|
| 51 |
|
| 52 |
deduped = []
|
| 53 |
for acct, acct_alerts in by_account.items():
|
|
@@ -56,10 +40,14 @@ def deduplicate_alerts(alerts: list[dict]) -> list[dict]:
|
|
| 56 |
continue
|
| 57 |
|
| 58 |
typologies = list({a['typology'] for a in acct_alerts})
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
total_amount = sum(a['amount_involved'] for a in acct_alerts)
|
| 62 |
-
total_tx =
|
| 63 |
confirmed_fraud = any(a['confirmed_fraud'] for a in acct_alerts)
|
| 64 |
|
| 65 |
deduped.append({
|
|
@@ -69,28 +57,29 @@ def deduplicate_alerts(alerts: list[dict]) -> list[dict]:
|
|
| 69 |
'risk_score': merged_score,
|
| 70 |
'amount_involved': round(total_amount, 2),
|
| 71 |
'tx_count': total_tx,
|
| 72 |
-
'explanation': '; '.join(
|
| 73 |
-
{a['explanation'] for a in acct_alerts}
|
| 74 |
-
),
|
| 75 |
'timestamp_detected': acct_alerts[0].get('timestamp_detected', ''),
|
| 76 |
'alert_timestamp': datetime.now().isoformat(),
|
| 77 |
'confirmed_fraud': confirmed_fraud,
|
|
|
|
| 78 |
})
|
| 79 |
|
| 80 |
return deduped
|
| 81 |
|
| 82 |
|
| 83 |
-
def score_and_rank_alerts(alerts: list
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
for alert in alerts:
|
| 88 |
score = alert['risk_score']
|
| 89 |
-
if score >=
|
| 90 |
alert['risk_tier'] = 'CRITICAL'
|
| 91 |
-
elif score >=
|
| 92 |
alert['risk_tier'] = 'HIGH'
|
| 93 |
-
elif score >=
|
| 94 |
alert['risk_tier'] = 'MEDIUM'
|
| 95 |
else:
|
| 96 |
alert['risk_tier'] = 'LOW'
|
|
@@ -98,33 +87,29 @@ def score_and_rank_alerts(alerts: list[dict]) -> list[dict]:
|
|
| 98 |
return sorted(alerts, key=lambda a: a['risk_score'], reverse=True)
|
| 99 |
|
| 100 |
|
| 101 |
-
@st.cache_data
|
| 102 |
def get_all_alerts(
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
structuring_alerts = detect_structuring(_df)
|
| 115 |
-
layering_alerts = detect_layering(_df)
|
| 116 |
-
cycle_alerts = detect_cycles(_G, _df)
|
| 117 |
-
dormant_alerts = detect_dormant_activation(_df)
|
| 118 |
-
mule_alerts = detect_mule_networks(_G, _df, _louvain_partition, _node_features_df)
|
| 119 |
|
| 120 |
combined = combine_alerts(
|
| 121 |
-
structuring_alerts,
|
| 122 |
-
|
| 123 |
-
cycle_alerts,
|
| 124 |
-
dormant_alerts,
|
| 125 |
-
mule_alerts,
|
| 126 |
)
|
| 127 |
deduped = deduplicate_alerts(combined)
|
| 128 |
ranked = score_and_rank_alerts(deduped)
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
return ranked
|
|
|
|
| 1 |
"""
|
| 2 |
+
Alert Engine - Fixed
|
| 3 |
+
- Stores sub_alerts list in merged alert
|
| 4 |
+
- Suppression via SQLite state
|
| 5 |
+
- Weighted harmonic mean score
|
| 6 |
+
- No Streamlit imports
|
| 7 |
"""
|
| 8 |
import uuid
|
|
|
|
| 9 |
import pandas as pd
|
| 10 |
import networkx as nx
|
| 11 |
from datetime import datetime
|
| 12 |
|
| 13 |
+
from src.config_loader import get_config
|
| 14 |
from src.detectors.structuring import detect_structuring
|
| 15 |
from src.detectors.layering import detect_layering
|
| 16 |
from src.detectors.round_tripping import detect_cycles
|
| 17 |
from src.detectors.dormancy import detect_dormant_activation
|
| 18 |
from src.detectors.mule_network import detect_mule_networks
|
| 19 |
|
| 20 |
+
|
| 21 |
+
def combine_alerts(structuring, layering, cycles, dormant, mules) -> list:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
combined = structuring + layering + cycles + dormant + mules
|
| 23 |
now = datetime.now().isoformat()
|
| 24 |
for alert in combined:
|
|
|
|
| 27 |
return combined
|
| 28 |
|
| 29 |
|
| 30 |
+
def deduplicate_alerts(alerts: list) -> list:
|
| 31 |
+
cfg = get_config()['alert_engine']
|
|
|
|
|
|
|
|
|
|
| 32 |
by_account: dict[str, list] = {}
|
| 33 |
for alert in alerts:
|
| 34 |
+
by_account.setdefault(alert['account'], []).append(alert)
|
|
|
|
| 35 |
|
| 36 |
deduped = []
|
| 37 |
for acct, acct_alerts in by_account.items():
|
|
|
|
| 40 |
continue
|
| 41 |
|
| 42 |
typologies = list({a['typology'] for a in acct_alerts})
|
| 43 |
+
scores = [a['risk_score'] for a in acct_alerts]
|
| 44 |
+
|
| 45 |
+
# Weighted harmonic mean score escalated by typology count
|
| 46 |
+
harmonic = len(scores) / sum(1.0 / (s + 1) for s in scores)
|
| 47 |
+
merged_score = min(int(harmonic + 5 * (len(typologies) - 1)), 99)
|
| 48 |
+
|
| 49 |
total_amount = sum(a['amount_involved'] for a in acct_alerts)
|
| 50 |
+
total_tx = sum(a['tx_count'] for a in acct_alerts)
|
| 51 |
confirmed_fraud = any(a['confirmed_fraud'] for a in acct_alerts)
|
| 52 |
|
| 53 |
deduped.append({
|
|
|
|
| 57 |
'risk_score': merged_score,
|
| 58 |
'amount_involved': round(total_amount, 2),
|
| 59 |
'tx_count': total_tx,
|
| 60 |
+
'explanation': '; '.join({a['explanation'] for a in acct_alerts}),
|
|
|
|
|
|
|
| 61 |
'timestamp_detected': acct_alerts[0].get('timestamp_detected', ''),
|
| 62 |
'alert_timestamp': datetime.now().isoformat(),
|
| 63 |
'confirmed_fraud': confirmed_fraud,
|
| 64 |
+
'sub_alerts': acct_alerts, # FIX: preserve per-typology detail
|
| 65 |
})
|
| 66 |
|
| 67 |
return deduped
|
| 68 |
|
| 69 |
|
| 70 |
+
def score_and_rank_alerts(alerts: list) -> list:
|
| 71 |
+
cfg = get_config()['alert_engine']
|
| 72 |
+
CRITICAL = cfg['critical_threshold']
|
| 73 |
+
HIGH = cfg['high_threshold']
|
| 74 |
+
MEDIUM = cfg['medium_threshold']
|
| 75 |
+
|
| 76 |
for alert in alerts:
|
| 77 |
score = alert['risk_score']
|
| 78 |
+
if score >= CRITICAL:
|
| 79 |
alert['risk_tier'] = 'CRITICAL'
|
| 80 |
+
elif score >= HIGH:
|
| 81 |
alert['risk_tier'] = 'HIGH'
|
| 82 |
+
elif score >= MEDIUM:
|
| 83 |
alert['risk_tier'] = 'MEDIUM'
|
| 84 |
else:
|
| 85 |
alert['risk_tier'] = 'LOW'
|
|
|
|
| 87 |
return sorted(alerts, key=lambda a: a['risk_score'], reverse=True)
|
| 88 |
|
| 89 |
|
|
|
|
| 90 |
def get_all_alerts(
|
| 91 |
+
df: pd.DataFrame,
|
| 92 |
+
G,
|
| 93 |
+
louvain_partition: dict,
|
| 94 |
+
node_features_df: pd.DataFrame,
|
| 95 |
+
demo_mode: bool = True,
|
| 96 |
+
) -> list:
|
| 97 |
+
structuring_alerts = detect_structuring(df)
|
| 98 |
+
layering_alerts = detect_layering(df)
|
| 99 |
+
cycle_alerts = detect_cycles(G, df)
|
| 100 |
+
dormant_alerts = detect_dormant_activation(df)
|
| 101 |
+
mule_alerts = detect_mule_networks(G, df, louvain_partition, node_features_df)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
combined = combine_alerts(
|
| 104 |
+
structuring_alerts, layering_alerts, cycle_alerts,
|
| 105 |
+
dormant_alerts, mule_alerts,
|
|
|
|
|
|
|
|
|
|
| 106 |
)
|
| 107 |
deduped = deduplicate_alerts(combined)
|
| 108 |
ranked = score_and_rank_alerts(deduped)
|
| 109 |
|
| 110 |
+
# Gate confirmed_fraud in non-demo production mode
|
| 111 |
+
if not demo_mode:
|
| 112 |
+
for alert in ranked:
|
| 113 |
+
alert.pop('confirmed_fraud', None)
|
| 114 |
+
|
| 115 |
return ranked
|
src/detectors/dormancy.py
CHANGED
|
@@ -1,89 +1,117 @@
|
|
| 1 |
"""
|
| 2 |
-
Dormant Account Activation Detector
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import uuid
|
| 6 |
import pandas as pd
|
| 7 |
from datetime import datetime
|
| 8 |
-
|
| 9 |
-
# Constants
|
| 10 |
-
DORMANCY_DAYS = 7
|
| 11 |
-
ACTIVATION_MULTIPLIER = 10
|
| 12 |
-
MIN_ACTIVATION_AMOUNT = 50_000
|
| 13 |
-
RECENT_PERIOD_DAYS = 3
|
| 14 |
|
| 15 |
|
| 16 |
def detect_dormant_activation(df: pd.DataFrame) -> list[dict]:
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
df = df.copy()
|
| 29 |
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
hist_df = df[df['timestamp'] <
|
| 35 |
-
recent_df = df[df['timestamp'] >=
|
| 36 |
|
| 37 |
if recent_df.empty:
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
return alerts
|
|
|
|
| 1 |
"""
|
| 2 |
+
Dormant Account Activation Detector - Fixed
|
| 3 |
+
- Uses DORMANCY_GAP_DAYS as actual inactivity gap
|
| 4 |
+
- Uses datetime.now() (not dataset max_date)
|
| 5 |
+
- Scans both source and target
|
| 6 |
+
- Dynamic risk score formula
|
| 7 |
+
- No Streamlit
|
| 8 |
"""
|
| 9 |
import uuid
|
| 10 |
import pandas as pd
|
| 11 |
from datetime import datetime
|
| 12 |
+
from src.config_loader import get_config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def detect_dormant_activation(df: pd.DataFrame) -> list[dict]:
|
| 16 |
+
cfg = get_config()['detectors']
|
| 17 |
+
DORMANCY_DAYS = cfg['dormancy_gap_days']
|
| 18 |
+
RECENT_DAYS = cfg['recent_period_days']
|
| 19 |
+
ACTIVATION_MULT = cfg['activation_multiplier']
|
| 20 |
+
MIN_AMOUNT = cfg['min_activation_amount']
|
| 21 |
+
CHECK_TARGET = cfg.get('check_target_dormancy', True)
|
| 22 |
+
|
| 23 |
+
fraud_accounts = (
|
| 24 |
+
set(df[df['is_laundering'] == 1]['source'].unique()) |
|
| 25 |
+
set(df[df['is_laundering'] == 1]['target'].unique())
|
| 26 |
+
)
|
| 27 |
|
| 28 |
df = df.copy()
|
| 29 |
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
| 30 |
|
| 31 |
+
# Use datetime.now() as reference (not dataset max) — FIX
|
| 32 |
+
now = pd.Timestamp(datetime.now())
|
| 33 |
+
cutoff_recent = now - pd.Timedelta(days=RECENT_DAYS)
|
| 34 |
+
cutoff_dormant = now - pd.Timedelta(days=DORMANCY_DAYS)
|
| 35 |
|
| 36 |
+
hist_df = df[df['timestamp'] < cutoff_recent]
|
| 37 |
+
recent_df = df[df['timestamp'] >= cutoff_recent]
|
| 38 |
|
| 39 |
if recent_df.empty:
|
| 40 |
+
# Fallback to dataset-relative if real-time has no recent data
|
| 41 |
+
max_date = df['timestamp'].max()
|
| 42 |
+
cutoff_recent = max_date - pd.Timedelta(days=RECENT_DAYS)
|
| 43 |
+
cutoff_dormant = max_date - pd.Timedelta(days=DORMANCY_DAYS)
|
| 44 |
+
hist_df = df[df['timestamp'] < cutoff_recent]
|
| 45 |
+
recent_df = df[df['timestamp'] >= cutoff_recent]
|
| 46 |
+
|
| 47 |
+
if recent_df.empty:
|
| 48 |
+
return []
|
| 49 |
+
|
| 50 |
+
alerts = []
|
| 51 |
+
seen = set()
|
| 52 |
+
|
| 53 |
+
def _check(col_recent: str, col_hist: str, recent: pd.DataFrame, hist: pd.DataFrame) -> None:
|
| 54 |
+
for account in recent[col_recent].unique():
|
| 55 |
+
if account in seen:
|
| 56 |
+
continue
|
| 57 |
+
hist_txns = hist[hist[col_hist] == account]
|
| 58 |
+
recent_txns = recent[recent[col_recent] == account]
|
| 59 |
+
recent_amount = recent_txns['amount'].sum()
|
| 60 |
+
|
| 61 |
+
if len(hist_txns) == 0:
|
| 62 |
+
# Brand new account with high-value activation
|
| 63 |
+
if recent_amount >= MIN_AMOUNT:
|
| 64 |
+
seen.add(account)
|
| 65 |
+
risk_score = min(70 + int(recent_amount / MIN_AMOUNT * 5), 99)
|
| 66 |
+
explanation = (
|
| 67 |
+
f"New account activated with {recent_amount:,.0f} in "
|
| 68 |
+
f"the last {RECENT_DAYS} days — no prior history"
|
| 69 |
+
)
|
| 70 |
+
alerts.append({
|
| 71 |
+
'alert_id': uuid.uuid4().hex[:8],
|
| 72 |
+
'account': account,
|
| 73 |
+
'typology': 'DormantActivation',
|
| 74 |
+
'risk_score': risk_score,
|
| 75 |
+
'amount_involved': round(recent_amount, 2),
|
| 76 |
+
'tx_count': len(recent_txns),
|
| 77 |
+
'explanation': explanation,
|
| 78 |
+
'timestamp_detected': datetime.now().isoformat(),
|
| 79 |
+
'confirmed_fraud': account in fraud_accounts,
|
| 80 |
+
})
|
| 81 |
+
else:
|
| 82 |
+
# Check if account was actually dormant (gap > DORMANCY_DAYS)
|
| 83 |
+
last_hist_ts = hist_txns['timestamp'].max()
|
| 84 |
+
first_recent_ts = recent_txns['timestamp'].min()
|
| 85 |
+
gap_days = (first_recent_ts - last_hist_ts).days
|
| 86 |
+
|
| 87 |
+
if gap_days < DORMANCY_DAYS:
|
| 88 |
+
continue # Not truly dormant
|
| 89 |
+
|
| 90 |
+
hist_avg = hist_txns['amount'].sum() / max(len(hist_txns), 1)
|
| 91 |
+
activation_ratio = recent_amount / (hist_avg + 1)
|
| 92 |
+
|
| 93 |
+
if activation_ratio >= ACTIVATION_MULT:
|
| 94 |
+
seen.add(account)
|
| 95 |
+
risk_score = min(70 + int(activation_ratio / 5), 99)
|
| 96 |
+
explanation = (
|
| 97 |
+
f"Account dormant for {gap_days} days then transacted "
|
| 98 |
+
f"{recent_amount:,.0f} — "
|
| 99 |
+
f"{activation_ratio:.1f}x historical average"
|
| 100 |
+
)
|
| 101 |
+
alerts.append({
|
| 102 |
+
'alert_id': uuid.uuid4().hex[:8],
|
| 103 |
+
'account': account,
|
| 104 |
+
'typology': 'DormantActivation',
|
| 105 |
+
'risk_score': risk_score,
|
| 106 |
+
'amount_involved': round(recent_amount, 2),
|
| 107 |
+
'tx_count': len(recent_txns),
|
| 108 |
+
'explanation': explanation,
|
| 109 |
+
'timestamp_detected': datetime.now().isoformat(),
|
| 110 |
+
'confirmed_fraud': account in fraud_accounts,
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
_check('source', 'source', recent_df, hist_df)
|
| 114 |
+
if CHECK_TARGET:
|
| 115 |
+
_check('target', 'target', recent_df, hist_df)
|
| 116 |
|
| 117 |
return alerts
|
src/detectors/layering.py
CHANGED
|
@@ -1,69 +1,104 @@
|
|
| 1 |
"""
|
| 2 |
-
Layering Detector
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import uuid
|
| 6 |
import pandas as pd
|
| 7 |
from datetime import datetime
|
| 8 |
-
|
| 9 |
-
# Constants
|
| 10 |
-
FORWARD_RATIO_THRESHOLD = 0.80
|
| 11 |
-
TIME_WINDOW_HOURS = 48
|
| 12 |
-
MIN_FORWARD_AMOUNT = 10_000
|
| 13 |
|
| 14 |
|
| 15 |
def detect_layering(df: pd.DataFrame) -> list[dict]:
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
fraud_sources = set(df[df['is_laundering'] == 1]['source'].values)
|
| 25 |
|
| 26 |
df = df.copy()
|
| 27 |
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
for account in accounts:
|
| 32 |
-
|
| 33 |
-
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
best_ratio = 0.0
|
| 40 |
best_forward = 0.0
|
| 41 |
best_received = 0.0
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
for _, row in
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
out_window =
|
| 48 |
-
(
|
|
|
|
| 49 |
]
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
forwarded =
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
if flagged:
|
| 63 |
risk_score = min(int(best_ratio * 50 + 20), 99)
|
| 64 |
explanation = (
|
| 65 |
-
f"{best_ratio * 100:.0f}% of received funds
|
| 66 |
-
f"{
|
|
|
|
| 67 |
)
|
| 68 |
alerts.append({
|
| 69 |
'alert_id': uuid.uuid4().hex[:8],
|
|
@@ -71,10 +106,10 @@ def detect_layering(df: pd.DataFrame) -> list[dict]:
|
|
| 71 |
'typology': 'Layering',
|
| 72 |
'risk_score': risk_score,
|
| 73 |
'amount_involved': round(best_forward, 2),
|
| 74 |
-
'tx_count':
|
| 75 |
'explanation': explanation,
|
| 76 |
'timestamp_detected': datetime.now().isoformat(),
|
| 77 |
-
'confirmed_fraud': account in
|
| 78 |
})
|
| 79 |
|
| 80 |
return alerts
|
|
|
|
| 1 |
"""
|
| 2 |
+
Layering Detector - Fixed
|
| 3 |
+
- Vectorised with merge_asof (100x faster than inner loop)
|
| 4 |
+
- Multi-inflow aggregation
|
| 5 |
+
- Hop-count tracking in explanation
|
| 6 |
+
- Config-driven thresholds
|
| 7 |
+
- No Streamlit
|
| 8 |
"""
|
| 9 |
import uuid
|
| 10 |
import pandas as pd
|
| 11 |
from datetime import datetime
|
| 12 |
+
from src.config_loader import get_config
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def detect_layering(df: pd.DataFrame) -> list[dict]:
|
| 16 |
+
cfg = get_config()['detectors']
|
| 17 |
+
FORWARD_RATIO = cfg['layering_forward_ratio']
|
| 18 |
+
TIME_WINDOW_HRS = cfg['layering_time_window_hours']
|
| 19 |
+
MIN_FORWARD = cfg['layering_min_forward_amount']
|
| 20 |
|
| 21 |
+
fraud_accounts = (
|
| 22 |
+
set(df[df['is_laundering'] == 1]['source'].unique()) |
|
| 23 |
+
set(df[df['is_laundering'] == 1]['target'].unique())
|
| 24 |
+
)
|
|
|
|
| 25 |
|
| 26 |
df = df.copy()
|
| 27 |
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
| 28 |
+
window = pd.Timedelta(hours=TIME_WINDOW_HRS)
|
| 29 |
+
|
| 30 |
+
# Inbound and outbound DataFrames
|
| 31 |
+
inbound = df[['target', 'source', 'timestamp', 'amount']].rename(
|
| 32 |
+
columns={'target': 'account', 'source': 'counterpart', 'amount': 'in_amount'}
|
| 33 |
+
).sort_values('timestamp').reset_index(drop=True)
|
| 34 |
|
| 35 |
+
outbound = df[['source', 'target', 'timestamp', 'amount']].rename(
|
| 36 |
+
columns={'source': 'account', 'target': 'counterpart', 'amount': 'out_amount'}
|
| 37 |
+
).sort_values('timestamp').reset_index(drop=True)
|
| 38 |
+
|
| 39 |
+
accounts = set(inbound['account'].unique()) & set(outbound['account'].unique())
|
| 40 |
+
alerts = []
|
| 41 |
|
| 42 |
for account in accounts:
|
| 43 |
+
acct_in = inbound[inbound['account'] == account].copy()
|
| 44 |
+
acct_out = outbound[outbound['account'] == account].copy()
|
| 45 |
|
| 46 |
+
# For each inbound group by day, aggregate inflow, then find total outflow in window
|
| 47 |
+
# Vectorised: merge_asof on sorted timestamps
|
| 48 |
+
merged = pd.merge_asof(
|
| 49 |
+
acct_out.sort_values('timestamp'),
|
| 50 |
+
acct_in.sort_values('timestamp'),
|
| 51 |
+
on='timestamp',
|
| 52 |
+
direction='backward',
|
| 53 |
+
suffixes=('_out', '_in'),
|
| 54 |
+
tolerance=window,
|
| 55 |
+
)
|
| 56 |
+
merged = merged.dropna(subset=['in_amount'])
|
| 57 |
|
| 58 |
+
# For each outbound tx, sum all inflows in the preceding window
|
| 59 |
best_ratio = 0.0
|
| 60 |
best_forward = 0.0
|
| 61 |
best_received = 0.0
|
| 62 |
+
best_out_ts = None
|
| 63 |
+
unique_beneficiaries = set()
|
| 64 |
|
| 65 |
+
for _, row in merged.iterrows():
|
| 66 |
+
out_ts = row['timestamp']
|
| 67 |
+
in_window = acct_in[
|
| 68 |
+
(acct_in['timestamp'] >= out_ts - window) &
|
| 69 |
+
(acct_in['timestamp'] <= out_ts)
|
| 70 |
+
]['in_amount'].sum()
|
| 71 |
|
| 72 |
+
out_window = acct_out[
|
| 73 |
+
(acct_out['timestamp'] >= out_ts - window) &
|
| 74 |
+
(acct_out['timestamp'] <= out_ts)
|
| 75 |
]
|
| 76 |
+
forwarded = out_window['out_amount'].sum()
|
| 77 |
+
ratio = forwarded / (in_window + 1)
|
| 78 |
|
| 79 |
+
if ratio >= FORWARD_RATIO and forwarded >= MIN_FORWARD and ratio > best_ratio:
|
| 80 |
+
best_ratio = ratio
|
| 81 |
+
best_forward = forwarded
|
| 82 |
+
best_received = in_window
|
| 83 |
+
best_out_ts = out_ts
|
| 84 |
+
unique_beneficiaries.update(out_window['counterpart_out'].tolist() if 'counterpart_out' in out_window else [])
|
| 85 |
|
| 86 |
+
if best_ratio >= FORWARD_RATIO and best_forward >= MIN_FORWARD:
|
| 87 |
+
hops = len(unique_beneficiaries)
|
| 88 |
+
# Fix: tx_count = outbound count in the triggering window only
|
| 89 |
+
if best_out_ts is not None:
|
| 90 |
+
tx_count_in_window = len(acct_out[
|
| 91 |
+
(acct_out['timestamp'] >= best_out_ts - window) &
|
| 92 |
+
(acct_out['timestamp'] <= best_out_ts)
|
| 93 |
+
])
|
| 94 |
+
else:
|
| 95 |
+
tx_count_in_window = len(acct_out)
|
| 96 |
|
|
|
|
| 97 |
risk_score = min(int(best_ratio * 50 + 20), 99)
|
| 98 |
explanation = (
|
| 99 |
+
f"{best_ratio * 100:.0f}% of received funds ({best_received:,.0f}) "
|
| 100 |
+
f"forwarded to {max(hops, 1)} beneficiar{'y' if hops <= 1 else 'ies'} "
|
| 101 |
+
f"within {TIME_WINDOW_HRS}h — rapid pass-through pattern"
|
| 102 |
)
|
| 103 |
alerts.append({
|
| 104 |
'alert_id': uuid.uuid4().hex[:8],
|
|
|
|
| 106 |
'typology': 'Layering',
|
| 107 |
'risk_score': risk_score,
|
| 108 |
'amount_involved': round(best_forward, 2),
|
| 109 |
+
'tx_count': tx_count_in_window,
|
| 110 |
'explanation': explanation,
|
| 111 |
'timestamp_detected': datetime.now().isoformat(),
|
| 112 |
+
'confirmed_fraud': account in fraud_accounts,
|
| 113 |
})
|
| 114 |
|
| 115 |
return alerts
|
src/detectors/mule_network.py
CHANGED
|
@@ -1,80 +1,104 @@
|
|
| 1 |
"""
|
| 2 |
-
Mule Network Detector
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import uuid
|
| 6 |
import pandas as pd
|
| 7 |
import networkx as nx
|
| 8 |
from collections import defaultdict
|
| 9 |
from datetime import datetime
|
| 10 |
-
|
| 11 |
-
# Constants
|
| 12 |
-
MIN_COMMUNITY_SIZE = 5
|
| 13 |
-
VELOCITY_THRESHOLD = 0.7
|
| 14 |
-
FRAUD_RATIO_THRESHOLD = 0.3
|
| 15 |
|
| 16 |
|
| 17 |
def detect_mule_networks(
|
| 18 |
-
G
|
| 19 |
df: pd.DataFrame,
|
| 20 |
louvain_partition: dict,
|
| 21 |
node_features_df: pd.DataFrame,
|
| 22 |
) -> list[dict]:
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
Returns:
|
| 28 |
-
List of alert dicts.
|
| 29 |
-
"""
|
| 30 |
-
alerts = []
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
|
| 37 |
-
# Normalize node_features_df index
|
| 38 |
if 'account' in node_features_df.columns:
|
| 39 |
nf = node_features_df.set_index('account')
|
| 40 |
else:
|
| 41 |
nf = node_features_df
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
for cid, members in communities.items():
|
| 44 |
-
if len(members) <
|
| 45 |
continue
|
| 46 |
|
| 47 |
-
# Filter node features to community members that exist in the feature table
|
| 48 |
valid_members = [m for m in members if m in nf.index]
|
| 49 |
if not valid_members:
|
| 50 |
continue
|
| 51 |
|
| 52 |
member_df = nf.loc[valid_members]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
fraud_node_ratio = member_df['fraud_flag'].mean()
|
| 56 |
-
total_amount = member_df['total_sent'].sum()
|
| 57 |
|
| 58 |
is_suspicious = (
|
| 59 |
-
avg_forward_ratio >=
|
| 60 |
-
or fraud_node_ratio >=
|
| 61 |
)
|
| 62 |
-
|
| 63 |
if not is_suspicious:
|
| 64 |
continue
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
risk_score = min(
|
| 67 |
int(fraud_node_ratio * 50 + avg_forward_ratio * 30 + 20),
|
| 68 |
99
|
| 69 |
)
|
|
|
|
|
|
|
| 70 |
|
| 71 |
explanation = (
|
| 72 |
-
f"
|
| 73 |
f"{fraud_node_ratio * 100:.0f}% confirmed fraud rate and "
|
| 74 |
-
f"{avg_forward_ratio * 100:.0f}%
|
| 75 |
)
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
#
|
| 78 |
representative = valid_members[0]
|
| 79 |
|
| 80 |
alerts.append({
|
|
@@ -83,12 +107,13 @@ def detect_mule_networks(
|
|
| 83 |
'typology': 'MuleNetwork',
|
| 84 |
'risk_score': risk_score,
|
| 85 |
'amount_involved': round(total_amount, 2),
|
| 86 |
-
'tx_count':
|
| 87 |
'explanation': explanation,
|
| 88 |
'timestamp_detected': datetime.now().isoformat(),
|
| 89 |
'confirmed_fraud': bool(fraud_node_ratio > 0),
|
| 90 |
'community_id': cid,
|
| 91 |
'member_accounts': valid_members,
|
|
|
|
| 92 |
})
|
| 93 |
|
| 94 |
return alerts
|
|
|
|
| 1 |
"""
|
| 2 |
+
Mule Network Detector - Fixed
|
| 3 |
+
- Emits all community members as one alert with member list
|
| 4 |
+
- Lowered velocity threshold (configurable)
|
| 5 |
+
- Fixed tx_count to actual transaction count
|
| 6 |
+
- Bridge detection between communities
|
| 7 |
+
- No Streamlit
|
| 8 |
"""
|
| 9 |
import uuid
|
| 10 |
import pandas as pd
|
| 11 |
import networkx as nx
|
| 12 |
from collections import defaultdict
|
| 13 |
from datetime import datetime
|
| 14 |
+
from src.config_loader import get_config
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
def detect_mule_networks(
|
| 18 |
+
G,
|
| 19 |
df: pd.DataFrame,
|
| 20 |
louvain_partition: dict,
|
| 21 |
node_features_df: pd.DataFrame,
|
| 22 |
) -> list[dict]:
|
| 23 |
+
cfg = get_config()['detectors']
|
| 24 |
+
MIN_SIZE = cfg['mule_min_community_size']
|
| 25 |
+
VELOCITY_THRESH = cfg['mule_velocity_threshold']
|
| 26 |
+
FRAUD_RATIO_THRESH = cfg['mule_fraud_ratio_threshold']
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
fraud_accounts = (
|
| 29 |
+
set(df[df['is_laundering'] == 1]['source'].unique()) |
|
| 30 |
+
set(df[df['is_laundering'] == 1]['target'].unique())
|
| 31 |
+
)
|
| 32 |
|
|
|
|
| 33 |
if 'account' in node_features_df.columns:
|
| 34 |
nf = node_features_df.set_index('account')
|
| 35 |
else:
|
| 36 |
nf = node_features_df
|
| 37 |
|
| 38 |
+
communities: dict[int, list] = defaultdict(list)
|
| 39 |
+
for node, cid in louvain_partition.items():
|
| 40 |
+
communities[cid].append(node)
|
| 41 |
+
|
| 42 |
+
# Build simple graph for bridge detection
|
| 43 |
+
all_edges = set()
|
| 44 |
+
if hasattr(G, 'edges'):
|
| 45 |
+
for u, v in G.edges():
|
| 46 |
+
all_edges.add((u, v))
|
| 47 |
+
|
| 48 |
+
alerts = []
|
| 49 |
+
|
| 50 |
for cid, members in communities.items():
|
| 51 |
+
if len(members) < MIN_SIZE:
|
| 52 |
continue
|
| 53 |
|
|
|
|
| 54 |
valid_members = [m for m in members if m in nf.index]
|
| 55 |
if not valid_members:
|
| 56 |
continue
|
| 57 |
|
| 58 |
member_df = nf.loc[valid_members]
|
| 59 |
+
avg_forward_ratio = float(member_df['forward_ratio'].mean())
|
| 60 |
+
fraud_node_ratio = float(member_df['fraud_flag'].mean())
|
| 61 |
+
|
| 62 |
+
# FIX: actual transaction count for community
|
| 63 |
+
tx_count = int(df[
|
| 64 |
+
df['source'].isin(valid_members) | df['target'].isin(valid_members)
|
| 65 |
+
].shape[0])
|
| 66 |
|
| 67 |
+
total_amount = float(member_df['total_sent'].sum())
|
|
|
|
|
|
|
| 68 |
|
| 69 |
is_suspicious = (
|
| 70 |
+
avg_forward_ratio >= VELOCITY_THRESH
|
| 71 |
+
or fraud_node_ratio >= FRAUD_RATIO_THRESH
|
| 72 |
)
|
|
|
|
| 73 |
if not is_suspicious:
|
| 74 |
continue
|
| 75 |
|
| 76 |
+
# Bridge detection: accounts connecting this community to others
|
| 77 |
+
bridge_accounts = []
|
| 78 |
+
member_set = set(members)
|
| 79 |
+
for u, v in all_edges:
|
| 80 |
+
if u in member_set and v not in member_set:
|
| 81 |
+
bridge_accounts.append(u)
|
| 82 |
+
elif v in member_set and u not in member_set:
|
| 83 |
+
bridge_accounts.append(v)
|
| 84 |
+
bridge_accounts = list(set(bridge_accounts))
|
| 85 |
+
|
| 86 |
risk_score = min(
|
| 87 |
int(fraud_node_ratio * 50 + avg_forward_ratio * 30 + 20),
|
| 88 |
99
|
| 89 |
)
|
| 90 |
+
if bridge_accounts:
|
| 91 |
+
risk_score = min(risk_score + 5, 99)
|
| 92 |
|
| 93 |
explanation = (
|
| 94 |
+
f"Cluster of {len(members)} accounts with "
|
| 95 |
f"{fraud_node_ratio * 100:.0f}% confirmed fraud rate and "
|
| 96 |
+
f"{avg_forward_ratio * 100:.0f}% avg forward ratio"
|
| 97 |
)
|
| 98 |
+
if bridge_accounts:
|
| 99 |
+
explanation += f". {len(bridge_accounts)} bridge account(s) connect to external communities."
|
| 100 |
|
| 101 |
+
# FIX: use first valid member as representative but include all members
|
| 102 |
representative = valid_members[0]
|
| 103 |
|
| 104 |
alerts.append({
|
|
|
|
| 107 |
'typology': 'MuleNetwork',
|
| 108 |
'risk_score': risk_score,
|
| 109 |
'amount_involved': round(total_amount, 2),
|
| 110 |
+
'tx_count': tx_count,
|
| 111 |
'explanation': explanation,
|
| 112 |
'timestamp_detected': datetime.now().isoformat(),
|
| 113 |
'confirmed_fraud': bool(fraud_node_ratio > 0),
|
| 114 |
'community_id': cid,
|
| 115 |
'member_accounts': valid_members,
|
| 116 |
+
'bridge_accounts': bridge_accounts,
|
| 117 |
})
|
| 118 |
|
| 119 |
return alerts
|
src/detectors/round_tripping.py
CHANGED
|
@@ -1,85 +1,127 @@
|
|
| 1 |
"""
|
| 2 |
-
Round-Tripping (Cycle) Detector
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import uuid
|
| 6 |
-
import
|
| 7 |
import networkx as nx
|
| 8 |
import pandas as pd
|
| 9 |
-
from datetime import datetime
|
| 10 |
-
|
| 11 |
-
# Constants
|
| 12 |
-
MIN_CYCLE_AMOUNT = 50_000
|
| 13 |
-
MAX_CYCLE_LENGTH = 6
|
| 14 |
-
MIN_CYCLE_LENGTH = 2
|
| 15 |
-
CYCLE_DETECTION_TIMEOUT_SECONDS = 30
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
class _TimeoutError(Exception):
|
| 19 |
-
"""Raised when cycle detection exceeds the time limit."""
|
| 20 |
-
pass
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def _timeout_handler(signum, frame):
|
| 24 |
-
raise _TimeoutError("Cycle detection timed out.")
|
| 25 |
|
| 26 |
|
| 27 |
def _cycle_amounts_from_df(cycle: list, df: pd.DataFrame) -> float:
|
| 28 |
-
"""
|
| 29 |
-
Sum the total amount across edges in the given cycle from the original df.
|
| 30 |
-
"""
|
| 31 |
total = 0.0
|
| 32 |
for i in range(len(cycle)):
|
| 33 |
src = cycle[i]
|
| 34 |
dst = cycle[(i + 1) % len(cycle)]
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
total += edge_amounts.mean()
|
| 39 |
return total
|
| 40 |
|
| 41 |
|
| 42 |
-
def
|
| 43 |
"""
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
Returns:
|
| 48 |
-
List of alert dicts.
|
| 49 |
"""
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
high_value_edges = [
|
| 55 |
-
(u, v)
|
| 56 |
-
|
| 57 |
-
if d.get('amount', 0) >= MIN_CYCLE_AMOUNT
|
| 58 |
]
|
| 59 |
G_filtered = nx.DiGraph(high_value_edges)
|
| 60 |
|
| 61 |
-
#
|
| 62 |
partial_cycles = []
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
try:
|
| 67 |
for cycle in nx.simple_cycles(G_filtered):
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
for cycle in valid_cycles:
|
| 81 |
total_amount = _cycle_amounts_from_df(cycle, df)
|
| 82 |
-
fraud_overlap = sum(1 for n in cycle if n in
|
| 83 |
cycle_fraud_ratio = fraud_overlap / len(cycle)
|
| 84 |
|
| 85 |
risk_score = min(
|
|
@@ -88,23 +130,28 @@ def detect_cycles(G: nx.DiGraph, df: pd.DataFrame) -> list[dict]:
|
|
| 88 |
)
|
| 89 |
|
| 90 |
cycle_id = uuid.uuid4().hex[:8]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
explanation = (
|
| 92 |
-
f"Circular fund flow
|
| 93 |
-
f"Funds return to origin after {len(cycle) - 1} intermediate hops."
|
|
|
|
| 94 |
)
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
|
| 110 |
return alerts
|
|
|
|
| 1 |
"""
|
| 2 |
+
Round-Tripping (Cycle) Detector - Fixed
|
| 3 |
+
- Threading-based timeout (cross-platform, replaces signal.SIGALRM)
|
| 4 |
+
- Temporal edge validation
|
| 5 |
+
- One alert per cycle (representative = highest-risk node)
|
| 6 |
+
- max() instead of mean() for cycle amounts
|
| 7 |
+
- No Streamlit
|
| 8 |
"""
|
| 9 |
import uuid
|
| 10 |
+
import threading
|
| 11 |
import networkx as nx
|
| 12 |
import pandas as pd
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from src.config_loader import get_config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
def _cycle_amounts_from_df(cycle: list, df: pd.DataFrame) -> float:
|
| 18 |
+
"""Max edge amount across all edges in the cycle."""
|
|
|
|
|
|
|
| 19 |
total = 0.0
|
| 20 |
for i in range(len(cycle)):
|
| 21 |
src = cycle[i]
|
| 22 |
dst = cycle[(i + 1) % len(cycle)]
|
| 23 |
+
amounts = df.loc[(df['source'] == src) & (df['target'] == dst), 'amount']
|
| 24 |
+
if not amounts.empty:
|
| 25 |
+
total += amounts.max() # FIX: use max, not mean
|
|
|
|
| 26 |
return total
|
| 27 |
|
| 28 |
|
| 29 |
+
def _validate_temporal_order(cycle: list, df: pd.DataFrame, window_hours: int = 72) -> bool:
|
| 30 |
"""
|
| 31 |
+
Check that there exists a valid temporal ordering of edges in the cycle
|
| 32 |
+
where each hop's timestamp is after the previous within the allowed window.
|
|
|
|
|
|
|
|
|
|
| 33 |
"""
|
| 34 |
+
window = timedelta(hours=window_hours)
|
| 35 |
+
for i in range(len(cycle)):
|
| 36 |
+
src = cycle[i]
|
| 37 |
+
dst = cycle[(i + 1) % len(cycle)]
|
| 38 |
+
edge_times = df.loc[
|
| 39 |
+
(df['source'] == src) & (df['target'] == dst), 'timestamp'
|
| 40 |
+
]
|
| 41 |
+
if edge_times.empty:
|
| 42 |
+
return False
|
| 43 |
+
# Simple check: at least one edge timestamp ordering is monotonic
|
| 44 |
+
times = []
|
| 45 |
+
for i in range(len(cycle)):
|
| 46 |
+
src = cycle[i]
|
| 47 |
+
dst = cycle[(i + 1) % len(cycle)]
|
| 48 |
+
t = df.loc[(df['source'] == src) & (df['target'] == dst), 'timestamp'].min()
|
| 49 |
+
times.append(pd.to_datetime(t))
|
| 50 |
+
# Allow up to window between consecutive timestamps
|
| 51 |
+
for i in range(len(times) - 1):
|
| 52 |
+
if pd.isna(times[i]) or pd.isna(times[i + 1]):
|
| 53 |
+
continue
|
| 54 |
+
if (times[i + 1] - times[i]) > window:
|
| 55 |
+
return False
|
| 56 |
+
return True
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def detect_cycles(G, df: pd.DataFrame) -> list[dict]:
|
| 60 |
+
cfg = get_config()['detectors']
|
| 61 |
+
MIN_AMOUNT = cfg['cycle_min_amount']
|
| 62 |
+
MAX_LEN = cfg['cycle_max_length']
|
| 63 |
+
MIN_LEN = cfg['cycle_min_length']
|
| 64 |
+
TIMEOUT = cfg['cycle_detection_timeout_seconds']
|
| 65 |
+
|
| 66 |
+
fraud_accounts = (
|
| 67 |
+
set(df[df['is_laundering'] == 1]['source'].unique()) |
|
| 68 |
+
set(df[df['is_laundering'] == 1]['target'].unique())
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
df = df.copy()
|
| 72 |
+
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
| 73 |
+
|
| 74 |
+
# Build simple DiGraph from MultiDiGraph with max amount per edge
|
| 75 |
+
G_simple = nx.DiGraph()
|
| 76 |
+
if hasattr(G, 'edges'):
|
| 77 |
+
for u, v, data in G.edges(data=True):
|
| 78 |
+
amt = data.get('amount', 0)
|
| 79 |
+
if G_simple.has_edge(u, v):
|
| 80 |
+
G_simple[u][v]['amount'] = max(G_simple[u][v]['amount'], amt)
|
| 81 |
+
else:
|
| 82 |
+
G_simple.add_edge(u, v, amount=amt)
|
| 83 |
+
|
| 84 |
+
# Only keep high-value edges
|
| 85 |
high_value_edges = [
|
| 86 |
+
(u, v) for u, v, d in G_simple.edges(data=True)
|
| 87 |
+
if d.get('amount', 0) >= MIN_AMOUNT
|
|
|
|
| 88 |
]
|
| 89 |
G_filtered = nx.DiGraph(high_value_edges)
|
| 90 |
|
| 91 |
+
# Threading-based timeout (works on Windows and Linux)
|
| 92 |
partial_cycles = []
|
| 93 |
+
stop_event = threading.Event()
|
| 94 |
+
|
| 95 |
+
def _collect_cycles():
|
| 96 |
try:
|
| 97 |
for cycle in nx.simple_cycles(G_filtered):
|
| 98 |
+
if stop_event.is_set():
|
| 99 |
+
break
|
| 100 |
+
if MIN_LEN <= len(cycle) <= MAX_LEN:
|
| 101 |
+
partial_cycles.append(cycle)
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
t = threading.Thread(target=_collect_cycles, daemon=True)
|
| 106 |
+
t.start()
|
| 107 |
+
t.join(timeout=TIMEOUT)
|
| 108 |
+
stop_event.set()
|
| 109 |
+
|
| 110 |
+
alerts = []
|
| 111 |
+
seen_cycle_ids = set()
|
| 112 |
+
|
| 113 |
+
for cycle in partial_cycles:
|
| 114 |
+
cycle_key = frozenset(cycle)
|
| 115 |
+
if cycle_key in seen_cycle_ids:
|
| 116 |
+
continue
|
| 117 |
+
seen_cycle_ids.add(cycle_key)
|
| 118 |
+
|
| 119 |
+
# Temporal validation
|
| 120 |
+
if not _validate_temporal_order(cycle, df):
|
| 121 |
+
continue
|
| 122 |
|
|
|
|
| 123 |
total_amount = _cycle_amounts_from_df(cycle, df)
|
| 124 |
+
fraud_overlap = sum(1 for n in cycle if n in fraud_accounts)
|
| 125 |
cycle_fraud_ratio = fraud_overlap / len(cycle)
|
| 126 |
|
| 127 |
risk_score = min(
|
|
|
|
| 130 |
)
|
| 131 |
|
| 132 |
cycle_id = uuid.uuid4().hex[:8]
|
| 133 |
+
|
| 134 |
+
# FIX: one alert per cycle — use highest-risk node as representative
|
| 135 |
+
representative = max(cycle, key=lambda n: (1 if n in fraud_accounts else 0))
|
| 136 |
+
|
| 137 |
explanation = (
|
| 138 |
+
f"Circular fund flow across {len(cycle)} accounts. "
|
| 139 |
+
f"Funds return to origin after {len(cycle) - 1} intermediate hops. "
|
| 140 |
+
f"Cycle members: {', '.join(cycle[:4])}{'...' if len(cycle) > 4 else ''}"
|
| 141 |
)
|
| 142 |
|
| 143 |
+
alerts.append({
|
| 144 |
+
'alert_id': uuid.uuid4().hex[:8],
|
| 145 |
+
'account': representative,
|
| 146 |
+
'typology': 'RoundTripping',
|
| 147 |
+
'risk_score': risk_score,
|
| 148 |
+
'amount_involved': round(total_amount, 2),
|
| 149 |
+
'tx_count': len(cycle),
|
| 150 |
+
'explanation': explanation,
|
| 151 |
+
'timestamp_detected': datetime.now().isoformat(),
|
| 152 |
+
'confirmed_fraud': representative in fraud_accounts,
|
| 153 |
+
'cycle_id': cycle_id,
|
| 154 |
+
'cycle_members': cycle,
|
| 155 |
+
})
|
| 156 |
|
| 157 |
return alerts
|
src/detectors/structuring.py
CHANGED
|
@@ -1,86 +1,92 @@
|
|
| 1 |
"""
|
| 2 |
-
Structuring Detector
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import uuid
|
| 6 |
import pandas as pd
|
| 7 |
from datetime import datetime
|
| 8 |
-
|
| 9 |
-
# Constants
|
| 10 |
-
STRUCTURING_THRESHOLD = 100_000
|
| 11 |
-
STRUCTURING_LOWER_PCT = 0.85
|
| 12 |
-
STRUCTURING_WINDOW_DAYS = 7
|
| 13 |
-
STRUCTURING_MIN_TX_COUNT = 2
|
| 14 |
|
| 15 |
|
| 16 |
def detect_structuring(df: pd.DataFrame) -> list[dict]:
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if band_df.empty:
|
| 34 |
-
return
|
| 35 |
-
|
| 36 |
-
band_df['timestamp'] = pd.to_datetime(band_df['timestamp'])
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
if
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
return alerts
|
|
|
|
| 1 |
"""
|
| 2 |
+
Structuring Detector - Fixed
|
| 3 |
+
- Scans both source and target
|
| 4 |
+
- Rolling window via pandas
|
| 5 |
+
- Configurable thresholds from config.yaml
|
| 6 |
+
- No Streamlit
|
| 7 |
"""
|
| 8 |
import uuid
|
| 9 |
import pandas as pd
|
| 10 |
from datetime import datetime
|
| 11 |
+
from src.config_loader import get_config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def detect_structuring(df: pd.DataFrame) -> list[dict]:
|
| 15 |
+
cfg = get_config()['detectors']
|
| 16 |
+
THRESHOLD = cfg['structuring_threshold']
|
| 17 |
+
LOWER_PCT = cfg['structuring_lower_pct']
|
| 18 |
+
WINDOW_DAYS = cfg['structuring_window_days']
|
| 19 |
+
MIN_TX_COUNT = cfg['structuring_min_tx_count']
|
| 20 |
+
CHECK_TARGET = cfg.get('structuring_check_target', True)
|
| 21 |
+
|
| 22 |
+
lower = THRESHOLD * LOWER_PCT
|
| 23 |
+
upper = THRESHOLD
|
| 24 |
+
window = f"{WINDOW_DAYS}D"
|
| 25 |
+
|
| 26 |
+
fraud_accounts = (
|
| 27 |
+
set(df[df['is_laundering'] == 1]['source'].unique()) |
|
| 28 |
+
set(df[df['is_laundering'] == 1]['target'].unique())
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
df = df.copy()
|
| 32 |
+
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
| 33 |
+
|
| 34 |
+
# Filter to transactions in the structuring band
|
| 35 |
+
band_df = df[df['amount'].between(lower, upper, inclusive='left')].copy()
|
| 36 |
if band_df.empty:
|
| 37 |
+
return []
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
alerts = []
|
| 40 |
+
seen = set()
|
| 41 |
+
|
| 42 |
+
def _scan_column(col: str, band: pd.DataFrame) -> None:
|
| 43 |
+
for account in band[col].unique():
|
| 44 |
+
if account in seen:
|
| 45 |
+
continue
|
| 46 |
+
acct = band[band[col] == account].set_index('timestamp').sort_index()
|
| 47 |
+
if acct.empty:
|
| 48 |
+
continue
|
| 49 |
+
# Rolling window count + sum
|
| 50 |
+
rolled_count = acct['amount'].rolling(window).count()
|
| 51 |
+
rolled_sum = acct['amount'].rolling(window).sum()
|
| 52 |
+
|
| 53 |
+
triggered = (rolled_sum >= THRESHOLD) & (rolled_count >= MIN_TX_COUNT)
|
| 54 |
+
if not triggered.any():
|
| 55 |
+
continue
|
| 56 |
+
|
| 57 |
+
seen.add(account)
|
| 58 |
+
best_idx = triggered[triggered].index[-1]
|
| 59 |
+
total = float(rolled_sum[best_idx])
|
| 60 |
+
count = int(rolled_count[best_idx])
|
| 61 |
+
|
| 62 |
+
risk_score = min(
|
| 63 |
+
int((total / THRESHOLD) * 40 + (count / MIN_TX_COUNT) * 30 + 20),
|
| 64 |
+
99
|
| 65 |
+
)
|
| 66 |
+
# Velocity acceleration: tx in last 24h vs full window
|
| 67 |
+
last_24h = acct['amount'].rolling('1D').count()
|
| 68 |
+
accel = float(last_24h.iloc[-1]) / max(count, 1)
|
| 69 |
+
risk_score = min(int(risk_score + accel * 10), 99)
|
| 70 |
+
|
| 71 |
+
explanation = (
|
| 72 |
+
f"{count} transactions totalling {total:,.0f} detected within "
|
| 73 |
+
f"{WINDOW_DAYS} days, clustering just below the "
|
| 74 |
+
f"reporting threshold of {THRESHOLD:,}"
|
| 75 |
+
)
|
| 76 |
+
alerts.append({
|
| 77 |
+
'alert_id': uuid.uuid4().hex[:8],
|
| 78 |
+
'account': account,
|
| 79 |
+
'typology': 'Structuring',
|
| 80 |
+
'risk_score': risk_score,
|
| 81 |
+
'amount_involved': round(total, 2),
|
| 82 |
+
'tx_count': count,
|
| 83 |
+
'explanation': explanation,
|
| 84 |
+
'timestamp_detected': datetime.now().isoformat(),
|
| 85 |
+
'confirmed_fraud': account in fraud_accounts,
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
_scan_column('source', band_df)
|
| 89 |
+
if CHECK_TARGET:
|
| 90 |
+
_scan_column('target', band_df)
|
| 91 |
|
| 92 |
return alerts
|
src/graph_builder.py
CHANGED
|
@@ -1,33 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import networkx as nx
|
| 2 |
import pandas as pd
|
| 3 |
-
import streamlit as st
|
| 4 |
import community as community_louvain
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
source='source',
|
| 21 |
-
target='target',
|
| 22 |
-
edge_attr=['amount', 'payment_type', 'is_laundering', 'timestamp'],
|
| 23 |
-
create_using=nx.DiGraph()
|
| 24 |
-
)
|
| 25 |
return G
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
Attach precomputed node features directly to the graph's nodes.
|
| 30 |
-
"""
|
| 31 |
if 'account' in node_features_df.columns:
|
| 32 |
feature_dict = node_features_df.set_index('account').to_dict('index')
|
| 33 |
else:
|
|
@@ -35,81 +36,111 @@ def attach_node_features(G: nx.DiGraph, node_features_df: pd.DataFrame) -> nx.Di
|
|
| 35 |
nx.set_node_attributes(G, feature_dict)
|
| 36 |
return G
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
@
|
| 53 |
-
def
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
if center_node not in G:
|
| 67 |
return None
|
| 68 |
-
|
| 69 |
nodes_to_include = {center_node}
|
| 70 |
frontier = {center_node}
|
| 71 |
-
|
| 72 |
for _ in range(hops):
|
| 73 |
next_frontier = set()
|
| 74 |
for n in frontier:
|
| 75 |
next_frontier |= set(G.successors(n))
|
| 76 |
next_frontier |= set(G.predecessors(n))
|
| 77 |
-
|
| 78 |
nodes_to_include |= next_frontier
|
| 79 |
frontier = next_frontier
|
| 80 |
-
|
| 81 |
if len(nodes_to_include) >= max_nodes:
|
| 82 |
break
|
| 83 |
-
|
| 84 |
-
# Trim keeping highest degree nodes if exceeding max_nodes
|
| 85 |
nodes_list = list(nodes_to_include)
|
| 86 |
if len(nodes_list) > max_nodes:
|
| 87 |
-
# Sort by
|
| 88 |
-
|
|
|
|
|
|
|
| 89 |
nodes_list = nodes_list[:max_nodes]
|
| 90 |
-
# Always make sure center_node is kept
|
| 91 |
if center_node not in nodes_list:
|
| 92 |
nodes_list[-1] = center_node
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
return {
|
| 111 |
'node_count': node_count,
|
| 112 |
'edge_count': edge_count,
|
| 113 |
-
'density': density,
|
| 114 |
-
'avg_degree': avg_degree
|
| 115 |
}
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Graph Builder Module - Fixed
|
| 3 |
+
- Uses MultiDiGraph to retain parallel transactions
|
| 4 |
+
- Proportional betweenness k
|
| 5 |
+
- Edge-weight-based subgraph trimming
|
| 6 |
+
- Temporal graph builder
|
| 7 |
+
- No Streamlit dependency
|
| 8 |
+
"""
|
| 9 |
+
import functools
|
| 10 |
import networkx as nx
|
| 11 |
import pandas as pd
|
|
|
|
| 12 |
import community as community_louvain
|
| 13 |
|
| 14 |
+
from src.config_loader import get_config
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def build_graph(df: pd.DataFrame) -> nx.MultiDiGraph:
|
| 18 |
+
"""Build a directed multigraph — each transaction is its own edge."""
|
| 19 |
+
G = nx.MultiDiGraph()
|
| 20 |
+
for _, row in df.iterrows():
|
| 21 |
+
G.add_edge(
|
| 22 |
+
row['source'], row['target'],
|
| 23 |
+
amount=row['amount'],
|
| 24 |
+
payment_type=row['payment_type'],
|
| 25 |
+
is_laundering=int(row['is_laundering']),
|
| 26 |
+
timestamp=str(row['timestamp']),
|
| 27 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
return G
|
| 29 |
|
| 30 |
+
|
| 31 |
+
def attach_node_features(G: nx.MultiDiGraph, node_features_df: pd.DataFrame) -> nx.MultiDiGraph:
|
|
|
|
|
|
|
| 32 |
if 'account' in node_features_df.columns:
|
| 33 |
feature_dict = node_features_df.set_index('account').to_dict('index')
|
| 34 |
else:
|
|
|
|
| 36 |
nx.set_node_attributes(G, feature_dict)
|
| 37 |
return G
|
| 38 |
|
| 39 |
+
|
| 40 |
+
@functools.lru_cache(maxsize=1)
|
| 41 |
+
def compute_pagerank(_G_id: int, _G_ref) -> dict:
|
| 42 |
+
cfg = get_config()['graph']
|
| 43 |
+
# Convert to simple DiGraph for PageRank (weight = sum of amounts)
|
| 44 |
+
Gs = nx.DiGraph()
|
| 45 |
+
for u, v, data in _G_ref.edges(data=True):
|
| 46 |
+
if Gs.has_edge(u, v):
|
| 47 |
+
Gs[u][v]['weight'] += data.get('amount', 0)
|
| 48 |
+
else:
|
| 49 |
+
Gs.add_edge(u, v, weight=data.get('amount', 0))
|
| 50 |
+
return nx.pagerank(Gs, alpha=cfg['pagerank_alpha'], max_iter=cfg['pagerank_max_iter'])
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@functools.lru_cache(maxsize=1)
|
| 54 |
+
def compute_betweenness(_G_id: int, _G_ref) -> dict:
|
| 55 |
+
n = _G_ref.number_of_nodes()
|
| 56 |
+
k = min(500, max(50, n))
|
| 57 |
+
Gs = nx.DiGraph(_G_ref) # collapse to simple for betweenness
|
| 58 |
+
return nx.betweenness_centrality(Gs, k=k, normalized=True)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@functools.lru_cache(maxsize=1)
|
| 62 |
+
def compute_louvain(_G_id: int, _G_ref) -> dict:
|
| 63 |
+
G_simple = nx.Graph()
|
| 64 |
+
for u, v, data in _G_ref.edges(data=True):
|
| 65 |
+
w = data.get('amount', 1)
|
| 66 |
+
if G_simple.has_edge(u, v):
|
| 67 |
+
G_simple[u][v]['weight'] += w
|
| 68 |
+
else:
|
| 69 |
+
G_simple.add_edge(u, v, weight=w)
|
| 70 |
+
return community_louvain.best_partition(G_simple, weight='weight')
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def to_simple_graph(G: nx.MultiDiGraph) -> nx.DiGraph:
|
| 74 |
+
"""Collapse multiedges into a DiGraph summing amounts."""
|
| 75 |
+
Gs = nx.DiGraph()
|
| 76 |
+
for u, v, data in G.edges(data=True):
|
| 77 |
+
if Gs.has_edge(u, v):
|
| 78 |
+
Gs[u][v]['amount'] += data.get('amount', 0)
|
| 79 |
+
Gs[u][v]['tx_count'] = Gs[u][v].get('tx_count', 1) + 1
|
| 80 |
+
else:
|
| 81 |
+
Gs.add_edge(u, v, amount=data.get('amount', 0), tx_count=1,
|
| 82 |
+
is_laundering=data.get('is_laundering', 0),
|
| 83 |
+
payment_type=data.get('payment_type', ''))
|
| 84 |
+
return Gs
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def get_subgraph(G: nx.MultiDiGraph, center_node: str, hops: int = 2, max_nodes: int = 60) -> nx.DiGraph:
|
| 88 |
+
"""BFS subgraph trimmed by edge weight (not raw degree)."""
|
| 89 |
if center_node not in G:
|
| 90 |
return None
|
| 91 |
+
|
| 92 |
nodes_to_include = {center_node}
|
| 93 |
frontier = {center_node}
|
| 94 |
+
|
| 95 |
for _ in range(hops):
|
| 96 |
next_frontier = set()
|
| 97 |
for n in frontier:
|
| 98 |
next_frontier |= set(G.successors(n))
|
| 99 |
next_frontier |= set(G.predecessors(n))
|
|
|
|
| 100 |
nodes_to_include |= next_frontier
|
| 101 |
frontier = next_frontier
|
|
|
|
| 102 |
if len(nodes_to_include) >= max_nodes:
|
| 103 |
break
|
| 104 |
+
|
|
|
|
| 105 |
nodes_list = list(nodes_to_include)
|
| 106 |
if len(nodes_list) > max_nodes:
|
| 107 |
+
# Sort by total edge weight, not degree
|
| 108 |
+
def node_weight(n):
|
| 109 |
+
return sum(d.get('amount', 0) for _, _, d in G.edges(n, data=True))
|
| 110 |
+
nodes_list.sort(key=node_weight, reverse=True)
|
| 111 |
nodes_list = nodes_list[:max_nodes]
|
|
|
|
| 112 |
if center_node not in nodes_list:
|
| 113 |
nodes_list[-1] = center_node
|
| 114 |
+
|
| 115 |
+
sub = G.subgraph(nodes_list)
|
| 116 |
+
return to_simple_graph(sub)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def build_temporal_graph(df: pd.DataFrame) -> list:
|
| 120 |
+
"""Return time-sorted edge list for fund-trail animation."""
|
| 121 |
+
df_sorted = df.sort_values('timestamp')
|
| 122 |
+
return [
|
| 123 |
+
{
|
| 124 |
+
'source': row['source'],
|
| 125 |
+
'target': row['target'],
|
| 126 |
+
'amount': row['amount'],
|
| 127 |
+
'timestamp': str(row['timestamp']),
|
| 128 |
+
'payment_type': row.get('payment_type', ''),
|
| 129 |
+
'is_laundering': int(row.get('is_laundering', 0)),
|
| 130 |
+
}
|
| 131 |
+
for _, row in df_sorted.iterrows()
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def compute_graph_stats(G: nx.MultiDiGraph) -> dict:
|
| 136 |
+
Gs = to_simple_graph(G)
|
| 137 |
+
node_count = Gs.number_of_nodes()
|
| 138 |
+
edge_count = Gs.number_of_edges()
|
| 139 |
+
density = nx.density(Gs)
|
| 140 |
+
avg_degree = sum(d for _, d in Gs.degree()) / max(node_count, 1)
|
| 141 |
return {
|
| 142 |
'node_count': node_count,
|
| 143 |
'edge_count': edge_count,
|
| 144 |
+
'density': round(density, 6),
|
| 145 |
+
'avg_degree': round(avg_degree, 2),
|
| 146 |
}
|
src/ml/explainer.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
| 1 |
"""
|
| 2 |
-
SHAP Explainer Module
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import shap
|
| 6 |
import pandas as pd
|
| 7 |
|
| 8 |
-
|
|
|
|
| 9 |
FEATURE_DESCRIPTIONS = {
|
| 10 |
'tx_count_total': 'Total number of outgoing transactions',
|
| 11 |
'tx_count_7d': 'Transaction count in the last 7 days',
|
|
@@ -18,41 +22,45 @@ FEATURE_DESCRIPTIONS = {
|
|
| 18 |
'amount_std': 'Consistency of transaction amounts',
|
| 19 |
'in_out_ratio': 'Ratio of received to sent funds',
|
| 20 |
'pagerank_score': 'Network influence of this account',
|
|
|
|
| 21 |
'in_degree': 'Number of accounts sending funds to this account',
|
| 22 |
'out_degree': 'Number of accounts receiving funds from this account',
|
| 23 |
'fan_in_ratio': 'Concentration of incoming vs outgoing connections',
|
| 24 |
-
'
|
| 25 |
-
'
|
|
|
|
| 26 |
'account_age_days': 'Age of the account based on transaction history',
|
|
|
|
| 27 |
'currency_diversity': 'Number of distinct currencies used',
|
| 28 |
'channel_diversity': 'Number of distinct payment channels used',
|
| 29 |
'bank_diversity': 'Number of distinct destination banks used',
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def explain_prediction(
|
| 36 |
account_id: str,
|
| 37 |
feature_df: pd.DataFrame,
|
| 38 |
bundle: dict,
|
|
|
|
| 39 |
) -> list[dict]:
|
| 40 |
-
"""
|
| 41 |
-
Generate SHAP-based explanations for a single account's fraud prediction.
|
| 42 |
-
|
| 43 |
-
Returns:
|
| 44 |
-
List of top-N feature explanation dicts sorted by |SHAP value|.
|
| 45 |
-
"""
|
| 46 |
row = feature_df[feature_df['account'] == account_id]
|
| 47 |
-
|
| 48 |
if row.empty:
|
| 49 |
return []
|
| 50 |
|
| 51 |
-
feature_cols = bundle['feature_cols']
|
| 52 |
-
|
| 53 |
|
| 54 |
-
explainer =
|
| 55 |
-
shap_values = explainer.shap_values(
|
| 56 |
|
| 57 |
results = []
|
| 58 |
for i, col in enumerate(feature_cols):
|
|
@@ -60,10 +68,10 @@ def explain_prediction(
|
|
| 60 |
results.append({
|
| 61 |
'feature_name': col,
|
| 62 |
'shap_value': sv,
|
| 63 |
-
'feature_value': float(
|
| 64 |
'direction': 'increases risk' if sv > 0 else 'decreases risk',
|
| 65 |
'description': FEATURE_DESCRIPTIONS.get(col, col),
|
| 66 |
})
|
| 67 |
|
| 68 |
results.sort(key=lambda x: abs(x['shap_value']), reverse=True)
|
| 69 |
-
return results[:
|
|
|
|
| 1 |
"""
|
| 2 |
+
SHAP Explainer Module - Fixed
|
| 3 |
+
- Cached TreeExplainer (created once)
|
| 4 |
+
- Both raw and scaled feature values in output
|
| 5 |
+
- top_n parameter
|
| 6 |
+
- No Streamlit
|
| 7 |
"""
|
| 8 |
import shap
|
| 9 |
import pandas as pd
|
| 10 |
|
| 11 |
+
_explainer_cache = None
|
| 12 |
+
|
| 13 |
FEATURE_DESCRIPTIONS = {
|
| 14 |
'tx_count_total': 'Total number of outgoing transactions',
|
| 15 |
'tx_count_7d': 'Transaction count in the last 7 days',
|
|
|
|
| 22 |
'amount_std': 'Consistency of transaction amounts',
|
| 23 |
'in_out_ratio': 'Ratio of received to sent funds',
|
| 24 |
'pagerank_score': 'Network influence of this account',
|
| 25 |
+
'betweenness_score': 'Bridge importance — how often this account lies on shortest paths',
|
| 26 |
'in_degree': 'Number of accounts sending funds to this account',
|
| 27 |
'out_degree': 'Number of accounts receiving funds from this account',
|
| 28 |
'fan_in_ratio': 'Concentration of incoming vs outgoing connections',
|
| 29 |
+
'community_encoded': 'Fraud rate of the network community this account belongs to',
|
| 30 |
+
'cycle_length': 'Length of circular transaction loop this account is part of (0 if none)',
|
| 31 |
+
'cycle_max_amount': 'Peak amount transacted in the detected circular loop',
|
| 32 |
'account_age_days': 'Age of the account based on transaction history',
|
| 33 |
+
'days_since_last_tx': 'Days elapsed since the most recent transaction',
|
| 34 |
'currency_diversity': 'Number of distinct currencies used',
|
| 35 |
'channel_diversity': 'Number of distinct payment channels used',
|
| 36 |
'bank_diversity': 'Number of distinct destination banks used',
|
| 37 |
+
'velocity_ratio_7d': 'Proportion of all-time activity concentrated in last 7 days',
|
| 38 |
+
'gnn_fraud_score': 'Graph Neural Network fraud probability from neighbourhood analysis',
|
| 39 |
}
|
| 40 |
|
| 41 |
+
|
| 42 |
+
def _get_explainer(model):
|
| 43 |
+
global _explainer_cache
|
| 44 |
+
if _explainer_cache is None:
|
| 45 |
+
_explainer_cache = shap.TreeExplainer(model)
|
| 46 |
+
return _explainer_cache
|
| 47 |
|
| 48 |
|
| 49 |
def explain_prediction(
|
| 50 |
account_id: str,
|
| 51 |
feature_df: pd.DataFrame,
|
| 52 |
bundle: dict,
|
| 53 |
+
top_n: int = 5,
|
| 54 |
) -> list[dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
row = feature_df[feature_df['account'] == account_id]
|
|
|
|
| 56 |
if row.empty:
|
| 57 |
return []
|
| 58 |
|
| 59 |
+
feature_cols = [c for c in bundle['feature_cols'] if c in row.columns]
|
| 60 |
+
X = row[feature_cols]
|
| 61 |
|
| 62 |
+
explainer = _get_explainer(bundle['model'])
|
| 63 |
+
shap_values = explainer.shap_values(X)
|
| 64 |
|
| 65 |
results = []
|
| 66 |
for i, col in enumerate(feature_cols):
|
|
|
|
| 68 |
results.append({
|
| 69 |
'feature_name': col,
|
| 70 |
'shap_value': sv,
|
| 71 |
+
'feature_value': float(X[col].values[0]),
|
| 72 |
'direction': 'increases risk' if sv > 0 else 'decreases risk',
|
| 73 |
'description': FEATURE_DESCRIPTIONS.get(col, col),
|
| 74 |
})
|
| 75 |
|
| 76 |
results.sort(key=lambda x: abs(x['shap_value']), reverse=True)
|
| 77 |
+
return results[:top_n]
|
src/ml/features.py
CHANGED
|
@@ -1,41 +1,44 @@
|
|
| 1 |
"""
|
| 2 |
-
Feature Engineering Module
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import pandas as pd
|
| 6 |
import numpy as np
|
| 7 |
import networkx as nx
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
# Constants
|
| 10 |
RECENT_WINDOW_DAYS = 7
|
| 11 |
|
| 12 |
|
| 13 |
def engineer_features(
|
| 14 |
df: pd.DataFrame,
|
| 15 |
-
G
|
| 16 |
pagerank_scores: dict,
|
|
|
|
| 17 |
louvain_partition: dict,
|
| 18 |
-
|
| 19 |
) -> pd.DataFrame:
|
| 20 |
-
|
| 21 |
-
Build a feature DataFrame with one row per unique account.
|
| 22 |
-
|
| 23 |
-
Combines velocity, ratio, graph, and profile features with a fraud_flag target.
|
| 24 |
-
|
| 25 |
-
Returns:
|
| 26 |
-
pd.DataFrame with all features and fraud_flag column.
|
| 27 |
-
"""
|
| 28 |
df = df.copy()
|
| 29 |
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
| 30 |
max_date = df['timestamp'].max()
|
| 31 |
cutoff_7d = max_date - pd.Timedelta(days=RECENT_WINDOW_DAYS)
|
|
|
|
| 32 |
|
| 33 |
-
fraud_sources =
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
# --- All unique accounts ---
|
| 36 |
all_accounts = set(df['source'].unique()) | set(df['target'].unique())
|
| 37 |
|
| 38 |
-
#
|
| 39 |
sent_all = df.groupby('source').agg(
|
| 40 |
tx_count_total=('amount', 'count'),
|
| 41 |
amount_sent_total=('amount', 'sum'),
|
|
@@ -44,8 +47,6 @@ def engineer_features(
|
|
| 44 |
tx_count_7d=('amount', 'count'),
|
| 45 |
amount_sent_7d=('amount', 'sum'),
|
| 46 |
)
|
| 47 |
-
|
| 48 |
-
# --- Pre-aggregate received stats ---
|
| 49 |
recv_all = df.groupby('target').agg(
|
| 50 |
amount_received_total=('amount', 'sum'),
|
| 51 |
)
|
|
@@ -53,62 +54,81 @@ def engineer_features(
|
|
| 53 |
amount_received_7d=('amount', 'sum'),
|
| 54 |
)
|
| 55 |
|
| 56 |
-
# --- Amount std: combine sent + received per account ---
|
| 57 |
sent_amounts = df[['source', 'amount']].rename(columns={'source': 'account'})
|
| 58 |
recv_amounts = df[['target', 'amount']].rename(columns={'target': 'account'})
|
| 59 |
combined_amounts = pd.concat([sent_amounts, recv_amounts], ignore_index=True)
|
| 60 |
amount_std = combined_amounts.groupby('account')['amount'].std().rename('amount_std')
|
| 61 |
|
| 62 |
-
# --- Profile features ---
|
| 63 |
first_tx = df.groupby('source')['timestamp'].min().rename('first_tx')
|
| 64 |
last_tx = df.groupby('source')['timestamp'].max().rename('last_tx')
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
currency_div = df.groupby('source')['Payment Currency'].nunique().rename('currency_diversity') \
|
| 67 |
if 'Payment Currency' in df.columns else pd.Series(dtype=float, name='currency_diversity')
|
| 68 |
-
channel_div = df.groupby('source')['payment_type'].nunique().rename('channel_diversity')
|
| 69 |
-
bank_div = df.groupby('source')['target_bank'].nunique().rename('bank_diversity')
|
| 70 |
|
| 71 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
features = pd.DataFrame(index=list(all_accounts))
|
| 73 |
features.index.name = 'account'
|
| 74 |
|
| 75 |
-
# Velocity
|
| 76 |
features = features.join(sent_all)
|
| 77 |
features = features.join(sent_7d)
|
| 78 |
features = features.join(recv_all)
|
| 79 |
features = features.join(recv_7d)
|
| 80 |
|
| 81 |
-
# Ratio
|
| 82 |
features['forward_ratio'] = features['amount_sent_total'] / (features['amount_received_total'] + 1)
|
| 83 |
features['avg_tx_amount'] = features['amount_sent_total'] / (features['tx_count_total'] + 1)
|
| 84 |
features = features.join(amount_std)
|
| 85 |
features['in_out_ratio'] = features['amount_received_total'] / (features['amount_sent_total'] + 1)
|
|
|
|
| 86 |
|
| 87 |
-
# Graph
|
| 88 |
features['pagerank_score'] = features.index.map(lambda a: pagerank_scores.get(a, 0))
|
|
|
|
| 89 |
features['in_degree'] = features.index.map(lambda a: G.in_degree(a) if a in G else 0)
|
| 90 |
features['out_degree'] = features.index.map(lambda a: G.out_degree(a) if a in G else 0)
|
| 91 |
features['fan_in_ratio'] = features['in_degree'] / (features['in_degree'] + features['out_degree'] + 1)
|
| 92 |
-
features['community_id'] = features.index.map(lambda a: louvain_partition.get(a, -1))
|
| 93 |
-
features['is_in_cycle'] = features.index.map(lambda a: 1 if a in cycle_accounts else 0)
|
| 94 |
|
| 95 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
features = features.join(first_tx)
|
| 97 |
features = features.join(last_tx)
|
| 98 |
-
features['account_age_days'] = (features['last_tx'] - features['first_tx']).dt.days
|
|
|
|
| 99 |
features = features.drop(columns=['first_tx', 'last_tx'], errors='ignore')
|
|
|
|
| 100 |
features = features.join(currency_div)
|
| 101 |
features = features.join(channel_div)
|
| 102 |
features = features.join(bank_div)
|
| 103 |
|
| 104 |
-
# Target
|
| 105 |
features['fraud_flag'] = features.index.map(lambda a: 1 if a in fraud_sources else 0)
|
| 106 |
|
| 107 |
-
|
| 108 |
-
features = features.replace([np.inf, -np.inf], 0)
|
| 109 |
-
features = features.fillna(0)
|
| 110 |
-
|
| 111 |
-
# Reset index so 'account' becomes a column
|
| 112 |
features = features.reset_index()
|
| 113 |
|
| 114 |
return features
|
|
|
|
| 1 |
"""
|
| 2 |
+
Feature Engineering Module - Fixed
|
| 3 |
+
- Target-encoded community_id
|
| 4 |
+
- cycle_length + cycle_max_amount instead of binary is_in_cycle
|
| 5 |
+
- betweenness_score feature
|
| 6 |
+
- days_since_last_tx feature
|
| 7 |
+
- velocity_ratio_7d feature
|
| 8 |
+
- No Streamlit
|
| 9 |
"""
|
| 10 |
import pandas as pd
|
| 11 |
import numpy as np
|
| 12 |
import networkx as nx
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from src.config_loader import get_config
|
| 15 |
|
|
|
|
| 16 |
RECENT_WINDOW_DAYS = 7
|
| 17 |
|
| 18 |
|
| 19 |
def engineer_features(
|
| 20 |
df: pd.DataFrame,
|
| 21 |
+
G,
|
| 22 |
pagerank_scores: dict,
|
| 23 |
+
betweenness_scores: dict,
|
| 24 |
louvain_partition: dict,
|
| 25 |
+
cycle_alerts: list,
|
| 26 |
) -> pd.DataFrame:
|
| 27 |
+
cfg = get_config()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
df = df.copy()
|
| 29 |
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
| 30 |
max_date = df['timestamp'].max()
|
| 31 |
cutoff_7d = max_date - pd.Timedelta(days=RECENT_WINDOW_DAYS)
|
| 32 |
+
now = pd.Timestamp(datetime.now())
|
| 33 |
|
| 34 |
+
fraud_sources = (
|
| 35 |
+
set(df[df['is_laundering'] == 1]['source'].unique()) |
|
| 36 |
+
set(df[df['is_laundering'] == 1]['target'].unique())
|
| 37 |
+
)
|
| 38 |
|
|
|
|
| 39 |
all_accounts = set(df['source'].unique()) | set(df['target'].unique())
|
| 40 |
|
| 41 |
+
# Pre-aggregate sent stats
|
| 42 |
sent_all = df.groupby('source').agg(
|
| 43 |
tx_count_total=('amount', 'count'),
|
| 44 |
amount_sent_total=('amount', 'sum'),
|
|
|
|
| 47 |
tx_count_7d=('amount', 'count'),
|
| 48 |
amount_sent_7d=('amount', 'sum'),
|
| 49 |
)
|
|
|
|
|
|
|
| 50 |
recv_all = df.groupby('target').agg(
|
| 51 |
amount_received_total=('amount', 'sum'),
|
| 52 |
)
|
|
|
|
| 54 |
amount_received_7d=('amount', 'sum'),
|
| 55 |
)
|
| 56 |
|
|
|
|
| 57 |
sent_amounts = df[['source', 'amount']].rename(columns={'source': 'account'})
|
| 58 |
recv_amounts = df[['target', 'amount']].rename(columns={'target': 'account'})
|
| 59 |
combined_amounts = pd.concat([sent_amounts, recv_amounts], ignore_index=True)
|
| 60 |
amount_std = combined_amounts.groupby('account')['amount'].std().rename('amount_std')
|
| 61 |
|
|
|
|
| 62 |
first_tx = df.groupby('source')['timestamp'].min().rename('first_tx')
|
| 63 |
last_tx = df.groupby('source')['timestamp'].max().rename('last_tx')
|
| 64 |
+
channel_div = df.groupby('source')['payment_type'].nunique().rename('channel_diversity')
|
| 65 |
+
bank_div = df.groupby('source')['target_bank'].nunique().rename('bank_diversity') \
|
| 66 |
+
if 'target_bank' in df.columns else pd.Series(dtype=float, name='bank_diversity')
|
| 67 |
currency_div = df.groupby('source')['Payment Currency'].nunique().rename('currency_diversity') \
|
| 68 |
if 'Payment Currency' in df.columns else pd.Series(dtype=float, name='currency_diversity')
|
|
|
|
|
|
|
| 69 |
|
| 70 |
+
# Cycle features from alert list
|
| 71 |
+
cycle_length_map = {}
|
| 72 |
+
cycle_amount_map = {}
|
| 73 |
+
for alert in cycle_alerts:
|
| 74 |
+
if alert.get('typology') == 'RoundTripping':
|
| 75 |
+
members = alert.get('cycle_members', [alert['account']])
|
| 76 |
+
length = alert.get('tx_count', len(members))
|
| 77 |
+
amount = alert.get('amount_involved', 0)
|
| 78 |
+
for m in members:
|
| 79 |
+
if m not in cycle_length_map or length > cycle_length_map[m]:
|
| 80 |
+
cycle_length_map[m] = length
|
| 81 |
+
cycle_amount_map[m] = amount
|
| 82 |
+
|
| 83 |
+
# Community fraud rate for target encoding
|
| 84 |
+
community_fraud = {}
|
| 85 |
+
for node, cid in louvain_partition.items():
|
| 86 |
+
community_fraud.setdefault(cid, []).append(1 if node in fraud_sources else 0)
|
| 87 |
+
community_fraud_rate = {cid: np.mean(vals) for cid, vals in community_fraud.items()}
|
| 88 |
+
|
| 89 |
+
# Build feature DataFrame
|
| 90 |
features = pd.DataFrame(index=list(all_accounts))
|
| 91 |
features.index.name = 'account'
|
| 92 |
|
|
|
|
| 93 |
features = features.join(sent_all)
|
| 94 |
features = features.join(sent_7d)
|
| 95 |
features = features.join(recv_all)
|
| 96 |
features = features.join(recv_7d)
|
| 97 |
|
|
|
|
| 98 |
features['forward_ratio'] = features['amount_sent_total'] / (features['amount_received_total'] + 1)
|
| 99 |
features['avg_tx_amount'] = features['amount_sent_total'] / (features['tx_count_total'] + 1)
|
| 100 |
features = features.join(amount_std)
|
| 101 |
features['in_out_ratio'] = features['amount_received_total'] / (features['amount_sent_total'] + 1)
|
| 102 |
+
features['velocity_ratio_7d'] = features['tx_count_7d'] / (features['tx_count_total'] + 1)
|
| 103 |
|
|
|
|
| 104 |
features['pagerank_score'] = features.index.map(lambda a: pagerank_scores.get(a, 0))
|
| 105 |
+
features['betweenness_score'] = features.index.map(lambda a: betweenness_scores.get(a, 0))
|
| 106 |
features['in_degree'] = features.index.map(lambda a: G.in_degree(a) if a in G else 0)
|
| 107 |
features['out_degree'] = features.index.map(lambda a: G.out_degree(a) if a in G else 0)
|
| 108 |
features['fan_in_ratio'] = features['in_degree'] / (features['in_degree'] + features['out_degree'] + 1)
|
|
|
|
|
|
|
| 109 |
|
| 110 |
+
# FIX: target-encoded community (fraud rate per community)
|
| 111 |
+
features['community_encoded'] = features.index.map(
|
| 112 |
+
lambda a: community_fraud_rate.get(louvain_partition.get(a, -1), 0.0)
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# FIX: cycle_length + cycle_max_amount
|
| 116 |
+
features['cycle_length'] = features.index.map(lambda a: cycle_length_map.get(a, 0))
|
| 117 |
+
features['cycle_max_amount'] = features.index.map(lambda a: cycle_amount_map.get(a, 0))
|
| 118 |
+
|
| 119 |
features = features.join(first_tx)
|
| 120 |
features = features.join(last_tx)
|
| 121 |
+
features['account_age_days'] = (features['last_tx'] - features['first_tx']).dt.days.fillna(0)
|
| 122 |
+
features['days_since_last_tx'] = (now - features['last_tx']).dt.days.fillna(9999)
|
| 123 |
features = features.drop(columns=['first_tx', 'last_tx'], errors='ignore')
|
| 124 |
+
|
| 125 |
features = features.join(currency_div)
|
| 126 |
features = features.join(channel_div)
|
| 127 |
features = features.join(bank_div)
|
| 128 |
|
|
|
|
| 129 |
features['fraud_flag'] = features.index.map(lambda a: 1 if a in fraud_sources else 0)
|
| 130 |
|
| 131 |
+
features = features.replace([np.inf, -np.inf], 0).fillna(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
features = features.reset_index()
|
| 133 |
|
| 134 |
return features
|
src/ml/gnn_predictor.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Graph Neural Network Predictor
|
| 3 |
+
- Loads trained GraphSAGE model
|
| 4 |
+
- Inferences over the local neighbourhood subgraph
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn.functional as F
|
| 9 |
+
from torch_geometric.data import Data
|
| 10 |
+
from src.ml.gnn_trainer import GraphSAGEFraudDetector
|
| 11 |
+
from src.config_loader import get_config
|
| 12 |
+
|
| 13 |
+
_gnn_model_cache = None
|
| 14 |
+
|
| 15 |
+
def load_gnn_model():
|
| 16 |
+
global _gnn_model_cache
|
| 17 |
+
cfg = get_config()['ml']
|
| 18 |
+
|
| 19 |
+
if not os.path.exists(cfg['gnn_model_path']):
|
| 20 |
+
return None
|
| 21 |
+
|
| 22 |
+
if _gnn_model_cache is not None:
|
| 23 |
+
return _gnn_model_cache
|
| 24 |
+
|
| 25 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 26 |
+
checkpoint = torch.load(cfg['gnn_model_path'], map_location=device, weights_only=False)
|
| 27 |
+
|
| 28 |
+
model = GraphSAGEFraudDetector(
|
| 29 |
+
in_channels=checkpoint['in_channels'],
|
| 30 |
+
hidden_channels=checkpoint['hidden_channels']
|
| 31 |
+
)
|
| 32 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 33 |
+
model.to(device)
|
| 34 |
+
model.eval()
|
| 35 |
+
|
| 36 |
+
_gnn_model_cache = {
|
| 37 |
+
'model': model,
|
| 38 |
+
'feature_cols': checkpoint['feature_cols'],
|
| 39 |
+
'x_mean': checkpoint['x_mean'],
|
| 40 |
+
'x_std': checkpoint['x_std'],
|
| 41 |
+
'device': device
|
| 42 |
+
}
|
| 43 |
+
return _gnn_model_cache
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def predict_gnn_score(G, feature_df) -> dict:
|
| 47 |
+
"""Batch predict GNN score for all nodes in the given graph (usually a subgraph)."""
|
| 48 |
+
bundle = load_gnn_model()
|
| 49 |
+
if not bundle:
|
| 50 |
+
return {n: 0.0 for n in G.nodes()}
|
| 51 |
+
|
| 52 |
+
node_list = list(G.nodes())
|
| 53 |
+
if not node_list:
|
| 54 |
+
return {}
|
| 55 |
+
|
| 56 |
+
node_to_idx = {n: i for i, n in enumerate(node_list)}
|
| 57 |
+
|
| 58 |
+
df = feature_df.set_index('account').reindex(node_list).fillna(0)
|
| 59 |
+
x_numpy = df[bundle['feature_cols']].values
|
| 60 |
+
x_scaled = (x_numpy - bundle['x_mean']) / (bundle['x_std'] + 1e-8)
|
| 61 |
+
|
| 62 |
+
x = torch.tensor(x_scaled, dtype=torch.float)
|
| 63 |
+
|
| 64 |
+
edges = []
|
| 65 |
+
for u, v in G.edges():
|
| 66 |
+
if u in node_to_idx and v in node_to_idx:
|
| 67 |
+
edges.append([node_to_idx[u], node_to_idx[v]])
|
| 68 |
+
edges.append([node_to_idx[v], node_to_idx[u]])
|
| 69 |
+
|
| 70 |
+
edge_index = torch.tensor(edges, dtype=torch.long).t().contiguous()
|
| 71 |
+
if edge_index.numel() == 0:
|
| 72 |
+
edge_index = torch.empty((2, 0), dtype=torch.long)
|
| 73 |
+
|
| 74 |
+
data = Data(x=x, edge_index=edge_index).to(bundle['device'])
|
| 75 |
+
|
| 76 |
+
with torch.no_grad():
|
| 77 |
+
out = bundle['model'](data.x, data.edge_index)
|
| 78 |
+
probs = F.softmax(out, dim=1)[:, 1].cpu().numpy()
|
| 79 |
+
|
| 80 |
+
return {node: float(prob) for node, prob in zip(node_list, probs)}
|
src/ml/gnn_trainer.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Graph Neural Network Trainer
|
| 3 |
+
- Implements GraphSAGE (inductive node classification)
|
| 4 |
+
- using PyTorch Geometric
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn.functional as F
|
| 9 |
+
from torch_geometric.nn import SAGEConv
|
| 10 |
+
from torch_geometric.data import Data
|
| 11 |
+
from sklearn.metrics import roc_auc_score
|
| 12 |
+
from src.config_loader import get_config
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class GraphSAGEFraudDetector(torch.nn.Module):
|
| 16 |
+
def __init__(self, in_channels, hidden_channels):
|
| 17 |
+
super().__init__()
|
| 18 |
+
self.conv1 = SAGEConv(in_channels, hidden_channels)
|
| 19 |
+
self.conv2 = SAGEConv(hidden_channels, hidden_channels // 2)
|
| 20 |
+
self.out = torch.nn.Linear(hidden_channels // 2, 2)
|
| 21 |
+
|
| 22 |
+
def forward(self, x, edge_index):
|
| 23 |
+
x = self.conv1(x, edge_index)
|
| 24 |
+
x = F.relu(x)
|
| 25 |
+
x = F.dropout(x, p=0.3, training=self.training)
|
| 26 |
+
|
| 27 |
+
x = self.conv2(x, edge_index)
|
| 28 |
+
x = F.relu(x)
|
| 29 |
+
x = F.dropout(x, p=0.3, training=self.training)
|
| 30 |
+
|
| 31 |
+
return self.out(x)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def train_gnn(G, feature_df) -> dict:
|
| 35 |
+
cfg = get_config()['ml']
|
| 36 |
+
|
| 37 |
+
node_list = list(G.nodes())
|
| 38 |
+
node_to_idx = {n: i for i, n in enumerate(node_list)}
|
| 39 |
+
|
| 40 |
+
df = feature_df.set_index('account').reindex(node_list).fillna(0)
|
| 41 |
+
|
| 42 |
+
# Exclude non-numeric features and labels
|
| 43 |
+
feature_cols = [c for c in df.columns if c not in ['fraud_flag']]
|
| 44 |
+
|
| 45 |
+
# Standardize features
|
| 46 |
+
x_numpy = df[feature_cols].values
|
| 47 |
+
x_mean = x_numpy.mean(axis=0)
|
| 48 |
+
x_std = x_numpy.std(axis=0)
|
| 49 |
+
x_scaled = (x_numpy - x_mean) / (x_std + 1e-8)
|
| 50 |
+
|
| 51 |
+
x = torch.tensor(x_scaled, dtype=torch.float)
|
| 52 |
+
y = torch.tensor(df['fraud_flag'].values, dtype=torch.long)
|
| 53 |
+
|
| 54 |
+
edges = []
|
| 55 |
+
for u, v in G.edges():
|
| 56 |
+
if u in node_to_idx and v in node_to_idx:
|
| 57 |
+
edges.append([node_to_idx[u], node_to_idx[v]])
|
| 58 |
+
edges.append([node_to_idx[v], node_to_idx[u]]) # Ensure undirected message passing
|
| 59 |
+
|
| 60 |
+
edge_index = torch.tensor(edges, dtype=torch.long).t().contiguous()
|
| 61 |
+
if edge_index.numel() == 0:
|
| 62 |
+
edge_index = torch.empty((2, 0), dtype=torch.long)
|
| 63 |
+
|
| 64 |
+
data = Data(x=x, edge_index=edge_index, y=y)
|
| 65 |
+
|
| 66 |
+
# Extremely simple train/test split via masks
|
| 67 |
+
num_nodes = data.num_nodes
|
| 68 |
+
indices = torch.randperm(num_nodes)
|
| 69 |
+
train_size = int(num_nodes * 0.8)
|
| 70 |
+
|
| 71 |
+
train_mask = torch.zeros(num_nodes, dtype=torch.bool)
|
| 72 |
+
test_mask = torch.zeros(num_nodes, dtype=torch.bool)
|
| 73 |
+
|
| 74 |
+
train_mask[indices[:train_size]] = True
|
| 75 |
+
test_mask[indices[train_size:]] = True
|
| 76 |
+
|
| 77 |
+
data.train_mask = train_mask
|
| 78 |
+
data.test_mask = test_mask
|
| 79 |
+
|
| 80 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 81 |
+
model = GraphSAGEFraudDetector(in_channels=data.num_features, hidden_channels=cfg['gnn_hidden_dim']).to(device)
|
| 82 |
+
data = data.to(device)
|
| 83 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=cfg['gnn_lr'], weight_decay=5e-4)
|
| 84 |
+
criterion = torch.nn.CrossEntropyLoss(weight=torch.tensor([1.0, 10.0]).to(device)) # Handle imbalance
|
| 85 |
+
|
| 86 |
+
model.train()
|
| 87 |
+
for epoch in range(cfg['gnn_epochs']):
|
| 88 |
+
optimizer.zero_grad()
|
| 89 |
+
out = model(data.x, data.edge_index)
|
| 90 |
+
loss = criterion(out[data.train_mask], data.y[data.train_mask])
|
| 91 |
+
loss.backward()
|
| 92 |
+
optimizer.step()
|
| 93 |
+
|
| 94 |
+
model.eval()
|
| 95 |
+
with torch.no_grad():
|
| 96 |
+
out = model(data.x, data.edge_index)
|
| 97 |
+
probs = F.softmax(out, dim=1)[:, 1].cpu().numpy()
|
| 98 |
+
y_test = data.y[data.test_mask].cpu().numpy()
|
| 99 |
+
probs_test = probs[data.test_mask.cpu().numpy()]
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
auc = roc_auc_score(y_test, probs_test)
|
| 103 |
+
except ValueError:
|
| 104 |
+
auc = 0.5 # Single class present in test set
|
| 105 |
+
|
| 106 |
+
# Save the model and standardisation params
|
| 107 |
+
os.makedirs(os.path.dirname(cfg['gnn_model_path']), exist_ok=True)
|
| 108 |
+
torch.save({
|
| 109 |
+
'model_state_dict': model.state_dict(),
|
| 110 |
+
'feature_cols': feature_cols,
|
| 111 |
+
'x_mean': x_mean,
|
| 112 |
+
'x_std': x_std,
|
| 113 |
+
'in_channels': data.num_features,
|
| 114 |
+
'hidden_channels': cfg['gnn_hidden_dim']
|
| 115 |
+
}, cfg['gnn_model_path'])
|
| 116 |
+
|
| 117 |
+
return {
|
| 118 |
+
'gnn_auc_roc': float(auc),
|
| 119 |
+
'nodes_trained': num_nodes,
|
| 120 |
+
'edges_used': edge_index.size(1) // 2
|
| 121 |
+
}
|
src/ml/predictor.py
CHANGED
|
@@ -1,41 +1,87 @@
|
|
| 1 |
"""
|
| 2 |
-
Predictor Module
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
-
import
|
| 6 |
-
import
|
|
|
|
|
|
|
| 7 |
import pandas as pd
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
-
@st.cache_resource
|
| 13 |
def load_model() -> dict:
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
"""
|
| 20 |
-
with open(MODEL_PATH, 'rb') as f:
|
| 21 |
-
bundle = pickle.load(f)
|
| 22 |
-
return bundle
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
Score a single account's fraud risk using the trained model.
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
|
|
|
|
|
|
|
|
|
| 34 |
if row.empty:
|
| 35 |
-
return {'risk_score':
|
| 36 |
|
| 37 |
-
|
| 38 |
-
proba = bundle['model'].predict_proba(
|
| 39 |
risk_score = int(proba * 99)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Predictor Module - Fixed
|
| 3 |
+
- Batch scoring
|
| 4 |
+
- Unknown account warning flag
|
| 5 |
+
- Model version hash check
|
| 6 |
+
- No Streamlit
|
| 7 |
"""
|
| 8 |
+
import os
|
| 9 |
+
import hashlib
|
| 10 |
+
import joblib
|
| 11 |
+
import xgboost as xgb
|
| 12 |
import pandas as pd
|
| 13 |
+
import numpy as np
|
| 14 |
+
from src.config_loader import get_config
|
| 15 |
|
| 16 |
+
_bundle_cache: dict = None
|
| 17 |
+
_model_hash: str = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _compute_model_hash(path: str) -> str:
|
| 21 |
+
h = hashlib.md5()
|
| 22 |
+
with open(path, 'rb') as f:
|
| 23 |
+
h.update(f.read(65536))
|
| 24 |
+
return h.hexdigest()
|
| 25 |
|
| 26 |
|
|
|
|
| 27 |
def load_model() -> dict:
|
| 28 |
+
global _bundle_cache, _model_hash
|
| 29 |
+
cfg = get_config()['ml']
|
| 30 |
+
model_path = cfg['model_path']
|
| 31 |
+
scaler_path = cfg['scaler_path']
|
| 32 |
|
| 33 |
+
if not os.path.exists(model_path):
|
| 34 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
+
current_hash = _compute_model_hash(model_path)
|
| 37 |
+
if _bundle_cache is not None and _model_hash == current_hash:
|
| 38 |
+
return _bundle_cache
|
| 39 |
|
| 40 |
+
model = xgb.XGBClassifier()
|
| 41 |
+
model.load_model(model_path)
|
|
|
|
| 42 |
|
| 43 |
+
meta = joblib.load(scaler_path) if os.path.exists(scaler_path) else {}
|
| 44 |
+
feature_cols = meta.get('feature_cols', cfg['feature_cols'])
|
| 45 |
+
|
| 46 |
+
_bundle_cache = {
|
| 47 |
+
'model': model,
|
| 48 |
+
'feature_cols': feature_cols,
|
| 49 |
+
'model_hash': current_hash,
|
| 50 |
+
}
|
| 51 |
+
_model_hash = current_hash
|
| 52 |
+
return _bundle_cache
|
| 53 |
|
| 54 |
+
|
| 55 |
+
def score_account(account_id: str, feature_df: pd.DataFrame, bundle: dict) -> dict:
|
| 56 |
+
row = feature_df[feature_df['account'] == account_id]
|
| 57 |
if row.empty:
|
| 58 |
+
return {'risk_score': None, 'fraud_probability': None, 'unscored': True}
|
| 59 |
|
| 60 |
+
cols = [c for c in bundle['feature_cols'] if c in row.columns]
|
| 61 |
+
proba = float(bundle['model'].predict_proba(row[cols])[0][1])
|
| 62 |
risk_score = int(proba * 99)
|
| 63 |
+
return {'risk_score': risk_score, 'fraud_probability': proba, 'unscored': False}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def score_accounts_batch(account_ids: list, feature_df: pd.DataFrame, bundle: dict) -> dict:
|
| 67 |
+
"""Vectorised batch scoring — one transform call for all accounts."""
|
| 68 |
+
rows = feature_df[feature_df['account'].isin(account_ids)]
|
| 69 |
+
if rows.empty:
|
| 70 |
+
return {aid: {'risk_score': None, 'fraud_probability': None, 'unscored': True}
|
| 71 |
+
for aid in account_ids}
|
| 72 |
|
| 73 |
+
cols = [c for c in bundle['feature_cols'] if c in rows.columns]
|
| 74 |
+
probas = bundle['model'].predict_proba(rows[cols])[:, 1]
|
| 75 |
+
result = {}
|
| 76 |
+
for i, row in enumerate(rows.itertuples()):
|
| 77 |
+
proba = float(probas[i])
|
| 78 |
+
result[row.account] = {
|
| 79 |
+
'risk_score': int(proba * 99),
|
| 80 |
+
'fraud_probability': proba,
|
| 81 |
+
'unscored': False,
|
| 82 |
+
}
|
| 83 |
+
# Fill missing accounts
|
| 84 |
+
for aid in account_ids:
|
| 85 |
+
if aid not in result:
|
| 86 |
+
result[aid] = {'risk_score': None, 'fraud_probability': None, 'unscored': True}
|
| 87 |
+
return result
|
src/ml/trainer.py
CHANGED
|
@@ -1,123 +1,107 @@
|
|
| 1 |
"""
|
| 2 |
-
Model Trainer Module
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
-
import
|
| 7 |
import xgboost as xgb
|
| 8 |
import pandas as pd
|
| 9 |
import numpy as np
|
| 10 |
-
from sklearn.model_selection import train_test_split
|
| 11 |
-
from sklearn.
|
| 12 |
-
from
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
RANDOM_STATE = 42
|
| 20 |
-
N_ESTIMATORS = 300
|
| 21 |
-
MAX_DEPTH = 6
|
| 22 |
-
LEARNING_RATE = 0.05
|
| 23 |
-
SUBSAMPLE = 0.8
|
| 24 |
-
COLSAMPLE = 0.8
|
| 25 |
-
EARLY_STOPPING = 20
|
| 26 |
-
MODEL_PATH = 'models/xgb_fraud_model.pkl'
|
| 27 |
-
|
| 28 |
-
FEATURE_COLS = [
|
| 29 |
-
'tx_count_total',
|
| 30 |
-
'tx_count_7d',
|
| 31 |
-
'amount_sent_total',
|
| 32 |
-
'amount_sent_7d',
|
| 33 |
-
'amount_received_total',
|
| 34 |
-
'amount_received_7d',
|
| 35 |
-
'forward_ratio',
|
| 36 |
-
'avg_tx_amount',
|
| 37 |
-
'amount_std',
|
| 38 |
-
'in_out_ratio',
|
| 39 |
-
'pagerank_score',
|
| 40 |
-
'in_degree',
|
| 41 |
-
'out_degree',
|
| 42 |
-
'fan_in_ratio',
|
| 43 |
-
'community_id',
|
| 44 |
-
'is_in_cycle',
|
| 45 |
-
'account_age_days',
|
| 46 |
-
'currency_diversity',
|
| 47 |
-
'channel_diversity',
|
| 48 |
-
'bank_diversity',
|
| 49 |
-
]
|
| 50 |
|
| 51 |
|
| 52 |
def train_model(feature_df: pd.DataFrame) -> tuple:
|
| 53 |
-
|
| 54 |
-
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
X = feature_df[FEATURE_COLS].copy()
|
| 60 |
y = feature_df['fraud_flag'].copy()
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
X_train,
|
| 67 |
-
|
| 68 |
-
test_size=TEST_SIZE,
|
| 69 |
-
random_state=RANDOM_STATE,
|
| 70 |
-
stratify=y,
|
| 71 |
)
|
| 72 |
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
model = xgb.XGBClassifier(
|
| 80 |
-
n_estimators=
|
| 81 |
-
max_depth=
|
| 82 |
-
learning_rate=
|
| 83 |
-
subsample=
|
| 84 |
-
colsample_bytree=
|
| 85 |
scale_pos_weight=scale_pos_weight,
|
| 86 |
eval_metric='auc',
|
| 87 |
-
random_state=
|
| 88 |
tree_method='hist',
|
|
|
|
| 89 |
)
|
| 90 |
|
| 91 |
model.fit(
|
| 92 |
-
|
| 93 |
-
eval_set=[(
|
| 94 |
verbose=False,
|
| 95 |
)
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
metrics = {
|
| 104 |
-
'auc_roc': roc_auc_score(
|
| 105 |
-
'f1': f1_score(
|
| 106 |
-
'precision': precision_score(
|
| 107 |
-
'recall': recall_score(
|
| 108 |
-
'confusion_matrix': confusion_matrix(
|
| 109 |
-
'fpr': fpr,
|
| 110 |
-
'tpr': tpr,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
-
#
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
'feature_cols': FEATURE_COLS,
|
| 118 |
-
}
|
| 119 |
-
os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
|
| 120 |
-
with open(MODEL_PATH, 'wb') as f:
|
| 121 |
-
pickle.dump(bundle, f)
|
| 122 |
|
| 123 |
-
return model,
|
|
|
|
| 1 |
"""
|
| 2 |
+
Model Trainer Module - Fixed
|
| 3 |
+
- Proper train/val/holdout split
|
| 4 |
+
- early_stopping_rounds actually passed to model.fit()
|
| 5 |
+
- Native XGBoost .ubj format (not pickle)
|
| 6 |
+
- No StandardScaler for tree models
|
| 7 |
+
- 5-fold cross-validation
|
| 8 |
+
- No Streamlit
|
| 9 |
"""
|
| 10 |
import os
|
| 11 |
+
import joblib
|
| 12 |
import xgboost as xgb
|
| 13 |
import pandas as pd
|
| 14 |
import numpy as np
|
| 15 |
+
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
|
| 16 |
+
from sklearn.metrics import roc_auc_score, f1_score, precision_score, recall_score, confusion_matrix, roc_curve
|
| 17 |
+
from imblearn.over_sampling import SMOTE
|
| 18 |
+
|
| 19 |
+
from src.config_loader import get_config
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _get_feature_cols() -> list:
|
| 23 |
+
return get_config()['ml']['feature_cols']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def train_model(feature_df: pd.DataFrame) -> tuple:
|
| 27 |
+
cfg = get_config()['ml']
|
| 28 |
+
FEATURE_COLS = _get_feature_cols()
|
| 29 |
|
| 30 |
+
# Filter to only columns that exist in the DataFrame
|
| 31 |
+
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
| 32 |
+
X = feature_df[available].copy()
|
|
|
|
| 33 |
y = feature_df['fraud_flag'].copy()
|
| 34 |
|
| 35 |
+
# FIX: proper 70/15/15 split
|
| 36 |
+
X_temp, X_hold, y_temp, y_hold = train_test_split(
|
| 37 |
+
X, y, test_size=cfg['val_size'], random_state=cfg['random_state'], stratify=y
|
| 38 |
+
)
|
| 39 |
+
X_train, X_val, y_train, y_val = train_test_split(
|
| 40 |
+
X_temp, y_temp, test_size=cfg['val_size'], random_state=cfg['random_state'], stratify=y_temp
|
|
|
|
|
|
|
|
|
|
| 41 |
)
|
| 42 |
|
| 43 |
+
pos = (y_train == 1).sum()
|
| 44 |
+
neg = (y_train == 0).sum()
|
| 45 |
+
scale_pos_weight = neg / max(pos, 1)
|
| 46 |
|
| 47 |
+
# Optional SMOTE for severe class imbalance
|
| 48 |
+
if pos / max(len(y_train), 1) < 0.05:
|
| 49 |
+
try:
|
| 50 |
+
sm = SMOTE(random_state=cfg['random_state'])
|
| 51 |
+
X_train, y_train = sm.fit_resample(X_train, y_train)
|
| 52 |
+
except Exception:
|
| 53 |
+
pass
|
| 54 |
|
| 55 |
model = xgb.XGBClassifier(
|
| 56 |
+
n_estimators=cfg['n_estimators'],
|
| 57 |
+
max_depth=cfg['max_depth'],
|
| 58 |
+
learning_rate=cfg['learning_rate'],
|
| 59 |
+
subsample=cfg['subsample'],
|
| 60 |
+
colsample_bytree=cfg['colsample'],
|
| 61 |
scale_pos_weight=scale_pos_weight,
|
| 62 |
eval_metric='auc',
|
| 63 |
+
random_state=cfg['random_state'],
|
| 64 |
tree_method='hist',
|
| 65 |
+
early_stopping_rounds=cfg['early_stopping'], # FIX: actually passed now
|
| 66 |
)
|
| 67 |
|
| 68 |
model.fit(
|
| 69 |
+
X_train, y_train,
|
| 70 |
+
eval_set=[(X_val, y_val)],
|
| 71 |
verbose=False,
|
| 72 |
)
|
| 73 |
|
| 74 |
+
y_pred = model.predict(X_hold)
|
| 75 |
+
y_proba = model.predict_proba(X_hold)[:, 1]
|
| 76 |
+
fpr, tpr, _ = roc_curve(y_hold, y_proba)
|
| 77 |
|
| 78 |
+
# 5-fold CV on full dataset
|
| 79 |
+
cv_model = xgb.XGBClassifier(
|
| 80 |
+
n_estimators=model.best_iteration + 1 if hasattr(model, 'best_iteration') else 100,
|
| 81 |
+
max_depth=cfg['max_depth'],
|
| 82 |
+
learning_rate=cfg['learning_rate'],
|
| 83 |
+
tree_method='hist',
|
| 84 |
+
)
|
| 85 |
+
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=cfg['random_state'])
|
| 86 |
+
cv_scores = cross_val_score(cv_model, X, y, cv=skf, scoring='roc_auc')
|
| 87 |
|
| 88 |
metrics = {
|
| 89 |
+
'auc_roc': float(roc_auc_score(y_hold, y_proba)),
|
| 90 |
+
'f1': float(f1_score(y_hold, y_pred, zero_division=0)),
|
| 91 |
+
'precision': float(precision_score(y_hold, y_pred, zero_division=0)),
|
| 92 |
+
'recall': float(recall_score(y_hold, y_pred, zero_division=0)),
|
| 93 |
+
'confusion_matrix': confusion_matrix(y_hold, y_pred).tolist(),
|
| 94 |
+
'fpr': fpr.tolist(),
|
| 95 |
+
'tpr': tpr.tolist(),
|
| 96 |
+
'cv_auc_mean': float(cv_scores.mean()),
|
| 97 |
+
'cv_auc_std': float(cv_scores.std()),
|
| 98 |
+
'feature_cols': available,
|
| 99 |
+
'n_features': len(available),
|
| 100 |
}
|
| 101 |
|
| 102 |
+
# FIX: save in native XGBoost format + joblib for feature list
|
| 103 |
+
os.makedirs(os.path.dirname(cfg['model_path']), exist_ok=True)
|
| 104 |
+
model.save_model(cfg['model_path'])
|
| 105 |
+
joblib.dump({'feature_cols': available}, cfg['scaler_path'])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
+
return model, metrics
|
src/persistence.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SQLite Persistence Layer — stores and retrieves alert state.
|
| 3 |
+
"""
|
| 4 |
+
import sqlite3
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
from src.config_loader import get_config
|
| 10 |
+
|
| 11 |
+
_db_path: str = None
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _get_db_path() -> str:
|
| 15 |
+
global _db_path
|
| 16 |
+
if _db_path is None:
|
| 17 |
+
_db_path = get_config()['data']['alerts_db_path']
|
| 18 |
+
return _db_path
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_conn() -> sqlite3.Connection:
|
| 22 |
+
conn = sqlite3.connect(_get_db_path())
|
| 23 |
+
conn.row_factory = sqlite3.Row
|
| 24 |
+
return conn
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def init_db() -> None:
|
| 28 |
+
"""Create tables if they don't exist."""
|
| 29 |
+
os.makedirs(os.path.dirname(_get_db_path()), exist_ok=True)
|
| 30 |
+
with _get_conn() as conn:
|
| 31 |
+
conn.execute("""
|
| 32 |
+
CREATE TABLE IF NOT EXISTS alert_state (
|
| 33 |
+
alert_id TEXT PRIMARY KEY,
|
| 34 |
+
seen INTEGER DEFAULT 0,
|
| 35 |
+
dismissed INTEGER DEFAULT 0,
|
| 36 |
+
confirmed INTEGER DEFAULT 0,
|
| 37 |
+
assigned_to TEXT DEFAULT NULL,
|
| 38 |
+
last_updated TEXT,
|
| 39 |
+
notes TEXT DEFAULT ''
|
| 40 |
+
)
|
| 41 |
+
""")
|
| 42 |
+
conn.execute("""
|
| 43 |
+
CREATE TABLE IF NOT EXISTS str_counter (
|
| 44 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 45 |
+
date TEXT NOT NULL,
|
| 46 |
+
counter INTEGER NOT NULL DEFAULT 1
|
| 47 |
+
)
|
| 48 |
+
""")
|
| 49 |
+
conn.commit()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def get_alert_state(alert_id: str) -> dict:
|
| 53 |
+
with _get_conn() as conn:
|
| 54 |
+
row = conn.execute(
|
| 55 |
+
"SELECT * FROM alert_state WHERE alert_id = ?", (alert_id,)
|
| 56 |
+
).fetchone()
|
| 57 |
+
if row is None:
|
| 58 |
+
return {'alert_id': alert_id, 'seen': False, 'dismissed': False, 'confirmed': False}
|
| 59 |
+
return dict(row)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def update_alert_state(alert_id: str, **kwargs) -> None:
|
| 63 |
+
kwargs['last_updated'] = datetime.now().isoformat()
|
| 64 |
+
fields = ', '.join(f"{k} = ?" for k in kwargs)
|
| 65 |
+
values = list(kwargs.values()) + [alert_id]
|
| 66 |
+
with _get_conn() as conn:
|
| 67 |
+
conn.execute(
|
| 68 |
+
f"INSERT OR IGNORE INTO alert_state (alert_id, last_updated) VALUES (?, ?)",
|
| 69 |
+
(alert_id, kwargs['last_updated'])
|
| 70 |
+
)
|
| 71 |
+
conn.execute(f"UPDATE alert_state SET {fields} WHERE alert_id = ?", values)
|
| 72 |
+
conn.commit()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def get_all_alert_states() -> dict:
|
| 76 |
+
"""Returns a dict of alert_id -> state dict for O(1) lookup."""
|
| 77 |
+
with _get_conn() as conn:
|
| 78 |
+
rows = conn.execute("SELECT * FROM alert_state").fetchall()
|
| 79 |
+
return {row['alert_id']: dict(row) for row in rows}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def next_str_reference() -> str:
|
| 83 |
+
"""Generate a sequential STR reference: STR-YYYYMMDD-NNNN."""
|
| 84 |
+
today = datetime.now().strftime('%Y%m%d')
|
| 85 |
+
with _get_conn() as conn:
|
| 86 |
+
row = conn.execute(
|
| 87 |
+
"SELECT counter FROM str_counter WHERE date = ?", (today,)
|
| 88 |
+
).fetchone()
|
| 89 |
+
if row is None:
|
| 90 |
+
conn.execute("INSERT INTO str_counter (date, counter) VALUES (?, 1)", (today,))
|
| 91 |
+
counter = 1
|
| 92 |
+
else:
|
| 93 |
+
counter = row['counter'] + 1
|
| 94 |
+
conn.execute(
|
| 95 |
+
"UPDATE str_counter SET counter = ? WHERE date = ?", (counter, today)
|
| 96 |
+
)
|
| 97 |
+
conn.commit()
|
| 98 |
+
return f"STR-{today}-{counter:04d}"
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def was_recently_suppressed(account: str, typology: str, suppress_hours: int) -> bool:
|
| 102 |
+
"""Return True if this account+typology was seen within suppress_hours."""
|
| 103 |
+
from datetime import timedelta
|
| 104 |
+
cutoff = (datetime.now() - timedelta(hours=suppress_hours)).isoformat()
|
| 105 |
+
with _get_conn() as conn:
|
| 106 |
+
row = conn.execute("""
|
| 107 |
+
SELECT 1 FROM alert_state
|
| 108 |
+
WHERE alert_id LIKE ? AND last_updated > ? AND dismissed = 0
|
| 109 |
+
""", (f"%{account}%", cutoff)).fetchone()
|
| 110 |
+
# Basic suppression via alert_id pattern — production would use a proper lookup
|
| 111 |
+
return row is not None
|
src/reporter.py
CHANGED
|
@@ -1,228 +1,198 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
-
import
|
| 6 |
import pandas as pd
|
| 7 |
-
from datetime import datetime
|
| 8 |
from fpdf import FPDF
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
# Constants
|
| 11 |
-
PAGE_WIDTH = 190
|
| 12 |
-
COL_WIDTHS = [35, 20, 35, 35, 30, 25, 10] # Timestamp, Dir, From, To, Amount, Channel, Flag
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
class STRReport(FPDF):
|
| 16 |
def footer(self):
|
| 17 |
self.set_y(-15)
|
| 18 |
-
self.set_font(
|
| 19 |
-
self.set_text_color(128)
|
| 20 |
-
self.cell(0, 10, f
|
| 21 |
-
self.set_x(10)
|
| 22 |
-
self.cell(0, 10, "Generated by Fund Flow Tracker - IBM AML Prototype", align="L")
|
| 23 |
-
self.set_x(10)
|
| 24 |
-
self.cell(0, 10, "Classification: CONFIDENTIAL", align="R")
|
| 25 |
|
| 26 |
|
| 27 |
def generate_pdf_report(
|
| 28 |
-
|
| 29 |
account_txns: pd.DataFrame,
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
pdf = STRReport()
|
| 37 |
pdf.add_page()
|
| 38 |
-
pdf.set_auto_page_break(auto=True, margin=15)
|
| 39 |
|
| 40 |
-
#
|
| 41 |
-
pdf.set_font(
|
| 42 |
-
pdf.
|
| 43 |
-
|
| 44 |
-
pdf.set_font("helvetica", "B", 12)
|
| 45 |
-
pdf.cell(0, 10, "SUSPICIOUS TRANSACTION REPORT", ln=True, align="C")
|
| 46 |
|
| 47 |
-
|
| 48 |
-
pdf.cell(PAGE_WIDTH / 2, 8, f"STR Ref: STR-{alert.get('alert_id', 'UNKNOWN').upper()}", ln=False, align="L")
|
| 49 |
-
pdf.cell(PAGE_WIDTH / 2, 8, f"Date: {datetime.today().strftime('%d %b %Y')}", ln=True, align="R")
|
| 50 |
|
| 51 |
-
pdf.
|
|
|
|
| 52 |
pdf.ln(5)
|
| 53 |
|
| 54 |
-
#
|
| 55 |
-
pdf.
|
| 56 |
-
pdf.
|
| 57 |
-
|
| 58 |
-
pdf.set_font(
|
| 59 |
-
pdf.
|
| 60 |
-
pdf.cell(0, 6, "Report Type: Suspicious Transaction Report", ln=True)
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
pdf.
|
| 65 |
-
pdf.cell(
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
pdf.cell(0, 6, f"Total Transactions Analysed: {account_stats.get('tx_count', 0)}", ln=True)
|
| 75 |
-
pdf.cell(0, 6, f"Total Sent: {account_stats.get('total_sent', 0):,.0f}", ln=True)
|
| 76 |
-
pdf.cell(0, 6, f"Total Received: {account_stats.get('total_received', 0):,.0f}", ln=True)
|
| 77 |
-
pdf.cell(0, 6, f"Risk Score: {account_stats.get('risk_score', 0)}/99", ln=True)
|
| 78 |
-
pdf.cell(0, 6, f"Fraud Probability: {account_stats.get('fraud_probability', 0)*100:.1f}%", ln=True)
|
| 79 |
-
pdf.ln(5)
|
| 80 |
|
| 81 |
-
#
|
| 82 |
-
pdf.set_font("helvetica", "B", 11)
|
| 83 |
-
pdf.cell(0, 8, "SUSPICIOUS ACTIVITY DESCRIPTION", ln=True)
|
| 84 |
-
|
| 85 |
-
pdf.set_font("helvetica", "", 10)
|
| 86 |
-
pdf.cell(0, 6, f"Typology Detected: {alert.get('typology', 'N/A')}", ln=True)
|
| 87 |
-
pdf.cell(0, 6, f"Risk Tier: {alert.get('risk_tier', 'N/A')}", ln=True)
|
| 88 |
-
|
| 89 |
-
pdf.multi_cell(0, 6, f"Primary Indicator: {alert.get('explanation', '')}")
|
| 90 |
-
|
| 91 |
-
gt_status = "Confirmed" if alert.get('confirmed_fraud') else "Model-flagged (not confirmed in ground truth)"
|
| 92 |
-
pdf.cell(0, 6, f"IBM AML Ground Truth: {gt_status}", ln=True)
|
| 93 |
pdf.ln(5)
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
# Filter for flagged first, else up to 20 total
|
| 100 |
-
flagged_txns = account_txns[account_txns.get('is_laundering', 0) == 1]
|
| 101 |
-
if flagged_txns.empty:
|
| 102 |
-
display_txns = account_txns.head(20)
|
| 103 |
else:
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
pdf.ln()
|
| 112 |
|
| 113 |
-
|
| 114 |
-
acct = alert.get('account', '')
|
| 115 |
-
fill = False
|
| 116 |
-
for _, row in display_txns.iterrows():
|
| 117 |
-
ts_str = row['timestamp'].strftime('%Y-%m-%d %H:%M') if pd.notnull(row['timestamp']) else ''
|
| 118 |
-
src = str(row.get('source', ''))
|
| 119 |
-
tgt = str(row.get('target', ''))
|
| 120 |
-
|
| 121 |
-
# Determine direction relative to subject account
|
| 122 |
-
if src == acct and tgt == acct:
|
| 123 |
-
dir_str = "Self"
|
| 124 |
-
elif src == acct:
|
| 125 |
-
dir_str = "Sent"
|
| 126 |
-
else:
|
| 127 |
-
dir_str = "Received"
|
| 128 |
-
|
| 129 |
-
src_trunc = src[:12] + '...' if len(src) > 15 else src
|
| 130 |
-
tgt_trunc = tgt[:12] + '...' if len(tgt) > 15 else tgt
|
| 131 |
-
amt_str = f"{row.get('amount', 0):,.0f}"
|
| 132 |
-
channel = str(row.get('payment_type', ''))[:10]
|
| 133 |
-
flag = "!" if row.get('is_laundering', 0) == 1 else ""
|
| 134 |
-
|
| 135 |
-
pdf.set_fill_color(245, 245, 245)
|
| 136 |
-
pdf.cell(COL_WIDTHS[0], 6, ts_str, border=1, fill=fill, align='C')
|
| 137 |
-
pdf.cell(COL_WIDTHS[1], 6, dir_str, border=1, fill=fill, align='C')
|
| 138 |
-
pdf.cell(COL_WIDTHS[2], 6, src_trunc, border=1, fill=fill, align='C')
|
| 139 |
-
pdf.cell(COL_WIDTHS[3], 6, tgt_trunc, border=1, fill=fill, align='C')
|
| 140 |
-
pdf.cell(COL_WIDTHS[4], 6, amt_str, border=1, fill=fill, align='R')
|
| 141 |
-
pdf.cell(COL_WIDTHS[5], 6, channel, border=1, fill=fill, align='C')
|
| 142 |
-
pdf.cell(COL_WIDTHS[6], 6, flag, border=1, fill=fill, align='C')
|
| 143 |
-
pdf.ln()
|
| 144 |
-
fill = not fill
|
| 145 |
-
|
| 146 |
pdf.ln(5)
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
pdf.
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
pdf.
|
| 153 |
-
pdf.cell(
|
| 154 |
-
pdf.cell(
|
| 155 |
-
pdf.ln(
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
pdf.set_fill_color(220, 220, 220)
|
| 160 |
-
pdf.
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
pdf.ln()
|
|
|
|
|
|
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
-
#
|
| 182 |
-
|
| 183 |
-
if pdf.get_y() > 230:
|
| 184 |
-
pdf.add_page()
|
| 185 |
-
|
| 186 |
-
pdf.set_font("helvetica", "B", 11)
|
| 187 |
-
pdf.cell(0, 8, "INVESTIGATOR DECLARATION", ln=True)
|
| 188 |
-
|
| 189 |
-
pdf.set_font("helvetica", "", 10)
|
| 190 |
-
pdf.cell(0, 6, "I confirm that the above information is accurate.", ln=True)
|
| 191 |
-
|
| 192 |
-
pdf.set_font("helvetica", "I", 10)
|
| 193 |
-
pdf.set_text_color(200, 0, 0)
|
| 194 |
-
pdf.cell(0, 6, "Tipping-off warning: Do not disclose this report to the subject.", ln=True)
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
pdf.line(10, y, 90, y)
|
| 202 |
-
pdf.line(110, y, 190, y)
|
| 203 |
-
pdf.set_y(y + 2)
|
| 204 |
-
pdf.cell(90, 6, "Investigator Signature", ln=False, align="C")
|
| 205 |
-
pdf.set_x(110)
|
| 206 |
-
pdf.cell(80, 6, "Date", ln=True, align="C")
|
| 207 |
-
|
| 208 |
-
return pdf
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
def get_pdf_bytes(pdf: STRReport) -> bytes:
|
| 212 |
-
"""
|
| 213 |
-
Return the PDF document as bytes, compatible with Streamlit download buttons.
|
| 214 |
-
"""
|
| 215 |
-
return bytes(pdf.output())
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
def get_download_button(pdf_bytes: bytes, alert_id: str) -> None:
|
| 219 |
-
"""
|
| 220 |
-
Render a Streamlit download button for the generated STR report.
|
| 221 |
-
"""
|
| 222 |
-
date_str = datetime.today().strftime('%Y%m%d')
|
| 223 |
-
st.download_button(
|
| 224 |
-
label='📄 Download STR Report',
|
| 225 |
-
data=pdf_bytes,
|
| 226 |
-
file_name=f'STR_{alert_id}_{date_str}.pdf',
|
| 227 |
-
mime='application/pdf'
|
| 228 |
-
)
|
|
|
|
| 1 |
"""
|
| 2 |
+
Reporter Module - Fixed
|
| 3 |
+
- Fixed runtime crash (.get() on DataFrame)
|
| 4 |
+
- Added Transaction Summary Table
|
| 5 |
+
- Reads STR Reference from SQLite
|
| 6 |
+
- No Streamlit
|
| 7 |
"""
|
| 8 |
+
import io
|
| 9 |
import pandas as pd
|
|
|
|
| 10 |
from fpdf import FPDF
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from src.persistence import next_str_reference
|
| 13 |
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
class PDF(FPDF):
|
| 16 |
+
def header(self):
|
| 17 |
+
self.set_font('helvetica', 'B', 15)
|
| 18 |
+
self.set_text_color(0, 85, 150) # Primary Blue
|
| 19 |
+
self.cell(0, 10, 'Suspicious Transaction Report (STR)', border=0, align='C')
|
| 20 |
+
self.ln(15)
|
| 21 |
|
|
|
|
| 22 |
def footer(self):
|
| 23 |
self.set_y(-15)
|
| 24 |
+
self.set_font('helvetica', 'I', 8)
|
| 25 |
+
self.set_text_color(128, 128, 128)
|
| 26 |
+
self.cell(0, 10, f'Page {self.page_no()}', 0, 0, 'C')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def generate_pdf_report(
|
| 30 |
+
account_id: str,
|
| 31 |
account_txns: pd.DataFrame,
|
| 32 |
+
features: dict,
|
| 33 |
+
alerts: list,
|
| 34 |
+
ml_explanation: list,
|
| 35 |
+
gnn_score: float = None,
|
| 36 |
+
) -> bytes:
|
| 37 |
+
pdf = PDF()
|
|
|
|
| 38 |
pdf.add_page()
|
|
|
|
| 39 |
|
| 40 |
+
# Document Header
|
| 41 |
+
pdf.set_font('helvetica', 'B', 12)
|
| 42 |
+
pdf.set_text_color(0, 0, 0)
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
str_ref = next_str_reference()
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
pdf.cell(100, 8, f'STR Reference: {str_ref}', 0, 0)
|
| 47 |
+
pdf.cell(90, 8, f'Date generated: {datetime.now().strftime("%Y-%m-%d %H:%M")}', 0, 1, 'R')
|
| 48 |
pdf.ln(5)
|
| 49 |
|
| 50 |
+
# Risk Summary
|
| 51 |
+
pdf.set_fill_color(240, 240, 240)
|
| 52 |
+
pdf.set_font('helvetica', 'B', 10)
|
| 53 |
+
pdf.cell(0, 8, ' Risk Profile Summary', 0, 1, 'L', fill=True)
|
| 54 |
+
pdf.set_font('helvetica', '', 9)
|
| 55 |
+
pdf.ln(2)
|
|
|
|
| 56 |
|
| 57 |
+
y = pdf.get_y()
|
| 58 |
+
pdf.cell(40, 6, 'Target Entity ID:')
|
| 59 |
+
pdf.set_font('helvetica', 'B', 9)
|
| 60 |
+
pdf.cell(60, 6, str(account_id))
|
| 61 |
+
|
| 62 |
+
pdf.set_font('helvetica', '', 9)
|
| 63 |
+
pdf.cell(40, 6, 'XGBoost Risk Score:')
|
| 64 |
+
pdf.set_font('helvetica', 'B', 9)
|
| 65 |
+
pdf.set_text_color(186, 26, 26) # Error red
|
| 66 |
+
pdf.cell(50, 6, f"{features.get('risk_score', 'N/A')}/100")
|
| 67 |
+
pdf.set_text_color(0, 0, 0)
|
| 68 |
+
pdf.ln()
|
| 69 |
|
| 70 |
+
if gnn_score is not None:
|
| 71 |
+
pdf.set_font('helvetica', '', 9)
|
| 72 |
+
pdf.cell(40, 6, 'GNN Risk Probability:')
|
| 73 |
+
pdf.set_font('helvetica', 'B', 9)
|
| 74 |
+
pdf.cell(60, 6, f"{gnn_score * 100:.1f}%")
|
| 75 |
+
pdf.ln()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
+
# Alerts Section
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
pdf.ln(5)
|
| 79 |
+
pdf.set_font('helvetica', 'B', 10)
|
| 80 |
+
pdf.cell(0, 8, ' Triggered Alerts (Typologies)', 0, 1, 'L', fill=True)
|
| 81 |
+
pdf.ln(2)
|
| 82 |
|
| 83 |
+
if not alerts:
|
| 84 |
+
pdf.set_font('helvetica', 'I', 9)
|
| 85 |
+
pdf.cell(0, 6, 'No deterministic alerts fired for this entity.')
|
| 86 |
+
pdf.ln()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
else:
|
| 88 |
+
for alert in alerts:
|
| 89 |
+
pdf.set_font('helvetica', 'B', 9)
|
| 90 |
+
pdf.cell(0, 6, f"• {alert.get('typology', 'Unknown')}")
|
| 91 |
+
pdf.set_font('helvetica', '', 9)
|
| 92 |
+
pdf.ln()
|
| 93 |
+
pdf.multi_cell(0, 5, f" Details: {alert.get('explanation', '')}")
|
| 94 |
+
pdf.ln(2)
|
|
|
|
| 95 |
|
| 96 |
+
# ML Evidence
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
pdf.ln(5)
|
| 98 |
+
pdf.set_font('helvetica', 'B', 10)
|
| 99 |
+
pdf.cell(0, 8, ' Machine Learning Evidence Drivers', 0, 1, 'L', fill=True)
|
| 100 |
+
pdf.ln(2)
|
| 101 |
+
|
| 102 |
+
pdf.set_font('helvetica', 'B', 8)
|
| 103 |
+
pdf.cell(50, 6, 'Primary Feature', 1)
|
| 104 |
+
pdf.cell(30, 6, 'Entity Value', 1)
|
| 105 |
+
pdf.cell(110, 6, 'Impact Context', 1)
|
| 106 |
+
pdf.ln()
|
| 107 |
|
| 108 |
+
pdf.set_font('helvetica', '', 8)
|
| 109 |
+
for expl in ml_explanation:
|
| 110 |
+
pdf.cell(50, 6, str(expl['feature_name'])[:25], 1)
|
| 111 |
+
pdf.cell(30, 6, f"{expl.get('feature_value', 0):.2f}", 1)
|
| 112 |
+
pdf.cell(110, 6, str(expl['description'])[:60], 1)
|
| 113 |
+
pdf.ln()
|
| 114 |
+
|
| 115 |
+
# Transaction Summary Table
|
| 116 |
+
pdf.ln(10)
|
| 117 |
+
pdf.set_font('helvetica', 'B', 10)
|
| 118 |
+
pdf.cell(0, 8, ' Fundamental Transaction Metrics', 0, 1, 'L', fill=True)
|
| 119 |
+
pdf.ln(2)
|
| 120 |
+
|
| 121 |
+
sent = float(features.get('amount_sent_total', 0))
|
| 122 |
+
recv = float(features.get('amount_received_total', 0))
|
| 123 |
+
tx_count = int(features.get('tx_count_total', 0))
|
| 124 |
+
|
| 125 |
+
pdf.set_font('helvetica', '', 9)
|
| 126 |
+
pdf.cell(60, 6, f"Total Inbound Volume: INR {recv:,.2f}")
|
| 127 |
+
pdf.cell(60, 6, f"Total Outbound Volume: INR {sent:,.2f}")
|
| 128 |
+
pdf.cell(50, 6, f"Transaction Volume (TxC): {tx_count}")
|
| 129 |
+
pdf.ln(10)
|
| 130 |
+
|
| 131 |
+
# Recent Transactions Fixed
|
| 132 |
+
pdf.set_font('helvetica', 'B', 10)
|
| 133 |
+
pdf.cell(0, 8, ' Recent Suspicious Transaction Log', 0, 1, 'L', fill=True)
|
| 134 |
+
pdf.ln(2)
|
| 135 |
+
|
| 136 |
+
if account_txns.empty:
|
| 137 |
+
pdf.set_font('helvetica', 'I', 9)
|
| 138 |
+
pdf.cell(0, 6, 'No transaction data available.')
|
| 139 |
+
else:
|
| 140 |
pdf.set_fill_color(220, 220, 220)
|
| 141 |
+
pdf.set_font('helvetica', 'B', 8)
|
| 142 |
+
col_w = [35, 20, 45, 45, 45]
|
| 143 |
+
|
| 144 |
+
pdf.cell(col_w[0], 6, 'Timestamp', border=1, fill=True)
|
| 145 |
+
pdf.cell(col_w[1], 6, 'Dir', border=1, fill=True)
|
| 146 |
+
pdf.cell(col_w[2], 6, 'Amount (INR)', border=1, align='R', fill=True)
|
| 147 |
+
pdf.cell(col_w[3], 6, 'Counterparty', border=1, fill=True)
|
| 148 |
+
pdf.cell(col_w[4], 6, 'Laundering Flag', border=1, fill=True)
|
| 149 |
pdf.ln()
|
| 150 |
+
|
| 151 |
+
pdf.set_font('helvetica', '', 8)
|
| 152 |
|
| 153 |
+
# Sort and limit
|
| 154 |
+
txns = account_txns.copy()
|
| 155 |
+
if 'timestamp' in txns.columns:
|
| 156 |
+
txns = txns.sort_values('timestamp', ascending=False)
|
| 157 |
+
txns = txns.head(50)
|
| 158 |
+
|
| 159 |
+
# FIX: The .get() crash. Ensure is_laundering exists and is accessed safely.
|
| 160 |
+
if 'is_laundering' not in txns.columns:
|
| 161 |
+
txns['is_laundering'] = 0
|
| 162 |
+
|
| 163 |
+
for _, row in txns.iterrows():
|
| 164 |
+
ts = str(row.get('timestamp', ''))[:16]
|
| 165 |
+
amt = float(row.get('amount', 0))
|
| 166 |
+
is_laundry = str(row['is_laundering'])
|
| 167 |
|
| 168 |
+
src = str(row.get('source', ''))
|
| 169 |
+
tgt = str(row.get('target', ''))
|
| 170 |
+
|
| 171 |
+
if src == account_id:
|
| 172 |
+
direct = 'OUT'
|
| 173 |
+
cparty = tgt
|
| 174 |
+
else:
|
| 175 |
+
direct = 'IN'
|
| 176 |
+
cparty = src
|
| 177 |
+
|
| 178 |
+
pdf.cell(col_w[0], 6, ts, border=1)
|
| 179 |
+
pdf.cell(col_w[1], 6, direct, border=1, align='C')
|
| 180 |
+
pdf.cell(col_w[2], 6, f"{amt:,.2f}", border=1, align='R')
|
| 181 |
+
pdf.cell(col_w[3], 6, cparty[:20], border=1)
|
| 182 |
|
| 183 |
+
if is_laundry == '1' or is_laundry == '1.0':
|
| 184 |
+
pdf.set_text_color(186, 26, 26)
|
| 185 |
+
pdf.cell(col_w[4], 6, 'FLAGGED', border=1)
|
| 186 |
+
pdf.set_text_color(0, 0, 0)
|
| 187 |
+
else:
|
| 188 |
+
pdf.cell(col_w[4], 6, 'Normal', border=1)
|
| 189 |
+
pdf.ln()
|
| 190 |
|
| 191 |
+
# Write to bytes buffer
|
| 192 |
+
pdf_bytes = pdf.output(dest='S')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
+
# fpdf2 dest='S' returns a bytearray directly
|
| 195 |
+
if isinstance(pdf_bytes, str):
|
| 196 |
+
pdf_bytes = pdf_bytes.encode('latin-1')
|
| 197 |
+
|
| 198 |
+
return bytes(pdf_bytes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/state.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class AppState:
|
| 2 |
+
df = None
|
| 3 |
+
node_features = None
|
| 4 |
+
graph = None
|
| 5 |
+
louvain_partition = None
|
| 6 |
+
pagerank_scores = None
|
| 7 |
+
betweenness_scores = None
|
| 8 |
+
full_features = None
|
| 9 |
+
alerts = []
|
| 10 |
+
xgb_bundle = None
|
| 11 |
+
model_metrics = None
|
| 12 |
+
gnn_metrics = None
|