bharatgraph / api /routes /graph.py
abinazebinoy's picture
feat(phase-33): add 4 graph analytics endpoints to api/routes/graph.py
5ac9ba3
Raw
History Blame Contribute Delete
10.8 kB
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from loguru import logger
from api.models import GraphResponse, GraphNode, GraphEdge
from api.dependencies import get_db
router = APIRouter()
@router.get("/graph/connections/{entity_id}", response_model=GraphResponse)
def get_connections(
entity_id: str,
depth: int = Query(2, ge=1, le=3),
driver=Depends(get_db),
):
logger.info(f"[Graph] entity_id={entity_id} depth={depth}")
nodes = {}
edges = []
_edge_seen = set()
with driver.session() as session:
anchor = session.run(
"MATCH (n {id:$id}) RETURN n", id=entity_id
).single()
if anchor and anchor.get("n"):
n = anchor["n"]
nid = n.get("id", entity_id)
labs = list(n.labels) if hasattr(n, "labels") else ["Unknown"]
props = dict(n)
nodes[nid] = GraphNode(
id=nid,
label=labs[0] if labs else "Unknown",
name=props.get("name", props.get("title", nid)),
properties={k: str(v) for k, v in props.items()
if k not in ("id", "name", "title")},
)
# BUG-1 FIX: Neo4j does NOT allow query parameters inside
# variable-length path patterns [r*1..$depth] -- the depth MUST be
# baked into the Cypher string as a literal integer using f-string/str().
# Passing depth as a parameter causes CypherSyntaxError on every call.
depth_safe = min(max(int(depth), 1), 3)
cypher = (
"MATCH (start {id: $id})-[r*1.."
+ str(depth_safe)
+ "]-(end) "
"RETURN DISTINCT "
"start.id AS src_id, "
"labels(start)[0] AS src_label, "
"coalesce(start.name, start.title, start.id) AS src_name, "
"properties(start) AS src_props, "
"[rel IN r | { "
" src: startNode(rel).id, "
" tgt: endNode(rel).id, "
" type: type(rel) "
"}] AS rels, "
"end.id AS end_id, "
"labels(end)[0] AS end_label, "
"coalesce(end.name, end.title, end.id) AS end_name, "
"properties(end) AS end_props "
"LIMIT 200"
)
rows = session.run(cypher, id=entity_id).data()
for row in rows:
for nid, nlabel, nname, nprops in [
(row.get("src_id"), row.get("src_label"), row.get("src_name"), row.get("src_props") or {}),
(row.get("end_id"), row.get("end_label"), row.get("end_name"), row.get("end_props") or {}),
]:
if nid and nid not in nodes:
nodes[nid] = GraphNode(
id=nid,
label=nlabel or "Unknown",
name=nname or nid,
properties={k: str(v) for k, v in nprops.items()
if k not in ("id","name","title") and v is not None},
)
for rel_step in (row.get("rels") or []):
src = rel_step.get("src", "")
tgt = rel_step.get("tgt", "")
rtype = rel_step.get("type", "RELATED_TO")
if src and tgt and isinstance(src, str) and isinstance(tgt, str):
edge_key = (src, tgt, rtype)
if edge_key not in _edge_seen:
_edge_seen.add(edge_key)
edges.append(GraphEdge(
source=src, target=tgt, relationship=rtype,
properties={},
))
return GraphResponse(
entity_id=entity_id,
depth=depth_safe,
nodes=list(nodes.values()),
edges=edges,
generated_at=datetime.now().isoformat(),
)
@router.get("/graph/pattern/politician-contracts")
def politician_contracts_pattern(
limit: int = Query(50, le=200),
driver=Depends(get_db),
):
with driver.session() as session:
rows = session.run(
"""
MATCH (p:Politician)-[:DIRECTOR_OF]->(c:Company)-[:WON_CONTRACT]->(ct:Contract)
RETURN p.name AS politician, p.party AS party, p.state AS state,
c.name AS company, ct.order_id AS contract_id,
ct.amount_crore AS amount_crore, ct.buyer_org AS buyer,
ct.order_date AS order_date
ORDER BY ct.amount_crore DESC LIMIT $limit
""",
limit=limit
).data()
return {"pattern": "politician -> company -> contract",
"total": len(rows), "results": rows,
"generated_at": datetime.now().isoformat()}
# ── Phase 33: Graph Intelligence endpoints ───────────────────────────────────
@router.get("/graph/analytics/{entity_id}")
def entity_graph_analytics(
entity_id: str,
depth: int = Query(3, ge=1, le=4),
driver=Depends(get_db),
):
"""
Run full graph analytics for one entity:
betweenness centrality, PageRank, and community detection
on the entity local sub-graph (up to depth hops out).
"""
logger.info(f"[GraphAnalytics] entity={entity_id[:8]} depth={depth}")
try:
from ai.graph_analytics import GraphAnalytics
ga = GraphAnalytics(driver=driver)
nodes, edges = ga._fetch_graph_from_neo4j(entity_id=entity_id, depth=depth)
if not nodes:
return {
"entity_id": entity_id,
"status": "no_data",
"note": "Entity not found or graph has no connections at this depth",
"analyzed_at": datetime.now().isoformat(),
}
betweenness = ga.compute_betweenness_centrality(nodes, edges)
pagerank = ga.compute_pagerank(nodes, edges)
communities = ga.detect_communities(nodes, edges)
return {
"entity_id": entity_id,
"depth": depth,
"node_count": len(nodes),
"edge_count": len(edges),
"betweenness": betweenness[:10],
"pagerank": pagerank[:10],
"communities": communities[:5],
"analyzed_at": datetime.now().isoformat(),
}
except ImportError:
return {"status": "error", "detail": "networkx not installed"}
except Exception as e:
logger.error(f"[GraphAnalytics] entity={entity_id[:8]}: {type(e).__name__}")
from fastapi import HTTPException
raise HTTPException(status_code=500, detail="Analytics failed")
@router.get("/graph/centrality/betweenness")
def global_betweenness(
limit: int = Query(20, ge=5, le=100),
driver=Depends(get_db),
):
"""
Top entities ranked by betweenness centrality across the entire graph.
High betweenness = acts as a bridge between institutional networks.
Expensive on large graphs -- cached by the caller for 10 minutes.
"""
logger.info("[GraphAnalytics] global betweenness requested")
try:
from ai.graph_analytics import GraphAnalytics
ga = GraphAnalytics(driver=driver)
nodes, edges = ga._fetch_graph_from_neo4j()
if not nodes:
return {"status": "no_data", "results": []}
results = ga.compute_betweenness_centrality(nodes, edges)
return {
"metric": "betweenness_centrality",
"node_count": len(nodes),
"edge_count": len(edges),
"top": results[:limit],
"analyzed_at": datetime.now().isoformat(),
}
except ImportError:
return {"status": "error", "detail": "networkx not installed"}
except Exception as e:
logger.error(f"[GraphAnalytics] betweenness error: {type(e).__name__}")
from fastapi import HTTPException
raise HTTPException(status_code=500, detail="Betweenness centrality failed")
@router.get("/graph/centrality/pagerank")
def global_pagerank(
limit: int = Query(20, ge=5, le=100),
driver=Depends(get_db),
):
"""
Top entities ranked by PageRank across the entire graph.
High PageRank = many high-influence entities point to this node.
"""
logger.info("[GraphAnalytics] global pagerank requested")
try:
from ai.graph_analytics import GraphAnalytics
ga = GraphAnalytics(driver=driver)
nodes, edges = ga._fetch_graph_from_neo4j()
if not nodes:
return {"status": "no_data", "results": []}
results = ga.compute_pagerank(nodes, edges)
return {
"metric": "pagerank",
"node_count": len(nodes),
"edge_count": len(edges),
"top": results[:limit],
"analyzed_at": datetime.now().isoformat(),
}
except ImportError:
return {"status": "error", "detail": "networkx not installed"}
except Exception as e:
logger.error(f"[GraphAnalytics] pagerank error: {type(e).__name__}")
from fastapi import HTTPException
raise HTTPException(status_code=500, detail="PageRank failed")
@router.get("/graph/communities")
def global_communities(
min_size: int = Query(3, ge=2, le=50),
driver=Depends(get_db),
):
"""
Detect institutional clusters (communities) in the full graph.
Returns communities with >= min_size members.
Large communities often indicate procurement clusters, shell company
networks, or party-linked directorships warranting investigation.
"""
logger.info("[GraphAnalytics] community detection requested")
try:
from ai.graph_analytics import GraphAnalytics
ga = GraphAnalytics(driver=driver)
nodes, edges = ga._fetch_graph_from_neo4j()
if not nodes:
return {"status": "no_data", "communities": []}
communities = ga.detect_communities(nodes, edges)
filtered = [c for c in communities if c["size"] >= min_size]
return {
"total_communities": len(communities),
"shown": len(filtered),
"min_size": min_size,
"communities": filtered,
"analyzed_at": datetime.now().isoformat(),
}
except ImportError:
return {"status": "error", "detail": "networkx not installed"}
except Exception as e:
logger.error(f"[GraphAnalytics] communities error: {type(e).__name__}")
from fastapi import HTTPException
raise HTTPException(status_code=500, detail="Community detection failed")