Spaces:
Build error
Build error
fix: all 26 bugs fixed
Browse files- ai/biography/timeline_builder.py +2 -2
- ai/deep_investigator.py +48 -41
- api/main.py +34 -20
- api/middleware/audit_logger.py +22 -4
- api/middleware/input_validator.py +14 -4
- api/middleware/rate_limiter.py +12 -0
- api/models.py +3 -0
- api/routes/export.py +4 -6
- api/routes/investigation.py +55 -26
- api/routes/profile.py +0 -32
- frontend/js/app.js +25 -8
- frontend/js/components.js +12 -11
- frontend/js/evidence_panel.js +4 -2
- frontend/sw.js +22 -10
- graph/schema.py +13 -7
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 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
| 80 |
|
| 81 |
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 |
-
|
| 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 |
-
|
| 127 |
-
|
| 128 |
with driver.session() as session:
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
"MATCH (m:PipelineMeta) "
|
| 131 |
"RETURN m.last_run AS ts ORDER BY m.last_run DESC LIMIT 1"
|
| 132 |
).single()
|
| 133 |
-
last_run =
|
| 134 |
-
|
| 135 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
await websocket.send_json({
|
| 153 |
-
"type": "
|
| 154 |
-
"message": "
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 40 |
}
|
| 41 |
|
| 42 |
entry_str = json.dumps(entry, separators=(",", ":"))
|
| 43 |
current_hash = hashlib.sha256(entry_str.encode()).hexdigest()
|
| 44 |
entry["hash"] = current_hash
|
| 45 |
-
|
| 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="
|
| 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 |
-
|
| 17 |
-
_hasher = None
|
| 18 |
|
| 19 |
|
| 20 |
def get_generator() -> DossierGenerator:
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 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
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
@router.get("/node-evidence/{entity_id}")
|
| 41 |
def node_evidence(entity_id: str, driver=Depends(get_db)):
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
"MATCH (n {id:$id}) RETURN n.id AS id", id=entity_id
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 206 |
-
|
|
|
|
| 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 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 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 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
auditreport: "A",
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
if (isApi) {
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 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 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|