- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
239 lines
7.8 KiB
Python
239 lines
7.8 KiB
Python
"""
|
|
Graph RAG - Community detection + narrative summaries from Knowledge Graph.
|
|
|
|
Extends the existing Redis-backed Knowledge Graph (5,614 nodes) with:
|
|
1. Label propagation community detection - find scammer rings, exchange clusters
|
|
2. Community narrative synthesis - LLM-generated summaries per cluster
|
|
3. Graph-augmented retrieval - expand queries with community context
|
|
|
|
Produces structured community reports that answer:
|
|
"What scammer rings are active right now?"
|
|
"Show me the cluster this address belongs to"
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from collections import defaultdict
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def detect_communities(
|
|
max_communities: int = 20,
|
|
min_community_size: int = 3,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Detect communities in the Knowledge Graph using label propagation.
|
|
|
|
Algorithm:
|
|
1. Each node starts as its own community
|
|
2. Iteratively: each node adopts the most common label among neighbors
|
|
3. Converge after ~5 iterations
|
|
4. Return top communities by size
|
|
|
|
Returns list of community dicts with nodes, edges, and metadata.
|
|
"""
|
|
try:
|
|
from app.knowledge_graph import get_knowledge_graph
|
|
|
|
kg = await get_knowledge_graph()
|
|
except ImportError:
|
|
logger.debug("Knowledge Graph not available")
|
|
return []
|
|
except Exception as e:
|
|
logger.warning(f"KG access failed: {e}")
|
|
return []
|
|
|
|
r = kg._redis_client if hasattr(kg, "_redis_client") else None
|
|
if not r:
|
|
logger.debug("No Redis client in KG")
|
|
return []
|
|
|
|
# 1. Get all nodes
|
|
try:
|
|
node_ids = list(await r.smembers("kg:nodes"))
|
|
if not node_ids:
|
|
logger.debug("KG has no nodes")
|
|
return []
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get KG nodes: {e}")
|
|
return []
|
|
|
|
# 2. Get edges (adjacency)
|
|
adjacency: dict[str, set[str]] = defaultdict(set)
|
|
for nid in node_ids:
|
|
try:
|
|
edges_raw = await r.smembers(f"kg:edges:{nid}")
|
|
for edge in edges_raw:
|
|
if isinstance(edge, bytes):
|
|
edge = edge.decode()
|
|
parts = edge.split(":") if ":" in edge else [edge, ""]
|
|
target = parts[1] if len(parts) > 1 else parts[0]
|
|
if target in node_ids or target in {b.decode() if isinstance(b, bytes) else b for b in node_ids}:
|
|
adjacency[nid].add(target)
|
|
except Exception:
|
|
pass
|
|
|
|
# 3. Label propagation
|
|
N_ITER = 5
|
|
labels = {nid: i for i, nid in enumerate(node_ids)}
|
|
|
|
for _ in range(N_ITER):
|
|
new_labels = {}
|
|
for nid in node_ids:
|
|
neighbors = adjacency.get(nid, set())
|
|
if not neighbors:
|
|
new_labels[nid] = labels.get(nid, 0)
|
|
continue
|
|
|
|
# Most common label among neighbors
|
|
label_counts = defaultdict(int)
|
|
for neighbor in neighbors:
|
|
n_label = labels.get(neighbor)
|
|
if n_label is not None:
|
|
label_counts[n_label] += 1
|
|
|
|
if label_counts:
|
|
new_labels[nid] = max(label_counts, key=label_counts.get)
|
|
else:
|
|
new_labels[nid] = labels.get(nid, 0)
|
|
labels = new_labels
|
|
|
|
# 4. Group into communities
|
|
communities: dict[int, list[str]] = defaultdict(list)
|
|
for nid, label in labels.items():
|
|
communities[label].append(nid)
|
|
|
|
# 5. Sort by size, filter minimum
|
|
sorted_communities = sorted(
|
|
communities.items(),
|
|
key=lambda x: len(x[1]),
|
|
reverse=True,
|
|
)
|
|
|
|
result = []
|
|
for comm_id, (label, members) in enumerate(sorted_communities[:max_communities]): # noqa: B007
|
|
if len(members) < min_community_size:
|
|
break
|
|
|
|
# Get node types for community characterization
|
|
node_types = defaultdict(int)
|
|
for nid in members:
|
|
try:
|
|
node_data = await r.get(f"kg:node:{nid}")
|
|
if node_data:
|
|
if isinstance(node_data, bytes):
|
|
node_data = node_data.decode()
|
|
data = json.loads(node_data) if node_data.startswith("{") else {}
|
|
ntype = data.get("type", "unknown")
|
|
node_types[ntype] += 1
|
|
except Exception:
|
|
node_types["unknown"] += 1
|
|
|
|
# Get internal edges
|
|
internal_edges = 0
|
|
for nid in members:
|
|
for neighbor in adjacency.get(nid, set()):
|
|
if neighbor in members:
|
|
internal_edges += 1
|
|
|
|
result.append(
|
|
{
|
|
"community_id": comm_id,
|
|
"size": len(members),
|
|
"members": members[:20], # Top 20 members
|
|
"node_types": dict(node_types),
|
|
"internal_edges": internal_edges // 2, # Undirected
|
|
"density": round((internal_edges // 2) / max(len(members) * (len(members) - 1) / 2, 1), 4),
|
|
"dominant_type": max(node_types, key=node_types.get) if node_types else "unknown",
|
|
}
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
async def build_community_narrative(
|
|
community: dict[str, Any],
|
|
max_chars: int = 2000,
|
|
) -> str:
|
|
"""
|
|
Generate a narrative summary of a detected community.
|
|
|
|
Uses LLM to synthesize: what this cluster is, key addresses, risk profile.
|
|
Falls back to heuristic summary if LLM unavailable.
|
|
"""
|
|
community.get("members", [])
|
|
node_types = community.get("node_types", {})
|
|
dominant = community.get("dominant_type", "unknown")
|
|
|
|
heuristic = (
|
|
f"Community {community['community_id']}: {community['size']} nodes, "
|
|
f"primarily {dominant} addresses. "
|
|
f"Density: {community.get('density', 0):.3f}. "
|
|
f"Member types: {json.dumps(dict(node_types))}"
|
|
)
|
|
|
|
# Try LLM synthesis
|
|
try:
|
|
from app.investigation_narratives import AI_BASE, AI_MODEL, LLM_API_KEY
|
|
|
|
if not LLM_API_KEY:
|
|
return heuristic
|
|
|
|
prompt = f"""Summarize this crypto wallet community:
|
|
|
|
Size: {community["size"]} addresses
|
|
Primary type: {dominant}
|
|
Density: {community.get("density", 0):.3f}
|
|
Member types: {json.dumps(dict(node_types))}
|
|
|
|
What kind of cluster is this? (scammer ring, exchange wallets, MEV bot network, etc.)
|
|
What is the risk profile? Limit to 3 sentences."""
|
|
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
resp = await client.post(
|
|
AI_BASE,
|
|
headers={
|
|
"Authorization": f"Bearer {LLM_API_KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": AI_MODEL,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"max_tokens": 200,
|
|
},
|
|
)
|
|
if resp.status_code == 200:
|
|
return resp.json()["choices"][0]["message"]["content"]
|
|
except Exception:
|
|
pass
|
|
|
|
return heuristic
|
|
|
|
|
|
async def graph_rag_search(
|
|
query: str,
|
|
max_communities: int = 10,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Full Graph RAG pipeline: detect communities → generate narratives → return.
|
|
|
|
Returns communities with narrative summaries suitable for display.
|
|
"""
|
|
communities = await detect_communities(max_communities=max_communities)
|
|
|
|
# Generate narratives for top communities
|
|
enriched = []
|
|
for comm in communities[:5]:
|
|
narrative = await build_community_narrative(comm)
|
|
enriched.append({**comm, "narrative": narrative})
|
|
|
|
return {
|
|
"total_communities": len(communities),
|
|
"communities": enriched,
|
|
"total_nodes_in_communities": sum(c["size"] for c in communities),
|
|
"summary": (f"Detected {len(communities)} communities across {sum(c['size'] for c in communities)} addresses."),
|
|
}
|