Aniket2006 commited on
Commit
c802754
·
1 Parent(s): 4283c81

feat: Implement caching for API responses and SHAP explanations to improve performance and reduce redundant computations.

Browse files
.gitignore CHANGED
@@ -122,5 +122,5 @@ Thumbs.db
122
 
123
  # Antigravity / Agent files
124
  .agent/
125
- data/checkpoints/
126
 
 
122
 
123
  # Antigravity / Agent files
124
  .agent/
125
+
126
 
api/routes/graph_api.py CHANGED
@@ -2,7 +2,7 @@ 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
 
@@ -13,9 +13,9 @@ async def get_account_graph(
13
  max_nodes: int = Query(100, ge=10, le=500)
14
  ):
15
  G = AppState.graph
16
- fdf = AppState.full_features
17
 
18
- if G is None or fdf is None:
19
  raise HTTPException(status_code=503, detail="Graph data is still loading. Please wait.")
20
 
21
  if account_id not in G:
@@ -25,23 +25,22 @@ async def get_account_graph(
25
 
26
  if subG is None or subG.number_of_nodes() == 0:
27
  return {"nodes": [], "edges": []}
28
-
29
- # Recompute Louvain just for this subgraph to get local communities for coloring
30
- local_communities = compute_louvain(0, subG)
31
-
32
  nodes_data = []
33
- edges_data = []
34
-
35
  for n in subG.nodes():
36
- row = fdf[fdf['account'] == n]
37
- if row.empty:
 
 
 
 
 
38
  risk = 0
39
  pr = 0
40
  is_fraud = False
41
- else:
42
- risk = int(row.iloc[0].get('risk_score', 0))
43
- pr = float(row.iloc[0].get('pagerank_score', 0))
44
- is_fraud = bool(row.iloc[0].get('fraud_flag', 0))
45
 
46
  nodes_data.append({
47
  "data": {
@@ -50,13 +49,13 @@ async def get_account_graph(
50
  "risk_score": risk,
51
  "pagerank": pr,
52
  "is_fraud": is_fraud,
53
- "community": local_communities.get(n, 0),
54
  "is_target": (n == account_id)
55
  }
56
  })
57
 
58
- for u, v, d in subG.edges(data=True):
59
- edges_data.append({
60
  "data": {
61
  "source": u,
62
  "target": v,
@@ -64,9 +63,12 @@ async def get_account_graph(
64
  "tx_count": int(d.get('tx_count', 1)),
65
  "payment_type": d.get('payment_type', 'Unknown'),
66
  }
67
- })
 
 
68
 
69
  return {
70
  "nodes": nodes_data,
71
  "edges": edges_data
72
  }
 
 
2
  from typing import Optional
3
  import networkx as nx
4
  from src.state import AppState
5
+ from src.graph_builder import get_subgraph
6
 
7
  router = APIRouter()
8
 
 
13
  max_nodes: int = Query(100, ge=10, le=500)
14
  ):
15
  G = AppState.graph
16
+ fba = AppState.features_by_account
17
 
18
+ if G is None:
19
  raise HTTPException(status_code=503, detail="Graph data is still loading. Please wait.")
20
 
21
  if account_id not in G:
 
25
 
26
  if subG is None or subG.number_of_nodes() == 0:
27
  return {"nodes": [], "edges": []}
28
+
29
+ # Use pre-computed global partition no per-request Louvain
30
+ global_partition = AppState.louvain_partition or {}
31
+
32
  nodes_data = []
 
 
33
  for n in subG.nodes():
34
+ # O(1) dict lookup instead of per-node DataFrame filter
35
+ feat = fba.get(n)
36
+ if feat:
37
+ risk = int(feat.get('risk_score', 0))
38
+ pr = float(feat.get('pagerank_score', 0))
39
+ is_fraud = bool(feat.get('fraud_flag', 0))
40
+ else:
41
  risk = 0
42
  pr = 0
43
  is_fraud = False
 
 
 
 
44
 
45
  nodes_data.append({
46
  "data": {
 
49
  "risk_score": risk,
50
  "pagerank": pr,
51
  "is_fraud": is_fraud,
52
+ "community": global_partition.get(n, 0),
53
  "is_target": (n == account_id)
54
  }
55
  })
56
 
57
+ edges_data = [
58
+ {
59
  "data": {
60
  "source": u,
61
  "target": v,
 
63
  "tx_count": int(d.get('tx_count', 1)),
64
  "payment_type": d.get('payment_type', 'Unknown'),
65
  }
66
+ }
67
+ for u, v, d in subG.edges(data=True)
68
+ ]
69
 
70
  return {
71
  "nodes": nodes_data,
72
  "edges": edges_data
73
  }
74
+
api/routes/investigation.py CHANGED
@@ -7,22 +7,26 @@ router = APIRouter()
7
 
8
  @router.get("/account/{account_id}")
9
  async def get_account_details(account_id: str, tx_limit: int = Query(default=200, ge=1, le=500)):
10
- fdf = AppState.full_features
11
  df = AppState.df
12
  alerts = AppState.alerts
13
 
14
- if df is None or fdf is None:
15
  raise HTTPException(status_code=503, detail="Data is still loading. Please wait a moment and try again.")
16
 
17
- row = fdf[fdf['account'] == account_id]
18
- if row.empty:
19
  raise HTTPException(status_code=404, detail="Account not found in feature set")
20
 
21
- metrics = row.iloc[0].to_dict()
22
-
23
- # Get relevant transactions — sort by timestamp descending and limit
24
- txns = df[(df['source'] == account_id) | (df['target'] == account_id)].copy()
25
  total_tx_count = len(txns)
 
 
 
 
 
26
  txns = txns.sort_values('timestamp', ascending=False).head(tx_limit)
27
  txns['timestamp'] = txns['timestamp'].astype(str)
28
 
@@ -30,19 +34,20 @@ async def get_account_details(account_id: str, tx_limit: int = Query(default=200
30
  acct_alerts = [a for a in (alerts or []) if a['account'] == account_id]
31
 
32
  # Generate SHAP explanation on the fly
33
- shap_expl = explain_prediction(account_id, AppState.full_features, AppState.xgb_bundle, top_n=5)
 
34
 
35
  return {
36
  "account_id": account_id,
37
  "metrics": {
38
- "total_sent": metrics.get('amount_sent_total', 0),
39
- "total_received": metrics.get('amount_received_total', 0),
40
- "tx_count": metrics.get('tx_count_total', 0),
41
- "risk_score": metrics.get('risk_score', 0),
42
- "gnn_risk_score": metrics.get('gnn_fraud_score', 0),
43
- "fraud_probability": metrics.get('fraud_probability', 0),
44
- "pagerank": metrics.get('pagerank_score', 0),
45
- "betweenness": metrics.get('betweenness_score', 0),
46
  },
47
  "transactions": txns.to_dict(orient="records"),
48
  "total_tx_count": total_tx_count,
 
7
 
8
  @router.get("/account/{account_id}")
9
  async def get_account_details(account_id: str, tx_limit: int = Query(default=200, ge=1, le=500)):
10
+ fba = AppState.features_by_account
11
  df = AppState.df
12
  alerts = AppState.alerts
13
 
14
+ if df is None or not fba or not AppState.startup_ready:
15
  raise HTTPException(status_code=503, detail="Data is still loading. Please wait a moment and try again.")
16
 
17
+ metrics = fba.get(account_id)
18
+ if not metrics:
19
  raise HTTPException(status_code=404, detail="Account not found in feature set")
20
 
21
+ # Fast filtering for transactions
22
+ mask = (df['source'] == account_id) | (df['target'] == account_id)
23
+ txns = df[mask].copy()
 
24
  total_tx_count = len(txns)
25
+
26
+ # Sort only the tail if there are too many (faster than sorting all 50k txns for one account)
27
+ if total_tx_count > tx_limit * 5:
28
+ txns = txns.tail(tx_limit * 5)
29
+
30
  txns = txns.sort_values('timestamp', ascending=False).head(tx_limit)
31
  txns['timestamp'] = txns['timestamp'].astype(str)
32
 
 
34
  acct_alerts = [a for a in (alerts or []) if a['account'] == account_id]
35
 
36
  # Generate SHAP explanation on the fly
37
+ feature_hash = id(AppState.full_features) if AppState.full_features is not None else 0
38
+ shap_expl = explain_prediction(account_id, feature_hash, top_n=5)
39
 
40
  return {
41
  "account_id": account_id,
42
  "metrics": {
43
+ "total_sent": float(metrics.get('amount_sent_total', 0)),
44
+ "total_received": float(metrics.get('amount_received_total', 0)),
45
+ "tx_count": int(metrics.get('tx_count_total', 0)),
46
+ "risk_score": int(metrics.get('risk_score', 0)),
47
+ "gnn_risk_score": float(metrics.get('gnn_fraud_score', 0)),
48
+ "fraud_probability": float(metrics.get('fraud_probability', 0)),
49
+ "pagerank": float(metrics.get('pagerank_score', 0)),
50
+ "betweenness": float(metrics.get('betweenness_score', 0)),
51
  },
52
  "transactions": txns.to_dict(orient="records"),
53
  "total_tx_count": total_tx_count,
api/routes/overview.py CHANGED
@@ -10,7 +10,7 @@ async def get_overview():
10
  alerts = AppState.alerts
11
 
12
  # Guard: if data hasn't loaded yet, return a safe loading response
13
- if df is None or fdf is None:
14
  return {
15
  "loading": True,
16
  "system_health": {},
@@ -21,11 +21,7 @@ async def get_overview():
21
  "channel_stats": [], "graph_stats": {"nodes": 0, "edges": 0}
22
  }
23
 
24
- tot_tx = len(df)
25
- flagged_tx = len(df[df['is_laundering'] == 1])
26
- tot_vol = float(df['amount'].sum())
27
  active_accts = len(fdf)
28
-
29
  crit_alerts = len([a for a in alerts if a['risk_score'] >= 75])
30
 
31
  # Generate Typology Counts
@@ -36,21 +32,9 @@ async def get_overview():
36
  for t in types:
37
  typo_counts[t] = typo_counts.get(t, 0) + 1
38
 
39
- # Channel Stats
40
- channel_counts = df['payment_type'].value_counts()
41
- fraud_channel_counts = df[df['is_laundering'] == 1]['payment_type'].value_counts()
42
-
43
- channel_stats = []
44
- for p_type, count in channel_counts.items():
45
- channel_stats.append({
46
- 'channel': p_type,
47
- 'count': int(count),
48
- 'fraud_count': int(fraud_channel_counts.get(p_type, 0))
49
- })
50
-
51
  # Graph Stats
52
- nodes = AppState.graph.number_of_nodes()
53
- edges = AppState.graph.number_of_edges()
54
 
55
  # System Health Checks
56
  system_health = {
@@ -64,14 +48,14 @@ async def get_overview():
64
 
65
  return {
66
  "system_health": system_health,
67
- "total_transactions": tot_tx,
68
- "flagged_transactions": flagged_tx,
69
- "total_volume": tot_vol,
70
  "active_accounts": active_accts,
71
  "alerts_generated": len(alerts),
72
  "critical_alerts": crit_alerts,
73
  "model_auc": AppState.model_metrics.get('auc_roc', 0.0) if AppState.model_metrics else 0.0,
74
  "typology_counts": typo_counts,
75
- "channel_stats": channel_stats,
76
  "graph_stats": {"nodes": nodes, "edges": edges}
77
  }
 
10
  alerts = AppState.alerts
11
 
12
  # Guard: if data hasn't loaded yet, return a safe loading response
13
+ if df is None or fdf is None or not AppState.startup_ready:
14
  return {
15
  "loading": True,
16
  "system_health": {},
 
21
  "channel_stats": [], "graph_stats": {"nodes": 0, "edges": 0}
22
  }
23
 
 
 
 
24
  active_accts = len(fdf)
 
25
  crit_alerts = len([a for a in alerts if a['risk_score'] >= 75])
26
 
27
  # Generate Typology Counts
 
32
  for t in types:
33
  typo_counts[t] = typo_counts.get(t, 0) + 1
34
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  # Graph Stats
36
+ nodes = AppState.graph.number_of_nodes() if AppState.graph else 0
37
+ edges = AppState.graph.number_of_edges() if AppState.graph else 0
38
 
39
  # System Health Checks
40
  system_health = {
 
48
 
49
  return {
50
  "system_health": system_health,
51
+ "total_transactions": AppState.cached_overview.get('total_transactions', 0),
52
+ "flagged_transactions": AppState.cached_overview.get('flagged_transactions', 0),
53
+ "total_volume": AppState.cached_overview.get('total_volume', 0),
54
  "active_accounts": active_accts,
55
  "alerts_generated": len(alerts),
56
  "critical_alerts": crit_alerts,
57
  "model_auc": AppState.model_metrics.get('auc_roc', 0.0) if AppState.model_metrics else 0.0,
58
  "typology_counts": typo_counts,
59
+ "channel_stats": AppState.cached_channel_stats,
60
  "graph_stats": {"nodes": nodes, "edges": edges}
61
  }
frontend/pages/investigation.html CHANGED
@@ -30,9 +30,10 @@
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()"
@@ -90,13 +91,40 @@
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
 
@@ -323,29 +351,61 @@
323
  if (n.data('is_target')) return '#005596';
324
  if (n.data('risk_score') > 75) return '#ba1a1a';
325
  if (n.data('risk_score') > 40) return '#c6e4f4';
326
- return '#d8dadc';
327
  },
328
  'width': (n) => 20 + (n.data('pagerank') / maxPr) * 40,
329
  'height': (n) => 20 + (n.data('pagerank') / maxPr) * 40,
330
- 'label': 'data(id)',
331
- 'font-size': '8px',
 
 
332
  'color': '#191c1e',
333
  'text-valign': 'top',
334
  'text-halign': 'center',
335
- 'text-margin-y': -2,
336
- 'border-width': (n) => n.data('is_target') ? 3 : 1,
337
- 'border-color': (n) => n.data('is_target') ? '#ffffff' : '#727781',
 
 
 
 
338
  }
339
  },
340
  {
341
  selector: 'edge',
342
  style: {
343
- 'width': (e) => Math.max(1, Math.log10(e.data('amount')) - 3),
344
- 'line-color': (e) => e.data('amount') > 500000 ? '#466270' : '#c1c7d2',
345
- 'target-arrow-color': (e) => e.data('amount') > 500000 ? '#466270' : '#c1c7d2',
346
  'target-arrow-shape': 'triangle',
 
347
  'curve-style': 'bezier',
348
- 'opacity': 0.6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  }
350
  }
351
  ],
@@ -353,7 +413,160 @@
353
  name: 'concentric',
354
  concentric: function (n) { return n.data('is_target') ? 10 : n.data('pagerank'); },
355
  levelWidth: function (nodes) { return maxPr / 4; },
356
- padding: 30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  }
358
  });
359
  };
 
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="10">10 Nodes</option>
34
+ <option value="25" selected>25 Nodes</option>
35
  <option value="50">50 Nodes</option>
36
+ <option value="100">100 Nodes</option>
 
37
  </select>
38
  </div>
39
  <button onclick="window.loadInvestigation()"
 
91
 
92
  <!-- Graph View -->
93
  <section
94
+ class="bg-surface-container-lowest rounded shadow-sm border border-outline-variant/10 flex flex-col h-[600px] relative">
95
  <div
96
  class="px-6 py-3 flex justify-between border-b border-outline-variant/10 z-20 bg-surface-container-lowest">
97
+ <h3 class="font-bold text-sm uppercase flex items-center gap-2 text-error">
98
+ <span class="material-symbols-outlined">warning</span> Suspicious Fund Flow — Pattern Detected
99
  </h3>
100
  </div>
101
+
102
+ <!-- Graph Legend -->
103
+ <div class="absolute top-16 left-6 z-30 bg-surface-container-lowest/90 backdrop-blur border border-outline-variant/50 rounded shadow flex flex-col p-3 text-[10px] pointer-events-none">
104
+ <div class="font-bold uppercase text-on-surface-variant mb-2">Legend</div>
105
+ <div class="flex items-center gap-2 mb-1"><div class="w-3 h-3 rounded-full bg-[#ba1a1a]"></div> <span class="font-bold">High-risk Accounts</span></div>
106
+ <div class="flex items-center gap-2 mb-1"><div class="w-3 h-3 rounded-full bg-[#c6e4f4]"></div> Medium risk</div>
107
+ <div class="flex items-center gap-2 mb-3"><div class="w-3 h-3 rounded-full bg-[#ffffff] border border-outline-variant"></div> Normal</div>
108
+ <div class="border-t border-outline-variant/50 pt-2 mb-1 flex items-center gap-2">
109
+ <div class="w-4 h-1 bg-outline rounded"></div> Thickness = Amount
110
+ </div>
111
+ <div class="flex items-center gap-2">
112
+ <span class="material-symbols-outlined text-[14px] text-outline">arrow_forward</span> Arrow = Direction
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Graph Tooltip Hover -->
117
+ <div id="cy-tooltip" class="hidden absolute z-50 bg-surface-container-highest border border-outline-variant rounded p-2 text-xs shadow-md pointer-events-none font-mono text-on-surface"></div>
118
+
119
+ <!-- Graph Click Details Modal/Card -->
120
+ <div id="cy-click-card" class="hidden absolute top-16 right-4 z-40 bg-surface-container-lowest border border-outline-variant shadow-lg rounded-md w-72 flex flex-col">
121
+ <div class="p-3 border-b border-outline-variant/30 flex justify-between items-center bg-surface-container-low">
122
+ <h4 class="font-bold text-sm text-primary flex items-center gap-1"><span class="material-symbols-outlined text-sm text-primary">info</span> Details</h4>
123
+ <button onclick="document.getElementById('cy-click-card').classList.add('hidden')" class="text-outline hover:text-error transition-colors"><span class="material-symbols-outlined text-sm">close</span></button>
124
+ </div>
125
+ <div id="cy-click-content" class="p-4 flex flex-col gap-2 text-xs"></div>
126
+ </div>
127
+
128
  <div id="cy-container" class="flex-1 w-full h-full bg-[#f2f4f6]/40 relative z-10"></div>
129
  </section>
130
 
 
351
  if (n.data('is_target')) return '#005596';
352
  if (n.data('risk_score') > 75) return '#ba1a1a';
353
  if (n.data('risk_score') > 40) return '#c6e4f4';
354
+ return '#ffffff';
355
  },
356
  'width': (n) => 20 + (n.data('pagerank') / maxPr) * 40,
357
  'height': (n) => 20 + (n.data('pagerank') / maxPr) * 40,
358
+ // Label ONLY target or high-risk nodes to reduce clutter
359
+ 'label': (n) => (n.data('is_target') || n.data('risk_score') > 75) ? n.data('id') : '',
360
+ 'font-size': '10px',
361
+ 'font-weight': 'bold',
362
  'color': '#191c1e',
363
  'text-valign': 'top',
364
  'text-halign': 'center',
365
+ 'text-margin-y': -4,
366
+ 'text-background-color': '#ffffff',
367
+ 'text-background-opacity': 0.7,
368
+ 'text-background-padding': '2px',
369
+ 'border-width': (n) => n.data('is_target') ? 4 : (n.data('risk_score') > 75 ? 2 : 1),
370
+ 'border-color': (n) => n.data('is_target') ? '#c6e4f4' : '#727781',
371
+ 'z-index': (n) => n.data('is_target') || n.data('risk_score') > 75 ? 100 : 10
372
  }
373
  },
374
  {
375
  selector: 'edge',
376
  style: {
377
+ 'width': (e) => Math.max(1, Math.log10(e.data('amount')) - 2),
378
+ 'line-color': '#e2e5ea',
379
+ 'target-arrow-color': '#e2e5ea',
380
  'target-arrow-shape': 'triangle',
381
+ 'arrow-scale': 1.2,
382
  'curve-style': 'bezier',
383
+ 'opacity': 0.8,
384
+ 'z-index': 1
385
+ }
386
+ },
387
+ {
388
+ selector: '.highlighted-edge',
389
+ style: {
390
+ 'line-color': '#ba1a1a',
391
+ 'target-arrow-color': '#ba1a1a',
392
+ 'width': (e) => Math.max(3, Math.log10(e.data('amount')) - 1),
393
+ 'opacity': 1.0,
394
+ 'arrow-scale': 1.5,
395
+ 'z-index': 50,
396
+ 'line-style': 'dashed',
397
+ 'line-dash-pattern': [8, 4],
398
+ 'line-dash-offset': 0
399
+ }
400
+ },
401
+ {
402
+ selector: '.highlighted-node',
403
+ style: {
404
+ 'background-color': '#ba1a1a',
405
+ 'label': 'data(id)',
406
+ 'color': '#ba1a1a',
407
+ 'font-size': '12px',
408
+ 'z-index': 100
409
  }
410
  }
411
  ],
 
413
  name: 'concentric',
414
  concentric: function (n) { return n.data('is_target') ? 10 : n.data('pagerank'); },
415
  levelWidth: function (nodes) { return maxPr / 4; },
416
+ padding: 40
417
+ }
418
+ });
419
+
420
+ // --- Find & Highlight Main Suspicious Path ---
421
+ const targetNode = cy.getElementById(targetId);
422
+ if (targetNode.length > 0) {
423
+ // Find edges with highest amount flowing into/out of target, trace back
424
+ // Simplify: Highlight any edge connected to a high risk node, or the top 3 highest amount edges
425
+ let highEdges = cy.edges().sort((a, b) => b.data('amount') - a.data('amount')).slice(0, 5);
426
+ // Also highlight paths between high risk nodes
427
+ let highNodes = cy.nodes().filter(n => n.data('risk_score') > 75 || n.data('is_target'));
428
+
429
+ let highlightCollection = cy.collection();
430
+
431
+ highNodes.forEach(node => {
432
+ let neighbors = node.neighborhood('edge');
433
+ // get max amount edge
434
+ if(neighbors.length > 0) {
435
+ let sorted = neighbors.sort((a,b) => b.data('amount') - a.data('amount'));
436
+ highlightCollection = highlightCollection.union(sorted[0]);
437
+ if(sorted.length > 1) highlightCollection = highlightCollection.union(sorted[1]);
438
+ }
439
+ highlightCollection = highlightCollection.union(node);
440
+ });
441
+
442
+ highlightCollection = highlightCollection.union(highEdges);
443
+
444
+ // Apply highlight classes
445
+ highlightCollection.edges().addClass('highlighted-edge');
446
+
447
+ // Show sequence tooltip on central node
448
+ targetNode.addClass('highlighted-node');
449
+ }
450
+
451
+ // --- Edge Animation Loop ---
452
+ let offset = 0;
453
+ function animateEdges() {
454
+ if (!cy || cy.destroyed()) return;
455
+ offset -= 1; // Moves dash pattern forward
456
+ cy.edges('.highlighted-edge').style('line-dash-offset', offset);
457
+ requestAnimationFrame(animateEdges);
458
+ }
459
+ animateEdges();
460
+
461
+ // --- Hover Interactivity ---
462
+ const tooltip = document.getElementById('cy-tooltip');
463
+
464
+ cy.on('mouseover', 'node', function(e) {
465
+ const data = e.target.data();
466
+ let extra = "";
467
+ if (data.is_target) extra = "<div class='text-error mt-1 font-bold'>Target Account</div>";
468
+ else if (data.risk_score > 75) extra = "<div class='text-error mt-1 font-bold'>High Risk (Detected)</div>";
469
+ else if (data.pagerank > maxPr / 2) extra = "<div class='text-tertiary mt-1 font-bold'>High Centrality (Hub)</div>";
470
+
471
+ tooltip.innerHTML = `
472
+ <div class="border-b border-outline-variant/30 pb-1 mb-1 font-bold">Node ${data.id}</div>
473
+ <div class="grid grid-cols-2 gap-x-4 gap-y-1">
474
+ <span class="text-on-surface-variant">Risk Score</span><span class="${data.risk_score > 75 ? 'text-error font-bold' : ''}">${data.risk_score}</span>
475
+ <span class="text-on-surface-variant">PageRank</span><span>${data.pagerank.toFixed(4)}</span>
476
+ </div>
477
+ ${extra}
478
+ `;
479
+ tooltip.classList.remove('hidden');
480
+ });
481
+
482
+ cy.on('mousemove', 'node', function(e) {
483
+ tooltip.style.left = (e.renderedPosition.x + 15) + 'px';
484
+ tooltip.style.top = (e.renderedPosition.y + 15 + 48) + 'px'; // +48 offset for header
485
+ });
486
+
487
+ cy.on('mouseout', 'node', function(e) {
488
+ tooltip.classList.add('hidden');
489
+ });
490
+
491
+ cy.on('mouseover', 'edge', function(e) {
492
+ const data = e.target.data();
493
+ tooltip.innerHTML = `<strong>Type:</strong> ${data.payment_type}<br><strong>Amt:</strong> ₹${formatCurrency(data.amount)}<br><strong>Tx(s):</strong> ${data.tx_count}`;
494
+ tooltip.classList.remove('hidden');
495
+ });
496
+
497
+ cy.on('mousemove', 'edge', function(e) {
498
+ tooltip.style.left = (e.renderedPosition.x + 15) + 'px';
499
+ tooltip.style.top = (e.renderedPosition.y + 15 + 48) + 'px';
500
+ });
501
+
502
+ cy.on('mouseout', 'edge', function(e) {
503
+ tooltip.classList.add('hidden');
504
+ });
505
+
506
+ // --- Click Interactivity ---
507
+ const clickCard = document.getElementById('cy-click-card');
508
+ const clickContent = document.getElementById('cy-click-content');
509
+
510
+ cy.on('tap', 'node', function(e) {
511
+ const data = e.target.data();
512
+ clickContent.innerHTML = `
513
+ <div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
514
+ <span class="font-bold text-on-surface-variant">Account</span>
515
+ <span class="font-mono text-primary font-bold">${data.id}</span>
516
+ </div>
517
+ <div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
518
+ <span class="font-bold text-on-surface-variant">Risk Score</span>
519
+ <span class="${data.risk_score > 75 ? 'text-error font-bold' : ''}">${data.risk_score}</span>
520
+ </div>
521
+ <div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
522
+ <span class="font-bold text-on-surface-variant">Is Fraud</span>
523
+ <span>${data.is_fraud ? '<span class="text-error font-bold">Yes</span>' : 'No'}</span>
524
+ </div>
525
+ <div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
526
+ <span class="font-bold text-on-surface-variant">Community</span>
527
+ <span>${data.community}</span>
528
+ </div>
529
+ <div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-2">
530
+ <span class="font-bold text-on-surface-variant">PageRank</span>
531
+ <span class="font-mono">${data.pagerank.toFixed(5)}</span>
532
+ </div>
533
+ <button onclick="document.getElementById('inv-acct-input').value='${data.id}'; window.loadInvestigation(); document.getElementById('cy-click-card').classList.add('hidden');" class="w-full py-1.5 bg-primary/10 hover:bg-primary/20 text-primary font-bold rounded flex justify-center items-center gap-1 transition-colors">
534
+ <span class="material-symbols-outlined text-[14px]">search</span> Inspect Account
535
+ </button>
536
+ `;
537
+ clickCard.classList.remove('hidden');
538
+ });
539
+
540
+ cy.on('tap', 'edge', function(e) {
541
+ const data = e.target.data();
542
+ clickContent.innerHTML = `
543
+ <div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
544
+ <span class="font-bold text-on-surface-variant">Source</span>
545
+ <span class="font-mono">${data.source}</span>
546
+ </div>
547
+ <div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
548
+ <span class="font-bold text-on-surface-variant">Target</span>
549
+ <span class="font-mono">${data.target}</span>
550
+ </div>
551
+ <div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
552
+ <span class="font-bold text-on-surface-variant">Amount</span>
553
+ <span class="font-mono text-error font-bold">₹${formatCurrency(data.amount)}</span>
554
+ </div>
555
+ <div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
556
+ <span class="font-bold text-on-surface-variant">Channel</span>
557
+ <span class="uppercase">${data.payment_type}</span>
558
+ </div>
559
+ <div class="flex justify-between border-b border-outline-variant/10 pb-1">
560
+ <span class="font-bold text-on-surface-variant">Num Tx</span>
561
+ <span>${data.tx_count}</span>
562
+ </div>
563
+ `;
564
+ clickCard.classList.remove('hidden');
565
+ });
566
+
567
+ cy.on('tap', function(e) {
568
+ if (e.target === cy) {
569
+ clickCard.classList.add('hidden');
570
  }
571
  });
572
  };
server.py CHANGED
@@ -5,6 +5,7 @@ FastAPI Server Entrypoint
5
  - Frontend polls /api/status to show loading progress
6
  """
7
  import os
 
8
  import threading
9
  import uvicorn
10
  from fastapi import FastAPI
@@ -46,6 +47,44 @@ class Checkpoint:
46
  except Exception as e:
47
  print(f" ✗ Failed to save {name} checkpoint: {e}")
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  # ── FastAPI App ──────────────────────────────────────────────
51
  app = FastAPI(title="Fund Flow Tracker API")
@@ -101,20 +140,27 @@ def background_init():
101
  AppState.df = pd.DataFrame({'source':[], 'target':[], 'amount':[], 'timestamp':[], 'is_laundering':[], 'payment_type':[]})
102
  AppState.node_features = None
103
 
104
- # Step 2: Build Graph (with checkpoint)
105
  _update_status("Building transaction graph...", 2)
106
- cached_graph = Checkpoint.load('graph')
107
- if cached_graph is not None:
108
- AppState.graph = cached_graph
109
- print(f" ✓ Loaded graph from checkpoint ({cached_graph.number_of_nodes()} nodes, {cached_graph.number_of_edges()} edges).")
110
  else:
111
- try:
112
- AppState.graph = build_graph(AppState.df)
113
- Checkpoint.save('graph', AppState.graph)
114
- except Exception as e:
115
- print(f" Graph build failed: {e}")
116
- AppState.startup_status['errors'].append(f"Graph: {e}")
117
- AppState.graph = nx.MultiDiGraph()
 
 
 
 
 
 
 
 
118
 
119
  gid = 1
120
 
@@ -251,8 +297,15 @@ def background_init():
251
  acct_ids = AppState.full_features['account'].tolist() if 'account' in AppState.full_features.columns else []
252
  if AppState.xgb_bundle and acct_ids:
253
  scores = score_accounts_batch(acct_ids, AppState.full_features, AppState.xgb_bundle)
254
- AppState.full_features['risk_score'] = AppState.full_features['account'].map(lambda a: scores[a]['risk_score'] if a in scores else 0)
255
- AppState.full_features['fraud_probability'] = AppState.full_features['account'].map(lambda a: scores[a]['fraud_probability'] if a in scores else 0.0)
 
 
 
 
 
 
 
256
  else:
257
  AppState.full_features['risk_score'] = 0
258
  AppState.full_features['fraud_probability'] = 0.0
@@ -265,7 +318,7 @@ def background_init():
265
  try:
266
  if hasattr(AppState.full_features, 'columns') and 'account' in AppState.full_features.columns:
267
  gnn_scores = predict_gnn_score(AppState.graph, AppState.full_features)
268
- AppState.full_features['gnn_fraud_score'] = AppState.full_features['account'].map(lambda a: gnn_scores.get(a, 0.0))
269
  else:
270
  AppState.full_features['gnn_fraud_score'] = 0.0
271
  except Exception as e:
@@ -273,6 +326,44 @@ def background_init():
273
  if hasattr(AppState.full_features, 'columns') and 'gnn_fraud_score' not in AppState.full_features.columns:
274
  AppState.full_features['gnn_fraud_score'] = 0.0
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  # DONE
277
  AppState.startup_ready = True
278
  AppState.startup_status['current_step'] = 'Ready'
 
5
  - Frontend polls /api/status to show loading progress
6
  """
7
  import os
8
+ import time
9
  import threading
10
  import uvicorn
11
  from fastapi import FastAPI
 
47
  except Exception as e:
48
  print(f" ✗ Failed to save {name} checkpoint: {e}")
49
 
50
+ @classmethod
51
+ def save_graph_parquet(cls, graph: nx.MultiDiGraph):
52
+ """Save graph as a Parquet edge list — much smaller and faster to reload."""
53
+ os.makedirs(cls.DIR, exist_ok=True)
54
+ path = os.path.join(cls.DIR, "graph_edges.parquet")
55
+ rows = []
56
+ for u, v, d in graph.edges(data=True):
57
+ rows.append({
58
+ 'source': u, 'target': v,
59
+ 'amount': d.get('amount', 0),
60
+ 'payment_type': d.get('payment_type', ''),
61
+ 'is_laundering': d.get('is_laundering', 0),
62
+ 'timestamp': d.get('timestamp', ''),
63
+ })
64
+ pd.DataFrame(rows).to_parquet(path, index=False)
65
+ print(f" ✓ Saved graph edge list ({len(rows):,} edges) to Parquet.")
66
+
67
+ @classmethod
68
+ def load_graph_parquet(cls) -> nx.MultiDiGraph:
69
+ """Rebuild graph from Parquet edge list — 10-20x faster than pickle."""
70
+ path = os.path.join(cls.DIR, "graph_edges.parquet")
71
+ if not os.path.exists(path):
72
+ return None
73
+ t0 = time.time()
74
+ df = pd.read_parquet(path)
75
+ G = nx.MultiDiGraph()
76
+ for row in df.itertuples(index=False):
77
+ G.add_edge(
78
+ row.source, row.target,
79
+ amount=row.amount,
80
+ payment_type=row.payment_type,
81
+ is_laundering=int(row.is_laundering),
82
+ timestamp=str(row.timestamp),
83
+ )
84
+ elapsed = time.time() - t0
85
+ print(f" ✓ Rebuilt graph from Parquet in {elapsed:.1f}s ({G.number_of_nodes():,} nodes, {G.number_of_edges():,} edges).")
86
+ return G
87
+
88
 
89
  # ── FastAPI App ──────────────────────────────────────────────
90
  app = FastAPI(title="Fund Flow Tracker API")
 
140
  AppState.df = pd.DataFrame({'source':[], 'target':[], 'amount':[], 'timestamp':[], 'is_laundering':[], 'payment_type':[]})
141
  AppState.node_features = None
142
 
143
+ # Step 2: Build Graph (fast Parquet edge-list, fallback to old pickle)
144
  _update_status("Building transaction graph...", 2)
145
+ parquet_graph = Checkpoint.load_graph_parquet()
146
+ if parquet_graph is not None:
147
+ AppState.graph = parquet_graph
 
148
  else:
149
+ # Try legacy pickle checkpoint
150
+ cached_graph = Checkpoint.load('graph')
151
+ if cached_graph is not None:
152
+ AppState.graph = cached_graph
153
+ print(f" Loaded graph from pickle ({cached_graph.number_of_nodes()} nodes, {cached_graph.number_of_edges()} edges).")
154
+ # Migrate: save as Parquet for next time
155
+ Checkpoint.save_graph_parquet(cached_graph)
156
+ else:
157
+ try:
158
+ AppState.graph = build_graph(AppState.df)
159
+ Checkpoint.save_graph_parquet(AppState.graph)
160
+ except Exception as e:
161
+ print(f" ✗ Graph build failed: {e}")
162
+ AppState.startup_status['errors'].append(f"Graph: {e}")
163
+ AppState.graph = nx.MultiDiGraph()
164
 
165
  gid = 1
166
 
 
297
  acct_ids = AppState.full_features['account'].tolist() if 'account' in AppState.full_features.columns else []
298
  if AppState.xgb_bundle and acct_ids:
299
  scores = score_accounts_batch(acct_ids, AppState.full_features, AppState.xgb_bundle)
300
+ # Vectorised: build scores DataFrame and merge no lambdas
301
+ scores_df = pd.DataFrame([
302
+ {'account': a, 'risk_score': s['risk_score'] or 0, 'fraud_probability': s['fraud_probability'] or 0.0}
303
+ for a, s in scores.items()
304
+ ])
305
+ AppState.full_features = AppState.full_features.drop(columns=['risk_score', 'fraud_probability'], errors='ignore')
306
+ AppState.full_features = AppState.full_features.merge(scores_df, on='account', how='left')
307
+ AppState.full_features['risk_score'] = AppState.full_features['risk_score'].fillna(0).astype(int)
308
+ AppState.full_features['fraud_probability'] = AppState.full_features['fraud_probability'].fillna(0.0)
309
  else:
310
  AppState.full_features['risk_score'] = 0
311
  AppState.full_features['fraud_probability'] = 0.0
 
318
  try:
319
  if hasattr(AppState.full_features, 'columns') and 'account' in AppState.full_features.columns:
320
  gnn_scores = predict_gnn_score(AppState.graph, AppState.full_features)
321
+ AppState.full_features['gnn_fraud_score'] = AppState.full_features['account'].map(gnn_scores).fillna(0.0)
322
  else:
323
  AppState.full_features['gnn_fraud_score'] = 0.0
324
  except Exception as e:
 
326
  if hasattr(AppState.full_features, 'columns') and 'gnn_fraud_score' not in AppState.full_features.columns:
327
  AppState.full_features['gnn_fraud_score'] = 0.0
328
 
329
+ # Build pre-indexed lookup cache for O(1) API access
330
+ if hasattr(AppState.full_features, 'columns') and 'account' in AppState.full_features.columns:
331
+ AppState.features_by_account = AppState.full_features.set_index('account').to_dict('index')
332
+ else:
333
+ AppState.features_by_account = {}
334
+
335
+ # Pre-compute overview stats so /api/overview doesn't recompute each call
336
+ if AppState.df is not None and len(AppState.df) > 0:
337
+ channel_counts = AppState.df['payment_type'].value_counts()
338
+ fraud_channel_counts = AppState.df[AppState.df['is_laundering'] == 1]['payment_type'].value_counts()
339
+ AppState.cached_channel_stats = [
340
+ {'channel': p, 'count': int(c), 'fraud_count': int(fraud_channel_counts.get(p, 0))}
341
+ for p, c in channel_counts.items()
342
+ ]
343
+ AppState.cached_overview = {
344
+ 'total_transactions': len(AppState.df),
345
+ 'flagged_transactions': int((AppState.df['is_laundering'] == 1).sum()),
346
+ 'total_volume': float(AppState.df['amount'].sum()),
347
+ }
348
+ else:
349
+ AppState.cached_channel_stats = []
350
+ AppState.cached_overview = {}
351
+
352
+ if AppState.alerts:
353
+ crit_count = 0
354
+ typo_counts = {}
355
+ for a in AppState.alerts:
356
+ if a['risk_score'] >= 75:
357
+ crit_count += 1
358
+ types = [t.strip() for t in a['typology'].split(',')]
359
+ for t in types:
360
+ typo_counts[t] = typo_counts.get(t, 0) + 1
361
+ AppState.cached_crit_alerts = crit_count
362
+ AppState.cached_typo_counts = typo_counts
363
+ else:
364
+ AppState.cached_crit_alerts = 0
365
+ AppState.cached_typo_counts = {}
366
+
367
  # DONE
368
  AppState.startup_ready = True
369
  AppState.startup_status['current_step'] = 'Ready'
src/ml/explainer.py CHANGED
@@ -46,21 +46,29 @@ def _get_explainer(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
- if not bundle:
 
 
 
 
 
56
  return []
57
-
58
- row = feature_df[feature_df['account'] == account_id]
59
- if row.empty:
60
  return []
61
 
62
- feature_cols = [c for c in bundle['feature_cols'] if c in row.columns]
63
- X = row[feature_cols]
 
64
 
65
  explainer = _get_explainer(bundle['model'])
66
  shap_values = explainer.shap_values(X)
 
46
  return _explainer_cache
47
 
48
 
49
+ from functools import lru_cache
50
+
51
+ @lru_cache(maxsize=100)
52
  def explain_prediction(
53
  account_id: str,
54
+ feature_df_hash: int, # Added hash so cache clears if features change
 
55
  top_n: int = 5,
56
  ) -> list[dict]:
57
+ # Use the global state directly to avoid hashing the whole DataFrame
58
+ from src.state import AppState
59
+ fba = AppState.features_by_account
60
+ bundle = AppState.xgb_bundle
61
+
62
+ if not bundle or not fba:
63
  return []
64
+
65
+ feat = fba.get(account_id)
66
+ if not feat:
67
  return []
68
 
69
+ feature_cols = [c for c in bundle['feature_cols'] if c in feat]
70
+ # Build single-row DataFrame for SHAP
71
+ X = pd.DataFrame([feat])[feature_cols]
72
 
73
  explainer = _get_explainer(bundle['model'])
74
  shap_values = explainer.shap_values(X)
src/ml/predictor.py CHANGED
@@ -64,7 +64,7 @@ def score_account(account_id: str, feature_df: pd.DataFrame, bundle: dict) -> di
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}
@@ -72,14 +72,14 @@ def score_accounts_batch(account_ids: list, feature_df: pd.DataFrame, bundle: di
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:
 
64
 
65
 
66
  def score_accounts_batch(account_ids: list, feature_df: pd.DataFrame, bundle: dict) -> dict:
67
+ """Vectorised batch scoring — fully numpy, no Python loops."""
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}
 
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
+
76
+ # Vectorised: build result directly from numpy arrays — no itertuples
77
+ accounts = rows['account'].values
78
+ risk_scores = (probas * 99).astype(int)
79
+ result = {
80
+ acct: {'risk_score': int(rs), 'fraud_probability': float(p), 'unscored': False}
81
+ for acct, rs, p in zip(accounts, risk_scores, probas)
82
+ }
83
  # Fill missing accounts
84
  for aid in account_ids:
85
  if aid not in result:
src/state.py CHANGED
@@ -10,6 +10,13 @@ class AppState:
10
  xgb_bundle = None
11
  model_metrics = None
12
  gnn_metrics = None
 
 
 
 
 
 
 
13
 
14
  # Startup tracking — lets the frontend show a loading screen
15
  startup_ready = False
@@ -19,3 +26,4 @@ class AppState:
19
  'total_steps': 10,
20
  'errors': []
21
  }
 
 
10
  xgb_bundle = None
11
  model_metrics = None
12
  gnn_metrics = None
13
+
14
+ # Pre-built caches for fast API responses
15
+ features_by_account = {} # account_id -> feature dict (O(1) lookup)
16
+ cached_channel_stats = [] # pre-computed channel breakdown
17
+ cached_overview = {} # pre-computed overview numbers
18
+ cached_typo_counts = {} # pre-computed alerts typologies
19
+ cached_crit_alerts = 0 # pre-computed critical count
20
 
21
  # Startup tracking — lets the frontend show a loading screen
22
  startup_ready = False
 
26
  'total_steps': 10,
27
  'errors': []
28
  }
29
+