Spaces:
Sleeping
Sleeping
release: complete — Evidence Connection Map + Deep Investigation
Browse files6-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 +191 -0
- ai/deep_investigator.py +213 -0
- api/main.py +3 -2
- api/routes/investigation.py +44 -0
- frontend/index.html +1 -0
- frontend/js/api.js +2 -0
- frontend/js/evidence_panel.js +181 -0
- frontend/js/graph.js +7 -3
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,
|
|
|
|
| 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 |
-
|
| 112 |
-
|
| 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)
|