abinazebinoy commited on
Commit
876e7e0
·
2 Parent(s): c9d02a2971c184

release: complete — Evidence Connection Map + Deep Investigation

Browse files

6-layer recursive investigation engine.
Click any graph node to see WHY connected, source, and next leads.
Evidence panel slides in from right with Investigate Further button.
/investigate/{id} and /connection-map endpoints live.

ai/connection_mapper.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
3
+
4
+ from datetime import datetime
5
+ from loguru import logger
6
+
7
+
8
+ RELATIONSHIP_LABELS = {
9
+ "DIRECTOR_OF": {"label":"director","strength":"strong","color":"#FF9933"},
10
+ "WON_CONTRACT": {"label":"contract","strength":"strong","color":"#138808"},
11
+ "AWARDED_BY": {"label":"awarded_by","strength":"strong","color":"#000080"},
12
+ "FLAGS": {"label":"audit_flag","strength":"strong","color":"#DC3545"},
13
+ "MEMBER_OF": {"label":"party","strength":"medium","color":"#6F42C1"},
14
+ "AUDITS": {"label":"audits","strength":"strong","color":"#DC3545"},
15
+ "MENTIONED_IN": {"label":"mentioned","strength":"weak","color":"#6C757D"},
16
+ }
17
+
18
+
19
+ class ConnectionMapper:
20
+
21
+ def __init__(self, driver=None):
22
+ self.driver = driver
23
+
24
+ def find_paths(self, entity_a: str, entity_b: str,
25
+ max_hops: int = 5) -> dict:
26
+ logger.info(f"[ConnectionMapper] {entity_a} → {entity_b} (max {max_hops} hops)")
27
+
28
+ if not self.driver:
29
+ return {"status":"no_database","paths":[],"path_count":0}
30
+
31
+ with self.driver.session() as session:
32
+ rows = session.run(
33
+ """
34
+ MATCH path = shortestPath(
35
+ (a {id:$a})-[*1..5]-(b {id:$b})
36
+ )
37
+ RETURN [n IN nodes(path) | {id:n.id, name:n.name,
38
+ label:labels(n)[0]}] AS nodes,
39
+ [r IN relationships(path) | type(r)] AS rels,
40
+ length(path) AS hops
41
+ LIMIT 10
42
+ """,
43
+ a=entity_a, b=entity_b
44
+ ).data()
45
+
46
+ paths = []
47
+ for row in rows:
48
+ nodes = row.get("nodes", [])
49
+ rels = row.get("rels", [])
50
+ hops = row.get("hops", 0)
51
+
52
+ steps = []
53
+ for i, rel in enumerate(rels):
54
+ rel_meta = RELATIONSHIP_LABELS.get(rel, {
55
+ "label": rel.lower(), "strength":"unknown","color":"#6C757D"
56
+ })
57
+ steps.append({
58
+ "from": nodes[i].get("name") or nodes[i].get("id"),
59
+ "from_id": nodes[i].get("id"),
60
+ "from_type": nodes[i].get("label"),
61
+ "relationship":rel,
62
+ "rel_label": rel_meta["label"],
63
+ "strength": rel_meta["strength"],
64
+ "color": rel_meta["color"],
65
+ "to": nodes[i+1].get("name") or nodes[i+1].get("id"),
66
+ "to_id": nodes[i+1].get("id"),
67
+ "to_type": nodes[i+1].get("label"),
68
+ "why": self._explain_relationship(rel),
69
+ "how": "Graph shortest path algorithm (Neo4j)",
70
+ "source": self._get_source(rel),
71
+ })
72
+
73
+ confidence = "HIGH" if hops <= 2 else "MEDIUM" if hops <= 3 else "LOW"
74
+
75
+ paths.append({
76
+ "hops": hops,
77
+ "confidence": confidence,
78
+ "steps": steps,
79
+ "path_summary": " → ".join(
80
+ n.get("name") or n.get("id","?") for n in nodes
81
+ ),
82
+ })
83
+
84
+ logger.success(f"[ConnectionMapper] Found {len(paths)} paths")
85
+
86
+ return {
87
+ "entity_a": entity_a,
88
+ "entity_b": entity_b,
89
+ "path_count": len(paths),
90
+ "paths": paths,
91
+ "mapped_at": datetime.now().isoformat(),
92
+ }
93
+
94
+ def get_node_evidence(self, entity_id: str) -> dict:
95
+ logger.info(f"[ConnectionMapper] Evidence for node {entity_id}")
96
+
97
+ if not self.driver:
98
+ return {"entity_id": entity_id, "edges": [], "status": "no_database"}
99
+
100
+ with self.driver.session() as session:
101
+ rows = session.run(
102
+ """
103
+ MATCH (n {id:$id})-[r]-(m)
104
+ RETURN type(r) AS rel, labels(m)[0] AS node_type,
105
+ m.name AS name, m.id AS mid,
106
+ m.state AS state
107
+ LIMIT 30
108
+ """,
109
+ id=entity_id
110
+ ).data()
111
+ entity_row = session.run(
112
+ "MATCH (n {id:$id}) RETURN n.name AS name, labels(n)[0] AS label, "
113
+ "n.risk_score AS score, n.risk_level AS level, n.state AS state,"
114
+ "n.party AS party",
115
+ id=entity_id
116
+ ).single()
117
+
118
+ edges = []
119
+ for row in rows:
120
+ rel = row.get("rel","")
121
+ rel_meta = RELATIONSHIP_LABELS.get(rel, {
122
+ "label":rel.lower(),"strength":"unknown","color":"#6C757D"
123
+ })
124
+ edges.append({
125
+ "relationship": rel,
126
+ "rel_label": rel_meta["label"],
127
+ "strength": rel_meta["strength"],
128
+ "color": rel_meta["color"],
129
+ "connected_to": row.get("name"),
130
+ "connected_id": row.get("mid"),
131
+ "node_type": row.get("node_type"),
132
+ "why": self._explain_relationship(rel),
133
+ "how": "Official government record",
134
+ "source": self._get_source(rel),
135
+ "next_leads": self._suggest_leads(rel, row.get("node_type","")),
136
+ })
137
+
138
+ entity_info = {}
139
+ if entity_row:
140
+ entity_info = {
141
+ "name": entity_row.get("name"),
142
+ "label": entity_row.get("label"),
143
+ "risk_score": entity_row.get("score"),
144
+ "risk_level": entity_row.get("level"),
145
+ "state": entity_row.get("state"),
146
+ "party": entity_row.get("party"),
147
+ }
148
+
149
+ return {
150
+ "entity_id": entity_id,
151
+ "entity_info": entity_info,
152
+ "edge_count": len(edges),
153
+ "edges": edges,
154
+ "fetched_at": datetime.now().isoformat(),
155
+ }
156
+
157
+ def _explain_relationship(self, rel: str) -> str:
158
+ explanations = {
159
+ "DIRECTOR_OF": "Entity holds directorship in this company per MCA21 filings.",
160
+ "WON_CONTRACT": "Company received government contract via GeM procurement.",
161
+ "AWARDED_BY": "Contract was awarded by this ministry/department.",
162
+ "FLAGS": "CAG audit report flagged this scheme or department.",
163
+ "MEMBER_OF": "Entity is a registered member of this political party.",
164
+ "AUDITS": "CAG conducted audit of this ministry.",
165
+ "MENTIONED_IN": "Entity is mentioned in this document.",
166
+ }
167
+ return explanations.get(rel, f"Relationship type: {rel}")
168
+
169
+ def _get_source(self, rel: str) -> str:
170
+ sources = {
171
+ "DIRECTOR_OF": "Ministry of Corporate Affairs — MCA21",
172
+ "WON_CONTRACT": "Government e-Marketplace (GeM)",
173
+ "AWARDED_BY": "Government e-Marketplace (GeM)",
174
+ "FLAGS": "Comptroller and Auditor General (CAG)",
175
+ "MEMBER_OF": "Election Commission of India",
176
+ "AUDITS": "Comptroller and Auditor General (CAG)",
177
+ }
178
+ return sources.get(rel, "BharatGraph Knowledge Graph")
179
+
180
+ def _suggest_leads(self, rel: str, node_type: str) -> list:
181
+ leads = {
182
+ "DIRECTOR_OF": ["Check other companies this director controls",
183
+ "Look for contracts from ministries this entity oversees"],
184
+ "WON_CONTRACT": ["Check if company was incorporated recently",
185
+ "Compare contract value vs company paid-up capital"],
186
+ "FLAGS": ["Read full CAG report for specific irregularity amount",
187
+ "Check if irregularity led to prosecution"],
188
+ "MEMBER_OF": ["Check donation records from this party",
189
+ "Check policy changes benefiting entity after joining party"],
190
+ }
191
+ return leads.get(rel, ["Expand graph one more hop", "Check timeline alignment"])
ai/deep_investigator.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
3
+
4
+ from datetime import datetime
5
+ from loguru import logger
6
+
7
+
8
+ LAYER_NAMES = [
9
+ "direct_evidence",
10
+ "relationship_expansion",
11
+ "pattern_investigation",
12
+ "timeline_investigation",
13
+ "network_influence",
14
+ "evidence_validation",
15
+ ]
16
+
17
+
18
+ class DeepInvestigator:
19
+
20
+ def __init__(self, driver=None):
21
+ self.driver = driver
22
+
23
+ def investigate(self, entity_id: str, entity_name: str = "") -> dict:
24
+ logger.info(f"[DeepInvestigator] 6-layer analysis: {entity_name or entity_id}")
25
+
26
+ if not self.driver:
27
+ return {
28
+ "entity_id": entity_id,
29
+ "entity_name": entity_name,
30
+ "layers": [],
31
+ "total_items": 0,
32
+ "status": "no_database",
33
+ "investigated_at": datetime.now().isoformat(),
34
+ }
35
+
36
+ layers = []
37
+ with self.driver.session() as session:
38
+ if not entity_name:
39
+ row = session.run(
40
+ "MATCH (n {id:$id}) RETURN n.name AS name",
41
+ id=entity_id
42
+ ).single()
43
+ entity_name = (row["name"] if row else entity_id) or entity_id
44
+
45
+ for i, fn in enumerate([
46
+ lambda s: self._layer_1_direct(entity_id, s),
47
+ lambda s: self._layer_2_expansion(entity_id, s),
48
+ lambda s: self._layer_3_patterns(entity_id, s),
49
+ lambda s: self._layer_4_timeline(entity_id, s),
50
+ lambda s: self._layer_5_influence(entity_id, s),
51
+ lambda s: self._layer_6_validation(entity_id, entity_name, s),
52
+ ], 1):
53
+ try:
54
+ result = fn(session)
55
+ layers.append(result)
56
+ logger.info(f"[DeepInvestigator] Layer {i}: {result.get('count',0)} items")
57
+ except Exception as e:
58
+ logger.warning(f"[DeepInvestigator] Layer {i} failed: {e}")
59
+ layers.append({"layer": i, "name": LAYER_NAMES[i-1],
60
+ "count": 0, "error": str(e)})
61
+
62
+ total = sum(l.get("count", 0) for l in layers)
63
+ logger.success(f"[DeepInvestigator] Complete: {total} items across 6 layers")
64
+
65
+ return {
66
+ "entity_id": entity_id,
67
+ "entity_name": entity_name,
68
+ "layer_count": len(layers),
69
+ "total_items": total,
70
+ "layers": layers,
71
+ "investigated_at": datetime.now().isoformat(),
72
+ }
73
+
74
+ def _layer_1_direct(self, entity_id: str, session) -> dict:
75
+ rows = session.run(
76
+ """
77
+ MATCH (n {id:$id})-[r]-(m)
78
+ RETURN type(r) AS rel, labels(m)[0] AS node_type,
79
+ m.name AS name, m.id AS mid
80
+ LIMIT 50
81
+ """,
82
+ id=entity_id
83
+ ).data()
84
+ items = [{"type":"direct_relationship","relationship":r.get("rel"),
85
+ "connected_to":r.get("name"),"node_type":r.get("node_type"),
86
+ "node_id":r.get("mid"),
87
+ "why":"Directly linked in knowledge graph",
88
+ "how":"Graph traversal — single hop",
89
+ "confidence":"HIGH","source":"Neo4j graph database"}
90
+ for r in rows]
91
+ return {"layer":1,"name":"Direct Evidence","count":len(items),"items":items}
92
+
93
+ def _layer_2_expansion(self, entity_id: str, session) -> dict:
94
+ rows = session.run(
95
+ """
96
+ MATCH (n {id:$id})-[:DIRECTOR_OF]->(:Company)<-[:DIRECTOR_OF]-(p)
97
+ WHERE p.id <> $id
98
+ RETURN p.name AS shared_person, p.id AS pid,
99
+ labels(p)[0] AS ptype
100
+ LIMIT 20
101
+ """,
102
+ id=entity_id
103
+ ).data()
104
+ items = [{"type":"shared_directorship",
105
+ "connected_to":r.get("shared_person"),
106
+ "node_id":r.get("pid"),
107
+ "why":"Shares company directorship with target entity",
108
+ "how":"Two-hop graph traversal via shared company",
109
+ "confidence":"HIGH","source":"MCA21 Director Records"}
110
+ for r in rows]
111
+ return {"layer":2,"name":"Relationship Expansion","count":len(items),"items":items}
112
+
113
+ def _layer_3_patterns(self, entity_id: str, session) -> dict:
114
+ rows = session.run(
115
+ """
116
+ MATCH (n {id:$id})-[:DIRECTOR_OF]->(c:Company)
117
+ -[:WON_CONTRACT]->(ct:Contract)
118
+ WITH ct.buyer_org AS buyer, count(ct) AS cnt,
119
+ sum(ct.amount_crore) AS total
120
+ WHERE cnt >= 2
121
+ RETURN buyer, cnt, total
122
+ ORDER BY cnt DESC LIMIT 10
123
+ """,
124
+ id=entity_id
125
+ ).data()
126
+ items = [{"type":"contract_concentration",
127
+ "buyer":r.get("buyer"),
128
+ "contract_count":r.get("cnt"),
129
+ "total_crore":r.get("total"),
130
+ "why":f"Repeated contracts ({r.get('cnt')}) from same buyer",
131
+ "how":"Pattern analysis — frequency count",
132
+ "confidence":"HIGH","source":"GeM Procurement Data"}
133
+ for r in rows]
134
+ return {"layer":3,"name":"Pattern Investigation","count":len(items),"items":items}
135
+
136
+ def _layer_4_timeline(self, entity_id: str, session) -> dict:
137
+ rows = session.run(
138
+ """
139
+ MATCH (n {id:$id})-[:DIRECTOR_OF|WON_CONTRACT*1..2]->(ct:Contract)
140
+ RETURN ct.order_date AS date, ct.amount_crore AS amount,
141
+ ct.buyer_org AS buyer, ct.order_id AS contract_id
142
+ ORDER BY ct.order_date LIMIT 30
143
+ """,
144
+ id=entity_id
145
+ ).data()
146
+ items = [{"type":"timeline_event","date":r.get("date"),
147
+ "event":"Contract Awarded",
148
+ "amount_crore":r.get("amount"),
149
+ "buyer":r.get("buyer"),
150
+ "why":"Temporal sequence event in entity history",
151
+ "how":"Timeline reconstruction from contract dates",
152
+ "confidence":"HIGH","source":"GeM"}
153
+ for r in rows if r.get("date")]
154
+ return {"layer":4,"name":"Timeline Investigation","count":len(items),"items":items}
155
+
156
+ def _layer_5_influence(self, entity_id: str, session) -> dict:
157
+ row = session.run(
158
+ """
159
+ MATCH (n {id:$id})
160
+ RETURN n.betweenness_centrality AS bc, n.pagerank AS pr
161
+ """,
162
+ id=entity_id
163
+ ).single()
164
+ items = []
165
+ if row:
166
+ bc = row.get("bc") or 0
167
+ if bc > 0.05:
168
+ items.append({
169
+ "type":"network_gatekeeper",
170
+ "metric":"betweenness_centrality",
171
+ "value":round(bc, 4),
172
+ "why":f"Betweenness centrality {bc:.4f} — entity connects clusters",
173
+ "how":"NetworkX betweenness_centrality algorithm",
174
+ "confidence":"HIGH","source":"Graph Analytics",
175
+ })
176
+ return {"layer":5,"name":"Network Influence","count":len(items),"items":items}
177
+
178
+ def _layer_6_validation(self, entity_id: str, entity_name: str,
179
+ session) -> dict:
180
+ items = []
181
+ row = session.run(
182
+ "MATCH (n:Politician {id:$id}) RETURN n.state AS state",
183
+ id=entity_id
184
+ ).single()
185
+ if row and row.get("state"):
186
+ sebi = session.run(
187
+ """
188
+ MATCH (s:RegulatoryOrder)
189
+ WHERE toLower(s.title) CONTAINS toLower($name)
190
+ AND s.state IS NOT NULL AND s.state <> $state
191
+ RETURN s.state AS sebi_state, s.title AS title LIMIT 1
192
+ """,
193
+ name=entity_name, state=row["state"]
194
+ ).single()
195
+ if sebi:
196
+ items.append({
197
+ "type":"conflicting_disclosure",
198
+ "why":"State differs between MCA and regulatory filing",
199
+ "how":"Cross-source contradiction detection",
200
+ "confidence":"MEDIUM","source":"MCA vs SEBI",
201
+ })
202
+ return {"layer":6,"name":"Evidence Validation","count":len(items),"items":items}
203
+
204
+
205
+ if __name__ == "__main__":
206
+ print("=" * 55)
207
+ print("BharatGraph — Deep Investigator Test (offline)")
208
+ print("=" * 55)
209
+ d = DeepInvestigator(driver=None)
210
+ r = d.investigate("test_001", "Test Entity")
211
+ print(f" Status: {r.get('status')}")
212
+ print(f" Layers: {r.get('layer_count', 0)}")
213
+ print("\nDone!")
api/main.py CHANGED
@@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
8
  from loguru import logger
9
 
10
  from api.dependencies import get_driver, close_driver
11
- from api.routes import search, profile, graph, risk, multilingual, export, admin
12
  from api.models import HealthResponse, StatsResponse
13
 
14
 
@@ -48,7 +48,8 @@ app.include_router(graph.router, tags=["Graph"])
48
  app.include_router(risk.router, tags=["Risk"])
49
  app.include_router(multilingual.router,tags=["Multilingual"])
50
  app.include_router(export.router, tags=["Export"])
51
- app.include_router(admin.router, tags=["Admin"])
 
52
 
53
 
54
  @app.get("/health", response_model=HealthResponse)
 
8
  from loguru import logger
9
 
10
  from api.dependencies import get_driver, close_driver
11
+ from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation
12
  from api.models import HealthResponse, StatsResponse
13
 
14
 
 
48
  app.include_router(risk.router, tags=["Risk"])
49
  app.include_router(multilingual.router,tags=["Multilingual"])
50
  app.include_router(export.router, tags=["Export"])
51
+ app.include_router(admin.router, tags=["Admin"])
52
+ app.include_router(investigation.router, tags=["Investigation"])
53
 
54
 
55
  @app.get("/health", response_model=HealthResponse)
api/routes/investigation.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
3
+
4
+ from datetime import datetime
5
+ from typing import Optional
6
+ from fastapi import APIRouter, Depends, Query
7
+ from loguru import logger
8
+
9
+ from api.dependencies import get_db
10
+ from ai.deep_investigator import DeepInvestigator
11
+ from ai.connection_mapper import ConnectionMapper
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ @router.get("/investigate/{entity_id}")
17
+ def deep_investigate(entity_id: str, driver=Depends(get_db)):
18
+ logger.info(f"[Investigation] Deep investigate: {entity_id}")
19
+ investigator = DeepInvestigator(driver=driver)
20
+ with driver.session() as session:
21
+ row = session.run(
22
+ "MATCH (n {id:$id}) RETURN n.name AS name",
23
+ id=entity_id
24
+ ).single()
25
+ name = (row["name"] if row else entity_id) or entity_id
26
+ return investigator.investigate(entity_id, name)
27
+
28
+
29
+ @router.get("/connection-map")
30
+ def connection_map(
31
+ a: str = Query(..., description="Entity A ID"),
32
+ b: str = Query(..., description="Entity B ID"),
33
+ driver=Depends(get_db),
34
+ ):
35
+ logger.info(f"[Investigation] Connection map: {a} → {b}")
36
+ mapper = ConnectionMapper(driver=driver)
37
+ return mapper.find_paths(a, b)
38
+
39
+
40
+ @router.get("/node-evidence/{entity_id}")
41
+ def node_evidence(entity_id: str, driver=Depends(get_db)):
42
+ logger.info(f"[Investigation] Node evidence: {entity_id}")
43
+ mapper = ConnectionMapper(driver=driver)
44
+ return mapper.get_node_evidence(entity_id)
frontend/index.html CHANGED
@@ -119,6 +119,7 @@
119
  <script src="js/api.js"></script>
120
  <script src="js/components.js"></script>
121
  <script src="js/graph.js"></script>
 
122
  <script src="js/app.js"></script>
123
  <script>
124
  Api.health().then(() => {
 
119
  <script src="js/api.js"></script>
120
  <script src="js/components.js"></script>
121
  <script src="js/graph.js"></script>
122
+ <script src="js/evidence_panel.js"></script>
123
  <script src="js/app.js"></script>
124
  <script>
125
  Api.health().then(() => {
frontend/js/api.js CHANGED
@@ -53,6 +53,8 @@ const Api = {
53
 
54
  verifyHash: (hash) => Api._request(`/verify/${hash}`),
55
 
 
 
56
  createFeedSocket: () => {
57
  const wsUrl = API_BASE.replace(/^http/, "ws") + "/ws/feed";
58
  return new WebSocket(wsUrl);
 
53
 
54
  verifyHash: (hash) => Api._request(`/verify/${hash}`),
55
 
56
+ nodeEvidence: (entityId) => Api._request(`/node-evidence/${entityId}`),
57
+
58
  createFeedSocket: () => {
59
  const wsUrl = API_BASE.replace(/^http/, "ws") + "/ws/feed";
60
  return new WebSocket(wsUrl);
frontend/js/evidence_panel.js ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const EvidencePanel = {
2
+ _visible: false,
3
+ _container: null,
4
+
5
+ init: () => {
6
+ const panel = document.createElement("div");
7
+ panel.id = "evidence-panel";
8
+ panel.style.cssText = `
9
+ position: fixed; top: 0; right: -420px; width: 400px; height: 100vh;
10
+ background: var(--bg-secondary); border-left: 1px solid var(--border-color);
11
+ box-shadow: -4px 0 24px rgba(0,0,0,0.3); z-index: 500;
12
+ overflow-y: auto; transition: right 0.3s ease; padding: 0;
13
+ `;
14
+ document.body.appendChild(panel);
15
+ EvidencePanel._container = panel;
16
+
17
+ const overlay = document.createElement("div");
18
+ overlay.id = "evidence-overlay";
19
+ overlay.style.cssText = `
20
+ position:fixed;inset:0;background:rgba(0,0,0,0.4);
21
+ z-index:499;display:none;
22
+ `;
23
+ overlay.addEventListener("click", EvidencePanel.close);
24
+ document.body.appendChild(overlay);
25
+ },
26
+
27
+ open: async (entityId, entityName) => {
28
+ if (!EvidencePanel._container) EvidencePanel.init();
29
+ EvidencePanel._container.innerHTML = `
30
+ <div style="padding:20px">
31
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
32
+ <span style="font-size:11px;font-weight:600;letter-spacing:.1em;
33
+ text-transform:uppercase;color:var(--color-saffron)">
34
+ Evidence Panel
35
+ </span>
36
+ <button onclick="EvidencePanel.close()"
37
+ style="background:none;border:none;color:var(--text-muted);
38
+ font-size:20px;cursor:pointer;line-height:1">×</button>
39
+ </div>
40
+ <h2 style="font-size:18px;font-weight:700;color:var(--text-primary);margin-bottom:4px">
41
+ ${entityName || entityId}
42
+ </h2>
43
+ <div style="font-size:12px;color:var(--text-muted);margin-bottom:20px">
44
+ Entity ID: ${entityId}
45
+ </div>
46
+ <div id="panel-content">
47
+ <div class="spinner" style="margin:40px auto"></div>
48
+ </div>
49
+ </div>
50
+ `;
51
+ EvidencePanel._container.style.right = "0";
52
+ document.getElementById("evidence-overlay").style.display = "block";
53
+ EvidencePanel._visible = true;
54
+
55
+ try {
56
+ const data = await Api.nodeEvidence(entityId);
57
+ EvidencePanel._render(data, entityId);
58
+ } catch (err) {
59
+ document.getElementById("panel-content").innerHTML = `
60
+ <p style="color:var(--color-risk-very-high);font-size:13px">
61
+ Could not load evidence. Ensure API is running.
62
+ </p>`;
63
+ }
64
+ },
65
+
66
+ _render: (data, entityId) => {
67
+ const info = data.entity_info || {};
68
+ const edges = data.edges || [];
69
+ const el = document.getElementById("panel-content");
70
+ if (!el) return;
71
+
72
+ const riskColour = {
73
+ "LOW":"var(--color-risk-low)","MODERATE":"#856404",
74
+ "HIGH":"var(--color-risk-high)","VERY_HIGH":"var(--color-risk-very-high)"
75
+ }[info.risk_level] || "var(--text-muted)";
76
+
77
+ el.innerHTML = `
78
+ ${info.risk_score != null ? `
79
+ <div style="padding:12px;background:var(--bg-tertiary);border-radius:8px;margin-bottom:16px">
80
+ <div style="font-size:11px;text-transform:uppercase;letter-spacing:.08em;
81
+ color:var(--text-muted);margin-bottom:6px">Structural Risk</div>
82
+ <div style="display:flex;align-items:center;gap:8px">
83
+ <div style="flex:1;height:6px;background:var(--bg-primary);border-radius:3px;overflow:hidden">
84
+ <div style="width:${info.risk_score}%;height:100%;background:${riskColour};border-radius:3px"></div>
85
+ </div>
86
+ <span style="font-weight:700;font-size:13px;color:${riskColour}">
87
+ ${info.risk_score}/100
88
+ </span>
89
+ </div>
90
+ </div>` : ""}
91
+
92
+ <div style="margin-bottom:20px">
93
+ <div style="font-size:12px;font-weight:600;text-transform:uppercase;
94
+ letter-spacing:.08em;color:var(--text-muted);margin-bottom:8px">
95
+ Connections (${edges.length})
96
+ </div>
97
+ ${edges.slice(0, 15).map(edge => `
98
+ <div style="padding:10px 12px;margin-bottom:8px;background:var(--bg-tertiary);
99
+ border-radius:6px;border-left:3px solid ${edge.color || '#666'}">
100
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
101
+ <span style="font-size:10px;font-weight:600;text-transform:uppercase;
102
+ color:${edge.color};letter-spacing:.06em">${edge.rel_label}</span>
103
+ <span style="font-size:10px;color:var(--text-muted)">
104
+ ${edge.strength === "strong" ? "●" : edge.strength === "medium" ? "◐" : "○"}
105
+ ${edge.strength}
106
+ </span>
107
+ </div>
108
+ <div style="font-size:13px;font-weight:500;color:var(--text-primary);margin-bottom:2px">
109
+ ${edge.connected_to || edge.connected_id}
110
+ </div>
111
+ <div style="font-size:11px;color:var(--text-secondary);margin-bottom:4px">
112
+ <strong>WHY:</strong> ${edge.why}
113
+ </div>
114
+ <div style="font-size:10px;color:var(--text-muted)">
115
+ <strong>SOURCE:</strong> ${edge.source}
116
+ </div>
117
+ ${edge.next_leads && edge.next_leads.length ? `
118
+ <div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border-color)">
119
+ <div style="font-size:10px;color:var(--color-saffron);font-weight:600;margin-bottom:3px">
120
+ NEXT LEADS
121
+ </div>
122
+ ${edge.next_leads.map(l => `
123
+ <div style="font-size:10px;color:var(--text-muted);margin-bottom:2px">→ ${l}</div>
124
+ `).join("")}
125
+ </div>` : ""}
126
+ </div>
127
+ `).join("")}
128
+ </div>
129
+
130
+ <button onclick="EvidencePanel._investigate('${entityId}')"
131
+ class="btn btn--primary" style="width:100%;margin-bottom:8px">
132
+ Investigate Further (6 Layers)
133
+ </button>
134
+ <button onclick="Router.navigate('/entity/${entityId}');EvidencePanel.close();"
135
+ class="btn btn--secondary" style="width:100%">
136
+ Open Full Dossier
137
+ </button>
138
+ `;
139
+ },
140
+
141
+ _investigate: async (entityId) => {
142
+ const btn = document.querySelector("#panel-content .btn--primary");
143
+ if (btn) { btn.textContent = "Investigating..."; btn.disabled = true; }
144
+ try {
145
+ const data = await Api._request(`/investigate/${entityId}`);
146
+ const el = document.getElementById("panel-content");
147
+ if (!el) return;
148
+ const total = data.total_items || 0;
149
+ el.innerHTML += `
150
+ <div style="margin-top:16px;padding:12px;background:var(--bg-tertiary);
151
+ border-radius:8px;border:1px solid var(--color-saffron)">
152
+ <div style="font-size:11px;font-weight:600;color:var(--color-saffron);
153
+ margin-bottom:8px">6-LAYER INVESTIGATION COMPLETE</div>
154
+ ${(data.layers || []).map(l => `
155
+ <div style="display:flex;justify-content:space-between;
156
+ font-size:11px;margin-bottom:4px">
157
+ <span style="color:var(--text-secondary)">Layer ${l.layer}: ${l.name}</span>
158
+ <span style="color:var(--text-primary);font-weight:600">${l.count} items</span>
159
+ </div>
160
+ `).join("")}
161
+ <div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border-color);
162
+ font-size:12px;font-weight:600;color:var(--text-primary)">
163
+ Total: ${total} investigative items
164
+ </div>
165
+ </div>
166
+ `;
167
+ } catch (err) {
168
+ if (btn) { btn.textContent = "Investigate Further"; btn.disabled = false; }
169
+ }
170
+ },
171
+
172
+ close: () => {
173
+ if (EvidencePanel._container)
174
+ EvidencePanel._container.style.right = "-420px";
175
+ const overlay = document.getElementById("evidence-overlay");
176
+ if (overlay) overlay.style.display = "none";
177
+ EvidencePanel._visible = false;
178
+ },
179
+ };
180
+
181
+ window.EvidencePanel = EvidencePanel;
frontend/js/graph.js CHANGED
@@ -108,9 +108,13 @@ const GraphRenderer = {
108
  })
109
  )
110
  .on("click", (event, d) => {
111
- event.stopPropagation();
112
- if (window.Router) Router.navigate(`/entity/${d.id}`);
113
- });
 
 
 
 
114
 
115
  node.append("circle")
116
  .attr("r", 22)
 
108
  })
109
  )
110
  .on("click", (event, d) => {
111
+ event.stopPropagation();
112
+ if (window.EvidencePanel) {
113
+ EvidencePanel.open(d.id, d.name || d.id);
114
+ } else if (window.Router) {
115
+ Router.navigate(`/entity/${d.id}`);
116
+ }
117
+ })
118
 
119
  node.append("circle")
120
  .attr("r", 22)