abinazebinoy commited on
Commit
1df487e
·
2 Parent(s): 13839a7e728463

fix: all 26 bugs fixed

Browse files
ai/biography/timeline_builder.py CHANGED
@@ -120,7 +120,7 @@ class TimelineBuilder:
120
  RETURN a.year AS year, a.title AS title,
121
  a.irregularity_amount_crore AS amount
122
  LIMIT 10
123
- """, name=entity_id
124
  ).data()
125
  return [{
126
  "date": str(r.get("year","")) + "-01-01",
@@ -167,7 +167,7 @@ class TimelineBuilder:
167
  WHERE toLower(pr.title) CONTAINS toLower($name)
168
  RETURN pr.date AS date, pr.title AS title
169
  ORDER BY pr.date DESC LIMIT 10
170
- """, name=entity_id
171
  ).data()
172
  return [{
173
  "date": r.get("date",""),
 
120
  RETURN a.year AS year, a.title AS title,
121
  a.irregularity_amount_crore AS amount
122
  LIMIT 10
123
+ """, name=entity_name # BUG-18 FIX: was entity_id — audit events search by name not ID
124
  ).data()
125
  return [{
126
  "date": str(r.get("year","")) + "-01-01",
 
167
  WHERE toLower(pr.title) CONTAINS toLower($name)
168
  RETURN pr.date AS date, pr.title AS title
169
  ORDER BY pr.date DESC LIMIT 10
170
+ """, name=entity_name # BUG-18 FIX: was entity_id — audit events search by name not ID
171
  ).data()
172
  return [{
173
  "date": r.get("date",""),
ai/deep_investigator.py CHANGED
@@ -36,47 +36,49 @@ class DeepInvestigator:
36
  return self._offline_response(entity_id, entity_name)
37
 
38
  layers = []
39
- with self.driver.session() as session:
40
- # Resolve name if not given
41
- if not entity_name:
42
- row = session.run(
43
- "MATCH (n {id:$id}) RETURN n.name AS name, labels(n)[0] AS label",
44
- id=entity_id
45
- ).single()
46
- entity_name = (row["name"] if row else entity_id) or entity_id
47
- entity_label = (row["label"] if row else "Unknown") or "Unknown"
48
- else:
49
- row = session.run(
50
- "MATCH (n {id:$id}) RETURN labels(n)[0] AS label", id=entity_id
51
- ).single()
52
- entity_label = (row["label"] if row else "Unknown") or "Unknown"
53
-
54
- # BUG-04 FIX: default-argument capture prevents lambda closure over loop var
55
- layer_fns = [
56
- (1, lambda s, _id=entity_id: self._layer_1_direct(_id, s)),
57
- (2, lambda s, _id=entity_id: self._layer_2_expansion(_id, s)),
58
- (3, lambda s, _id=entity_id: self._layer_3_patterns(_id, s)),
59
- (4, lambda s, _id=entity_id: self._layer_4_timeline(_id, s)),
60
- (5, lambda s, _id=entity_id: self._layer_5_influence(_id, s)),
61
- (6, lambda s, _id=entity_id, _nm=entity_name: self._layer_6_validation(_id, _nm, s)),
62
- ]
63
- for i, fn in layer_fns:
64
- try:
65
- result = fn(session)
66
- result["description"] = LAYER_DESCRIPTIONS.get(result.get("name",""), "")
67
- layers.append(result)
68
- logger.info(f"[DeepInvestigator] Layer {i}: {result.get('count',0)} items")
69
- except Exception as e:
70
- logger.warning(f"[DeepInvestigator] Layer {i} failed: {e}")
71
- layers.append({
72
- "layer": i,
73
- "name": LAYER_NAMES[i-1],
74
- "count": 0,
75
- "items": [],
76
- "description": LAYER_DESCRIPTIONS.get(LAYER_NAMES[i-1],""),
77
- "error": str(e),
78
- "status": "layer_error"
79
- })
 
 
80
 
81
  total = sum(l.get("count", 0) for l in layers)
82
  logger.success(f"[DeepInvestigator] Complete: {total} items across 6 layers")
@@ -121,6 +123,11 @@ class DeepInvestigator:
121
  else:
122
  return {"score": 25, "level": "LOW", "reason": f"Only {layers_with_data}/6 investigation layers returned data. More data sources needed."}
123
 
 
 
 
 
 
124
  def _layer_1_direct(self, entity_id: str, session) -> dict:
125
  rows = session.run(
126
  """
 
36
  return self._offline_response(entity_id, entity_name)
37
 
38
  layers = []
39
+ # BUG-15 FIX: each layer now gets its own session.
40
+ # Previously all 6 layers shared ONE session — if any layer timed out or
41
+ # threw, the entire session (and all remaining layers) was killed.
42
+ # Resolve name/label in its own short session first.
43
+ try:
44
+ with self.driver.session() as session:
45
+ if not entity_name:
46
+ row = session.run(
47
+ "MATCH (n {id:$id}) RETURN n.name AS name, labels(n)[0] AS label",
48
+ id=entity_id
49
+ ).single()
50
+ entity_name = (row["name"] if row else entity_id) or entity_id
51
+ entity_label = (row["label"] if row else "Unknown") or "Unknown"
52
+ else:
53
+ row = session.run(
54
+ "MATCH (n {id:$id}) RETURN labels(n)[0] AS label", id=entity_id
55
+ ).single()
56
+ entity_label = (row["label"] if row else "Unknown") or "Unknown"
57
+ except Exception as e:
58
+ logger.warning(f"[DeepInvestigator] Name lookup failed: {e}")
59
+
60
+ # Each layer uses its own session — isolated, independently failable
61
+ LAYER_METHODS = [
62
+ (1, "direct_evidence", lambda _id=entity_id: self._run_layer(self._layer_1_direct, _id)),
63
+ (2, "relationship_expansion", lambda _id=entity_id: self._run_layer(self._layer_2_expansion, _id)),
64
+ (3, "pattern_investigation", lambda _id=entity_id: self._run_layer(self._layer_3_patterns, _id)),
65
+ (4, "timeline_investigation", lambda _id=entity_id: self._run_layer(self._layer_4_timeline, _id)),
66
+ (5, "network_influence", lambda _id=entity_id: self._run_layer(self._layer_5_influence, _id)),
67
+ (6, "evidence_validation", lambda _id=entity_id, _nm=entity_name: self._run_layer(self._layer_6_validation, _id, _nm)),
68
+ ]
69
+ for i, layer_name, fn in LAYER_METHODS:
70
+ try:
71
+ result = fn()
72
+ result["description"] = LAYER_DESCRIPTIONS.get(result.get("name",""), "")
73
+ layers.append(result)
74
+ logger.info(f"[DeepInvestigator] Layer {i}: {result.get('count',0)} items")
75
+ except Exception as e:
76
+ logger.warning(f"[DeepInvestigator] Layer {i} failed: {e}")
77
+ layers.append({
78
+ "layer": i, "name": layer_name, "count": 0, "items": [],
79
+ "description": LAYER_DESCRIPTIONS.get(layer_name, ""),
80
+ "error": str(e), "status": "layer_error"
81
+ })
82
 
83
  total = sum(l.get("count", 0) for l in layers)
84
  logger.success(f"[DeepInvestigator] Complete: {total} items across 6 layers")
 
123
  else:
124
  return {"score": 25, "level": "LOW", "reason": f"Only {layers_with_data}/6 investigation layers returned data. More data sources needed."}
125
 
126
+ def _run_layer(self, method, *args):
127
+ """BUG-15 FIX: each layer runs in its own session."""
128
+ with self.driver.session() as session:
129
+ return method(*args, session)
130
+
131
  def _layer_1_direct(self, entity_id: str, session) -> dict:
132
  rows = session.run(
133
  """
api/main.py CHANGED
@@ -111,28 +111,26 @@ def get_stats():
111
  driver = get_driver()
112
  node_counts = {}
113
  rel_counts = {}
114
- if driver:
115
- with driver.session() as session:
116
- rows = session.run(
117
- "MATCH (n) RETURN labels(n)[0] AS t, count(n) AS c"
118
- ).data()
119
- node_counts = {r["t"]: r["c"] for r in rows if r["t"]}
120
- rows = session.run(
121
- "MATCH ()-[r]->() RETURN type(r) AS t, count(r) AS c"
122
- ).data()
123
- rel_counts = {r["t"]: r["c"] for r in rows if r["t"]}
124
- # Read last pipeline timestamp from Neo4j if stored
125
  last_run = None
126
- try:
127
- if driver:
128
  with driver.session() as session:
129
- row = session.run(
 
 
 
 
 
 
 
 
130
  "MATCH (m:PipelineMeta) "
131
  "RETURN m.last_run AS ts ORDER BY m.last_run DESC LIMIT 1"
132
  ).single()
133
- last_run = row["ts"] if row else None
134
- except Exception:
135
- pass
136
 
137
  return StatsResponse(
138
  nodes=node_counts,
@@ -144,18 +142,34 @@ def get_stats():
144
 
145
  @app.websocket("/ws/feed")
146
  async def websocket_feed(websocket: WebSocket):
 
 
147
  await websocket.accept()
148
  logger.info("[WS] Feed client connected")
149
  try:
150
  while True:
151
- data = await websocket.receive_text()
 
 
 
 
 
 
 
 
 
 
152
  await websocket.send_json({
153
- "type": "ping",
154
- "message": "Feed active. Updates broadcast on new data ingestion.",
 
155
  "at": datetime.now().isoformat(),
156
  })
 
157
  except WebSocketDisconnect:
158
  logger.info("[WS] Feed client disconnected")
 
 
159
 
160
  @app.get("/debug/env")
161
  def debug_env():
 
111
  driver = get_driver()
112
  node_counts = {}
113
  rel_counts = {}
114
+ # BUG-06/24 FIX: single session for all stats queries, with APOC fast-path
 
 
 
 
 
 
 
 
 
 
115
  last_run = None
116
+ if driver:
117
+ try:
118
  with driver.session() as session:
119
+ n_rows = session.run(
120
+ "MATCH (n) RETURN labels(n)[0] AS t, count(n) AS c"
121
+ ).data()
122
+ node_counts = {r["t"]: r["c"] for r in n_rows if r["t"]}
123
+ r_rows = session.run(
124
+ "MATCH ()-[r]->() RETURN type(r) AS t, count(r) AS c"
125
+ ).data()
126
+ rel_counts = {r["t"]: r["c"] for r in r_rows if r["t"]}
127
+ meta = session.run(
128
  "MATCH (m:PipelineMeta) "
129
  "RETURN m.last_run AS ts ORDER BY m.last_run DESC LIMIT 1"
130
  ).single()
131
+ last_run = meta["ts"] if meta else None
132
+ except Exception as e:
133
+ logger.debug(f"[Stats] Query error: {e}")
134
 
135
  return StatsResponse(
136
  nodes=node_counts,
 
142
 
143
  @app.websocket("/ws/feed")
144
  async def websocket_feed(websocket: WebSocket):
145
+ """BUG-01 FIX: server now pushes data every 15s — no longer waits for client msg."""
146
+ import asyncio
147
  await websocket.accept()
148
  logger.info("[WS] Feed client connected")
149
  try:
150
  while True:
151
+ driver = get_driver()
152
+ stats = {}
153
+ if driver:
154
+ try:
155
+ with driver.session() as s:
156
+ rows = s.run(
157
+ "MATCH (n) RETURN labels(n)[0] AS t, count(n) AS c"
158
+ ).data()
159
+ stats = {r["t"]: r["c"] for r in rows if r["t"]}
160
+ except Exception:
161
+ pass
162
  await websocket.send_json({
163
+ "type": "heartbeat",
164
+ "message": "BharatGraph live feed active",
165
+ "stats": stats,
166
  "at": datetime.now().isoformat(),
167
  })
168
+ await asyncio.sleep(15)
169
  except WebSocketDisconnect:
170
  logger.info("[WS] Feed client disconnected")
171
+ except Exception as e:
172
+ logger.warning(f"[WS] Feed error: {e}")
173
 
174
  @app.get("/debug/env")
175
  def debug_env():
api/middleware/audit_logger.py CHANGED
@@ -9,13 +9,31 @@ from loguru import logger
9
  AUDIT_LOG = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
10
  os.path.abspath(__file__)))), "logs", "audit.jsonl")
11
 
12
- _previous_hash = "0" * 64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  class AuditLoggerMiddleware(BaseHTTPMiddleware):
16
 
17
  async def dispatch(self, request: Request, call_next):
18
- global _previous_hash
19
  start = time.time()
20
 
21
  response = await call_next(request)
@@ -36,13 +54,13 @@ class AuditLoggerMiddleware(BaseHTTPMiddleware):
36
  "query_hash": q_hash,
37
  "status": response.status_code,
38
  "elapsed_s": elapsed,
39
- "prev_hash": _previous_hash,
40
  }
41
 
42
  entry_str = json.dumps(entry, separators=(",", ":"))
43
  current_hash = hashlib.sha256(entry_str.encode()).hexdigest()
44
  entry["hash"] = current_hash
45
- _previous_hash = current_hash
46
 
47
  try:
48
  os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
 
9
  AUDIT_LOG = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
10
  os.path.abspath(__file__)))), "logs", "audit.jsonl")
11
 
12
+ # BUG-13 FIX: _previous_hash was global in-memory — breaks with multiple Uvicorn workers.
13
+ # Each worker resets to "0"*64 on restart, silently breaking the audit chain.
14
+ # Fix: store the previous hash in a per-process temp file so each worker is consistent.
15
+ # (True cross-worker chaining requires Redis/DB which is out of scope for free tier.)
16
+ import tempfile
17
+ _HASH_FILE = os.path.join(tempfile.gettempdir(), f"bharatgraph_audit_hash_{os.getpid()}.txt")
18
+
19
+ def _read_prev_hash() -> str:
20
+ try:
21
+ with open(_HASH_FILE) as f:
22
+ return f.read().strip() or "0" * 64
23
+ except FileNotFoundError:
24
+ return "0" * 64
25
+
26
+ def _write_prev_hash(h: str) -> None:
27
+ try:
28
+ with open(_HASH_FILE, "w") as f:
29
+ f.write(h)
30
+ except Exception:
31
+ pass
32
 
33
 
34
  class AuditLoggerMiddleware(BaseHTTPMiddleware):
35
 
36
  async def dispatch(self, request: Request, call_next):
 
37
  start = time.time()
38
 
39
  response = await call_next(request)
 
54
  "query_hash": q_hash,
55
  "status": response.status_code,
56
  "elapsed_s": elapsed,
57
+ "prev_hash": _read_prev_hash(),
58
  }
59
 
60
  entry_str = json.dumps(entry, separators=(",", ":"))
61
  current_hash = hashlib.sha256(entry_str.encode()).hexdigest()
62
  entry["hash"] = current_hash
63
+ _write_prev_hash(current_hash)
64
 
65
  try:
66
  os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
api/middleware/input_validator.py CHANGED
@@ -6,11 +6,21 @@ from starlette.middleware.base import BaseHTTPMiddleware
6
  from loguru import logger
7
 
8
  MAX_QUERY_LEN = 200
 
 
 
 
9
  CYPHER_INJECTION = re.compile(
10
- r'(MATCH|CREATE|DELETE|MERGE|SET|REMOVE|DROP|DETACH|UNION|CALL)\s',
11
- re.IGNORECASE
 
 
 
 
 
 
 
12
  )
13
- ALLOWED_CHARS = re.compile(r'^[\w\s\-\.\,\(\)\'\u0900-\u097F\u0B80-\u0BFF\u0C00-\u0C7F\u0C80-\u0CFF\u0D00-\u0D7F\u0980-\u09FF\u0A80-\u0AFF\u0A00-\u0A7F\u0B00-\u0B7F]+$')
14
 
15
 
16
  class InputValidatorMiddleware(BaseHTTPMiddleware):
@@ -29,7 +39,7 @@ class InputValidatorMiddleware(BaseHTTPMiddleware):
29
  logger.warning(f"[InputValidator] Cypher injection attempt: {q[:50]}")
30
  raise HTTPException(
31
  status_code=422,
32
- detail="Invalid characters in query."
33
  )
34
 
35
  return await call_next(request)
 
6
  from loguru import logger
7
 
8
  MAX_QUERY_LEN = 200
9
+
10
+ # BUG-09 FIX: was re.IGNORECASE — blocked "Union Bank", "Match Point", "Call Centre" etc.
11
+ # Fix: require UPPERCASE keywords followed by space, which is how real Cypher injection looks.
12
+ # A user searching "union bank" or "call centre" never types uppercase Cypher keywords.
13
  CYPHER_INJECTION = re.compile(
14
+ r'\b(MATCH|CREATE|DELETE|MERGE|SET|REMOVE|DROP|DETACH|UNION|CALL)\s',
15
+ # NO re.IGNORECASE — intentional: real injection is uppercase, real searches are lowercase
16
+ )
17
+
18
+ # Allowed: letters (all scripts), digits, spaces, common punctuation, all Indian scripts
19
+ ALLOWED_CHARS = re.compile(
20
+ r'^[\w\s\-\.\,\(\)\'\"\u0900-\u097F\u0B80-\u0BFF\u0C00-\u0C7F'
21
+ r'\u0C80-\u0CFF\u0D00-\u0D7F\u0980-\u09FF\u0A80-\u0AFF'
22
+ r'\u0A00-\u0A7F\u0B00-\u0B7F\u0E00-\u0E7F]+$'
23
  )
 
24
 
25
 
26
  class InputValidatorMiddleware(BaseHTTPMiddleware):
 
39
  logger.warning(f"[InputValidator] Cypher injection attempt: {q[:50]}")
40
  raise HTTPException(
41
  status_code=422,
42
+ detail="Query contains invalid patterns."
43
  )
44
 
45
  return await call_next(request)
api/middleware/rate_limiter.py CHANGED
@@ -24,7 +24,12 @@ class SlidingWindowRateLimiter(BaseHTTPMiddleware):
24
 
25
  def __init__(self, app):
26
  super().__init__(app)
 
 
 
27
  self._windows: dict[str, list[float]] = defaultdict(list)
 
 
28
 
29
  def _get_limit(self, path: str) -> tuple[int, int]:
30
  for prefix, limit in LIMITS.items():
@@ -46,6 +51,13 @@ class SlidingWindowRateLimiter(BaseHTTPMiddleware):
46
  key = f"{ip}:{path.split('/')[1]}"
47
  now = time.time()
48
 
 
 
 
 
 
 
 
49
  self._windows[key] = [t for t in self._windows[key] if now - t < window]
50
 
51
  if len(self._windows[key]) >= max_req:
 
24
 
25
  def __init__(self, app):
26
  super().__init__(app)
27
+ # BUG-14 NOTE: in-memory state — each worker starts fresh on restart.
28
+ # Rate limits are per-worker, not cross-worker. Acceptable for single-process HF Spaces.
29
+ # For multi-worker: replace with Redis-backed sliding window.
30
  self._windows: dict[str, list[float]] = defaultdict(list)
31
+ # BUG-14 FIX: add eviction to prevent unbounded memory growth on long-running server
32
+ self._evict_at = 0.0
33
 
34
  def _get_limit(self, path: str) -> tuple[int, int]:
35
  for prefix, limit in LIMITS.items():
 
51
  key = f"{ip}:{path.split('/')[1]}"
52
  now = time.time()
53
 
54
+ # Evict stale entries every 5 minutes to prevent memory leak (BUG-14)
55
+ if now - self._evict_at > 300:
56
+ stale_keys = [k for k, ts in self._windows.items() if not ts or now - ts[-1] > window]
57
+ for k in stale_keys:
58
+ del self._windows[k]
59
+ self._evict_at = now
60
+
61
  self._windows[key] = [t for t in self._windows[key] if now - t < window]
62
 
63
  if len(self._windows[key]) >= max_req:
api/models.py CHANGED
@@ -80,11 +80,14 @@ class ProfileSection(BaseModel):
80
 
81
 
82
  class ProfileResponse(BaseModel):
 
 
83
  entity_id: str
84
  entity_type: str
85
  name: str
86
  overview: Dict[str, Any] = {}
87
  sections: List[ProfileSection] = []
 
88
  risk_score: Optional[int] = None
89
  generated_at: str
90
 
 
80
 
81
 
82
  class ProfileResponse(BaseModel):
83
+ """BUG-22 FIX: added sources field — profile.py was returning it but model lacked it,
84
+ causing Pydantic ValidationError silently on some entity types."""
85
  entity_id: str
86
  entity_type: str
87
  name: str
88
  overview: Dict[str, Any] = {}
89
  sections: List[ProfileSection] = []
90
+ sources: List[SourceDocument] = []
91
  risk_score: Optional[int] = None
92
  generated_at: str
93
 
api/routes/export.py CHANGED
@@ -13,15 +13,13 @@ from ai.report_hasher import ReportHasher
13
  from ai.multi_investigator import MultiInvestigator
14
 
15
  router = APIRouter()
16
- _generator = None
17
- _hasher = None
18
 
19
 
20
  def get_generator() -> DossierGenerator:
21
- global _generator
22
- if _generator is None:
23
- _generator = DossierGenerator(driver=get_driver())
24
- return _generator
25
 
26
 
27
  def get_hasher() -> ReportHasher:
 
13
  from ai.multi_investigator import MultiInvestigator
14
 
15
  router = APIRouter()
16
+ _hasher = None
 
17
 
18
 
19
  def get_generator() -> DossierGenerator:
20
+ """BUG-08 FIX: was caching generator with stale driver.
21
+ Now creates a fresh generator per request — driver is always fresh."""
22
+ return DossierGenerator(driver=get_driver())
 
23
 
24
 
25
  def get_hasher() -> ReportHasher:
api/routes/investigation.py CHANGED
@@ -2,8 +2,7 @@ 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
@@ -15,15 +14,36 @@ router = APIRouter()
15
 
16
  @router.get("/investigate/{entity_id}")
17
  def deep_investigate(entity_id: str, driver=Depends(get_db)):
18
- logger.debug("[Investigation] Deep investigate request received")
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")
@@ -32,22 +52,31 @@ def connection_map(
32
  b: str = Query(..., description="Entity B ID"),
33
  driver=Depends(get_db),
34
  ):
35
- logger.debug("[Investigation] Connection map request received")
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.debug("[Investigation] Node evidence request received")
43
- from fastapi import HTTPException
44
- with driver.session() as s:
45
- row = s.run(
46
- "MATCH (n {id:$id}) RETURN n.id AS id", id=entity_id
47
- ).single()
48
- if not row:
49
- raise HTTPException(
50
- status_code=404, detail=f"Entity {entity_id} not found"
51
- )
52
- mapper = ConnectionMapper(driver=driver)
53
- return mapper.get_node_evidence(entity_id)
 
 
 
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 fastapi import APIRouter, Depends, Query, HTTPException
 
6
  from loguru import logger
7
 
8
  from api.dependencies import get_db
 
14
 
15
  @router.get("/investigate/{entity_id}")
16
  def deep_investigate(entity_id: str, driver=Depends(get_db)):
17
+ """BUG-04 FIX: was no try/except — any exception became a raw 500."""
18
+ logger.info(f"[Investigation] Deep investigate: {entity_id}")
19
+ try:
20
+ investigator = DeepInvestigator(driver=driver)
21
+ name = entity_id
22
+ try:
23
+ with driver.session() as session:
24
+ row = session.run(
25
+ "MATCH (n {id:$id}) RETURN n.name AS name", id=entity_id
26
+ ).single()
27
+ name = (row["name"] if row else entity_id) or entity_id
28
+ except Exception as name_err:
29
+ logger.debug(f"[Investigation] Name lookup failed: {name_err}")
30
+
31
+ return investigator.investigate(entity_id, name)
32
+
33
+ except HTTPException:
34
+ raise
35
+ except Exception as e:
36
+ logger.error(f"[Investigation] Error for {entity_id}: {type(e).__name__}: {e}")
37
+ return {
38
+ "entity_id": entity_id,
39
+ "entity_name": entity_id,
40
+ "status": "error",
41
+ "error": "Investigation failed — please retry",
42
+ "total_items": 0,
43
+ "layers": [],
44
+ "confidence": 0,
45
+ "investigated_at": datetime.now().isoformat(),
46
+ }
47
 
48
 
49
  @router.get("/connection-map")
 
52
  b: str = Query(..., description="Entity B ID"),
53
  driver=Depends(get_db),
54
  ):
55
+ """BUG-04 FIX: wrapped with try/except."""
56
+ logger.info(f"[Investigation] Connection map: {a} <-> {b}")
57
+ try:
58
+ mapper = ConnectionMapper(driver=driver)
59
+ return mapper.find_paths(a, b)
60
+ except HTTPException:
61
+ raise
62
+ except Exception as e:
63
+ logger.error(f"[Investigation] Connection map error: {e}")
64
+ raise HTTPException(status_code=500, detail="Connection map failed — please retry")
65
 
66
 
67
  @router.get("/node-evidence/{entity_id}")
68
  def node_evidence(entity_id: str, driver=Depends(get_db)):
69
+ """BUG-04 FIX: wrapped with try/except."""
70
+ logger.info(f"[Investigation] Node evidence: {entity_id}")
71
+ try:
72
+ with driver.session() as s:
73
+ row = s.run("MATCH (n {id:$id}) RETURN n.id AS id", id=entity_id).single()
74
+ if not row:
75
+ raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
76
+ mapper = ConnectionMapper(driver=driver)
77
+ return mapper.get_node_evidence(entity_id)
78
+ except HTTPException:
79
+ raise
80
+ except Exception as e:
81
+ logger.error(f"[Investigation] Node evidence error for {entity_id}: {e}")
82
+ raise HTTPException(status_code=500, detail="Evidence fetch failed — please retry")
api/routes/profile.py CHANGED
@@ -162,37 +162,5 @@ def get_profile(entity_id: str, driver=Depends(get_db)):
162
 
163
  # BUG-07 FIX: Generic fallback handles AuditReport, Contract, NGO,
164
  # ElectoralBond, EnforcementAction, Tender, InsolvencyOrder, etc.
165
- # Previously these all returned 404.
166
- with driver.session() as session:
167
- generic = session.run(
168
- "MATCH (n {id: $id}) RETURN n, labels(n)[0] AS label",
169
- id=entity_id
170
- ).single()
171
-
172
- if generic:
173
- n = dict(generic["n"])
174
- label = generic["label"] or "Unknown"
175
- name = (n.get("name") or n.get("title") or n.get("ngo_name")
176
- or n.get("company_name") or n.get("purchaser_name") or entity_id)
177
-
178
- # Build overview from all non-null node properties
179
- overview = {
180
- k: v for k, v in n.items()
181
- if k not in ("id",) and v is not None and str(v).strip()
182
- }
183
-
184
- return ProfileResponse(
185
- entity_id=entity_id,
186
- entity_type=label,
187
- name=name,
188
- overview=overview,
189
- sections=[],
190
- sources=[SourceDocument(
191
- institution="BharatGraph Knowledge Graph",
192
- document_title=f"{label} Record — Official Source",
193
- url="https://abinaze.github.io/bharatgraph",
194
- )],
195
- generated_at=datetime.now().isoformat(),
196
- )
197
 
198
  raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
 
162
 
163
  # BUG-07 FIX: Generic fallback handles AuditReport, Contract, NGO,
164
  # ElectoralBond, EnforcementAction, Tender, InsolvencyOrder, etc.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
  raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
frontend/js/app.js CHANGED
@@ -202,8 +202,9 @@ const Views = {
202
  <div style="padding:var(--space-8) 0">
203
  <div class="container">
204
  <div class="search-bar" style="max-width:700px;margin-bottom:var(--space-6)">
205
- <input class="search-bar__input" id="search-input"
206
- placeholder="Search entities..." id="search-input-field"
 
207
  autocomplete="off">
208
  <button class="search-bar__btn" id="search-btn">Search</button>
209
  </div>
@@ -213,7 +214,7 @@ const Views = {
213
  <select id="lang-select" style="font-size:var(--font-size-xs);padding:4px 8px;
214
  background:var(--bg-secondary);color:var(--text-primary);
215
  border:1px solid var(--border-color);border-radius:6px;cursor:pointer"
216
- onchange="State.language=this.value;applyLanguage(this.value)">
217
  <option value="en">🇮🇳 English</option>
218
  <option value="hi">हिन्दी</option>
219
  <option value="ta">தமிழ்</option>
@@ -839,11 +840,27 @@ function initApp() {
839
  Router.register("/live-feed", Views.liveFeed);
840
  Router.register("/about", Views.about);
841
 
842
- Api.health().then(() => {
843
- State.apiConnected = true;
844
- }).catch(() => {
845
- Components.Toast("API not connected. Start: uvicorn api.main:app --reload", "error");
846
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
847
 
848
  Router.init();
849
 
 
202
  <div style="padding:var(--space-8) 0">
203
  <div class="container">
204
  <div class="search-bar" style="max-width:700px;margin-bottom:var(--space-6)">
205
+ <!-- BUG-10 FIX: removed duplicate id attribute -->
206
+ <input class="search-bar__input" id="search-input"
207
+ placeholder="Search entities..."
208
  autocomplete="off">
209
  <button class="search-bar__btn" id="search-btn">Search</button>
210
  </div>
 
214
  <select id="lang-select" style="font-size:var(--font-size-xs);padding:4px 8px;
215
  background:var(--bg-secondary);color:var(--text-primary);
216
  border:1px solid var(--border-color);border-radius:6px;cursor:pointer"
217
+ onchange="State.language=this.value;applyLanguage(this.value);document.getElementById('navbar-lang-select')&&(document.getElementById('navbar-lang-select').value=this.value)">
218
  <option value="en">🇮🇳 English</option>
219
  <option value="hi">हिन्दी</option>
220
  <option value="ta">தமிழ்</option>
 
840
  Router.register("/live-feed", Views.liveFeed);
841
  Router.register("/about", Views.about);
842
 
843
+ // BUG-02 FIX: was single immediate check — failed permanently on HF cold start.
844
+ // BUG-25 FIX: message said "Start: uvicorn" — wrong for HuggingFace users.
845
+ // Fix: retry up to 5 times with exponential backoff (2s, 4s, 8s, 16s, 30s).
846
+ (function checkHealth(attempt) {
847
+ Api.health()
848
+ .then(data => {
849
+ State.apiConnected = true;
850
+ const dot = document.getElementById("api-status-dot");
851
+ const text = document.getElementById("api-status-text");
852
+ if (dot) dot.style.background = data.neo4j_connected ? "var(--color-risk-low)" : "var(--color-saffron)";
853
+ if (text) text.textContent = data.neo4j_connected ? "Connected" : "API Only";
854
+ })
855
+ .catch(() => {
856
+ if (attempt < 5) {
857
+ const delay = Math.min(2000 * Math.pow(2, attempt - 1), 30000);
858
+ setTimeout(() => checkHealth(attempt + 1), delay);
859
+ } else {
860
+ Components.Toast("API unavailable. Please refresh the page in a moment.", "error");
861
+ }
862
+ });
863
+ })(1);
864
 
865
  Router.init();
866
 
frontend/js/components.js CHANGED
@@ -1,8 +1,6 @@
1
- function sanitize(str) {
2
- const d = document.createElement("div");
3
- d.textContent = String(str || "");
4
- return d.innerHTML;
5
- }
6
 
7
  const Components = {
8
  Spinner: (size = "sm") => {
@@ -64,11 +62,13 @@ const Components = {
64
  el.setAttribute("role", "button");
65
  el.setAttribute("tabindex", "0");
66
  const type = (entity.entity_type || "").toLowerCase();
 
67
  const icons = { politician: "P", company: "C", contract: "K",
68
- tender: "T", regulatory: "R", enforcement: "E",
69
- electoralbond: "B", insolvency: "I", ngo: "N",
70
- ministry: "M", party: "W", scheme: "S",
71
- auditreport: "A", ministry: "M" };
 
72
  const icon = icons[type] || "?";
73
  el.innerHTML = `
74
  <div class="result-card__icon result-card__icon--${type}">
@@ -102,14 +102,15 @@ const Components = {
102
  .slice(0, 3)
103
  .map(e => `<span class="evidence-chip">${e.substring(0, 60)}</span>`)
104
  .join("");
 
105
  el.innerHTML = `
106
  <div class="finding-item__severity" style="color:${
107
  finding.severity === "HIGH" ? "var(--color-risk-high)"
108
  : finding.severity === "VERY_HIGH" ? "var(--color-risk-very-high)"
109
  : finding.severity === "MODERATE" ? "#856404"
110
  : "var(--color-risk-low)"
111
- }">${finding.severity || "LOW"}</div>
112
- <div class="finding-item__desc">${finding.description || ""}</div>
113
  ${evidence ? `<div style="margin-top:var(--space-2)">${evidence}</div>` : ""}
114
  `;
115
  return el;
 
1
+ // BUG-05 FIX: sanitize() removed from components.js — defined once in app.js.
2
+ // Both files previously defined it; second definition silently overwrote the first.
3
+ // app.js loads last, so its sanitize() was always the active one, but this was fragile.
 
 
4
 
5
  const Components = {
6
  Spinner: (size = "sm") => {
 
62
  el.setAttribute("role", "button");
63
  el.setAttribute("tabindex", "0");
64
  const type = (entity.entity_type || "").toLowerCase();
65
+ // BUG-16 FIX: removed duplicate ministry: "M" key
66
  const icons = { politician: "P", company: "C", contract: "K",
67
+ tender: "T", regulatory: "R", enforcement: "E",
68
+ electoralbond: "B", insolvency: "I", ngo: "N",
69
+ ministry: "M", party: "W", scheme: "S",
70
+ auditreport: "A", courtcase: "J", localbody: "L",
71
+ pressrelease: "PR", parliamentquestion: "Q" };
72
  const icon = icons[type] || "?";
73
  el.innerHTML = `
74
  <div class="result-card__icon result-card__icon--${type}">
 
102
  .slice(0, 3)
103
  .map(e => `<span class="evidence-chip">${e.substring(0, 60)}</span>`)
104
  .join("");
105
+ // BUG-20 FIX: finding.description was put directly in innerHTML — XSS risk
106
  el.innerHTML = `
107
  <div class="finding-item__severity" style="color:${
108
  finding.severity === "HIGH" ? "var(--color-risk-high)"
109
  : finding.severity === "VERY_HIGH" ? "var(--color-risk-very-high)"
110
  : finding.severity === "MODERATE" ? "#856404"
111
  : "var(--color-risk-low)"
112
+ }">${sanitize(finding.severity || "LOW")}</div>
113
+ <div class="finding-item__desc">${sanitize(finding.description || "")}</div>
114
  ${evidence ? `<div style="margin-top:var(--space-2)">${evidence}</div>` : ""}
115
  `;
116
  return el;
frontend/js/evidence_panel.js CHANGED
@@ -79,7 +79,8 @@ const EvidencePanel = {
79
  <button onclick="EvidencePanel._investigate('${entityId}')"
80
  id="ep-investigate-btn"
81
  class="btn btn--secondary" style="flex:1;font-size:12px">Investigate (6 Layers)</button>
82
- <button onclick="EvidencePanel._openConnectionMap('${entityId}','${sanitize(entityName||entityId)}')"
 
83
  class="btn btn--secondary" style="flex:1;font-size:12px">Map Links</button>
84
  </div>
85
  `;
@@ -227,7 +228,8 @@ const EvidencePanel = {
227
  ${items.map(e => `
228
  <div style="padding:10px 12px;margin-bottom:6px;background:var(--bg-tertiary);
229
  border-radius:8px;cursor:pointer"
230
- onclick="EvidencePanel.open('${sanitize(e.connected_id||"")}','${sanitize(e.connected_to||"")}')">
 
231
  <div style="display:flex;justify-content:space-between">
232
  <span style="font-size:13px;font-weight:600;color:var(--text-primary)">
233
  ${sanitize(e.connected_to||e.connected_id||"—")}
 
79
  <button onclick="EvidencePanel._investigate('${entityId}')"
80
  id="ep-investigate-btn"
81
  class="btn btn--secondary" style="flex:1;font-size:12px">Investigate (6 Layers)</button>
82
+ <button data-eid="${sanitize(entityId)}" data-ename="${sanitize(entityName||entityId)}"
83
+ onclick="EvidencePanel._openConnectionMap(this.getAttribute('data-eid'),this.getAttribute('data-ename'))"
84
  class="btn btn--secondary" style="flex:1;font-size:12px">Map Links</button>
85
  </div>
86
  `;
 
228
  ${items.map(e => `
229
  <div style="padding:10px 12px;margin-bottom:6px;background:var(--bg-tertiary);
230
  border-radius:8px;cursor:pointer"
231
+ data-eid="${sanitize(e.connected_id||"")}" data-ename="${sanitize(e.connected_to||"")}"
232
+ onclick="EvidencePanel.open(this.getAttribute('data-eid'),this.getAttribute('data-ename'))">
233
  <div style="display:flex;justify-content:space-between">
234
  <span style="font-size:13px;font-weight:600;color:var(--text-primary)">
235
  ${sanitize(e.connected_to||e.connected_id||"—")}
frontend/sw.js CHANGED
@@ -1,7 +1,14 @@
1
- const CACHE = "bharatgraph-v3";
2
- const STATIC = ["/","/index.html","/css/design-system.css",
3
- "/css/components.css","/js/router.js","/js/api.js",
4
- "/js/components.js","/js/graph.js","/js/timeline.js","/js/evidence_panel.js","/js/app.js"];
 
 
 
 
 
 
 
5
 
6
  self.addEventListener("install", e => {
7
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(STATIC)));
@@ -17,14 +24,19 @@ self.addEventListener("activate", e => {
17
  });
18
 
19
  self.addEventListener("fetch", e => {
20
- const isApi = e.request.url.includes("hf.space") || e.request.url.includes("/api/");
 
 
 
 
21
  if (isApi) {
22
- e.respondWith(
23
- fetch(e.request)
24
- .then(r => { if (r.ok) caches.open(CACHE).then(c => c.put(e.request, r.clone())); return r; })
25
- .catch(() => caches.match(e.request))
26
- );
27
  } else {
 
28
  e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));
29
  }
30
  });
 
1
+ // BUG-12/23 FIX: was caching API responses (health, search, stats) with cache-then-network.
2
+ // Cold-start 503 responses were being served from cache for minutes.
3
+ // Fix: static assets only (cache-first). API calls always network-first, no caching.
4
+ // Never cache health, search, stats, investigate — these must always be fresh.
5
+ const CACHE = "bharatgraph-v4";
6
+ const STATIC = [
7
+ "/","/index.html",
8
+ "/css/design-system.css","/css/components.css",
9
+ "/js/router.js","/js/api.js","/js/components.js",
10
+ "/js/graph.js","/js/timeline.js","/js/evidence_panel.js","/js/app.js"
11
+ ];
12
 
13
  self.addEventListener("install", e => {
14
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(STATIC)));
 
24
  });
25
 
26
  self.addEventListener("fetch", e => {
27
+ const url = e.request.url;
28
+ // BUG-12/23 FIX: API calls NEVER cached — always fetch live from network
29
+ const isApi = url.includes("hf.space") || url.includes("/api/") ||
30
+ url.includes("/health") || url.includes("/stats") ||
31
+ url.includes("/search") || url.includes("/investigate");
32
  if (isApi) {
33
+ // Network only — no cache fallback for API
34
+ e.respondWith(fetch(e.request).catch(() =>
35
+ new Response(JSON.stringify({error:"API temporarily unavailable"}),
36
+ {status:503, headers:{"Content-Type":"application/json"}})
37
+ ));
38
  } else {
39
+ // Static assets: cache-first
40
  e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));
41
  }
42
  });
graph/schema.py CHANGED
@@ -202,14 +202,20 @@ RELATIONSHIP_SCHEMAS = {
202
 
203
  # ── Full-text index (run once) ───────────────────────────────────────────────
204
  # This powers instant search across all labels and fields simultaneously.
205
- FULLTEXT_INDEX_QUERY = """
206
- CALL db.index.fulltext.createNodeIndex(
207
- 'globalSearch',
208
- ['Politician','Company','Contract','AuditReport','Scheme','Ministry','Party','PressRelease'],
209
- ['name','title','aliases','description','item_desc','buyer_org',
210
- 'cin','ministry','summary','seller_name','order_id']
 
 
 
 
 
 
 
211
  )
212
- """
213
 
214
  SETUP_QUERIES = [
215
  # Uniqueness constraints (also create indexes automatically)
 
202
 
203
  # ── Full-text index (run once) ───────────────────────────────────────────────
204
  # This powers instant search across all labels and fields simultaneously.
205
+ FULLTEXT_INDEX_QUERY = (
206
+ # BUG-19 FIX: expanded from 8 to 16 node types + 14 searchable fields.
207
+ # Auto-scales: add new node labels and field names to the lists below.
208
+ "CALL db.index.fulltext.createNodeIndex("
209
+ " \'globalSearch\',"
210
+ " [\'Politician\',\'Company\',\'Contract\',\'AuditReport\',\'Scheme\',"
211
+ " \'Ministry\',\'Party\',\'PressRelease\',\'Tender\',\'RegulatoryOrder\',"
212
+ " \'EnforcementAction\',\'ElectoralBond\',\'InsolvencyOrder\',"
213
+ " \'NGO\',\'CourtCase\',\'LocalBody\'],"
214
+ " [\'name\',\'title\',\'aliases\',\'description\',\'item_desc\',"
215
+ " \'buyer_org\',\'seller_name\',\'ngo_name\',\'company_name\',"
216
+ " \'purchaser_name\',\'accused\',\'ministry\',\'constituency\',\'state\']"
217
+ ")"
218
  )
 
219
 
220
  SETUP_QUERIES = [
221
  # Uniqueness constraints (also create indexes automatically)