"""Graph traversal: entity expansion, trial lookup, evidence retrieval.""" from __future__ import annotations import networkx as nx from config import KG_EXPANSION_HOPS, KG_MIN_EDGE_CONFIDENCE from extraction.normalizer import normalize_entity, _GENE_ALIASES, _COMPOUND_ALIASES from logging_config import get_logger _logger = get_logger("graph.query") def expand_query_entities( G: nx.DiGraph, entity_names: list[str], max_hops: int = KG_EXPANSION_HOPS, ) -> list[str]: """ Map entity names to graph node IDs, then BFS-expand up to max_hops. Returns a deduplicated list of entity display_names for RAG search. Only traverses edges above KG_MIN_EDGE_CONFIDENCE. """ if not G or not entity_names: return entity_names # Phase 1: map names to canonical node IDs seed_nodes: set[str] = set() for name in entity_names: matched = _find_node(G, name) if matched: seed_nodes.update(matched) else: _logger.debug(f"No graph node for query entity: {name!r}") if not seed_nodes: return entity_names # fall through to lexical RAG search # Phase 2: BFS expansion frontier = set(seed_nodes) expanded = set(seed_nodes) for _ in range(max_hops): next_frontier: set[str] = set() for node in frontier: for neighbor in list(G.successors(node)) + list(G.predecessors(node)): if neighbor in expanded: continue # Only follow confident edges edge_data = G.get_edge_data(node, neighbor) or G.get_edge_data(neighbor, node) or {} if edge_data.get("confidence", 1.0) >= KG_MIN_EDGE_CONFIDENCE: # Skip trial nodes — they inflate retrieval noise if G.nodes[neighbor].get("type") != "ClinicalTrial": next_frontier.add(neighbor) expanded.update(next_frontier) frontier = next_frontier # Phase 3: convert canonical IDs back to display names for RAG text queries display_names: list[str] = [] seen: set[str] = set() for node_id in expanded: if node_id.startswith("trial:"): continue name = G.nodes[node_id].get("display_name", node_id.split(":", 1)[-1]) if name not in seen: display_names.append(name) seen.add(name) _logger.info( f"KG expansion: {len(entity_names)} query entities → {len(display_names)} expanded", extra={"data": {"seeds": list(seed_nodes), "expanded_count": len(expanded)}}, ) return display_names def find_trials_for_entities( G: nx.DiGraph, entity_names: list[str], max_trials: int = 5, ) -> list[dict]: """Return clinical trials linked to the given entity names.""" if not G or not entity_names: return [] target_nodes: set[str] = set() for name in entity_names: matched = _find_node(G, name) target_nodes.update(matched) trials: list[dict] = [] seen: set[str] = set() for node_id in target_nodes: # Trials point TO their targets, so look at predecessors for pred in G.predecessors(node_id): if not pred.startswith("trial:"): continue nct_id = G.nodes[pred].get("nct_id", "") if nct_id in seen: continue seen.add(nct_id) trials.append({ "nct_id": nct_id, "title": G.nodes[pred].get("display_name", ""), "phase": G.nodes[pred].get("phase", ""), "status": G.nodes[pred].get("status", ""), "url": G.nodes[pred].get("url", ""), }) if len(trials) >= max_trials: break if len(trials) >= max_trials: break return trials def get_entity_evidence(G: nx.DiGraph, canonical_id: str) -> dict: """Return node attributes + connected entity names for a canonical ID.""" if not G.has_node(canonical_id): return {} attrs = dict(G.nodes[canonical_id]) attrs["neighbors"] = [ { "id": n, "display_name": G.nodes[n].get("display_name", n), "relation": G.get_edge_data(canonical_id, n, {}).get("relation_type", ""), } for n in G.successors(canonical_id) if G.nodes[n].get("type") != "ClinicalTrial" ] return attrs def _find_node(G: nx.DiGraph, name: str) -> list[str]: """Map a raw entity name to zero or more graph node IDs.""" hits: list[str] = [] # 1. Try each entity type prefix for etype in ("Gene", "Protein", "Compound", "Mechanism", "Pathway", "Phenotype"): candidate = normalize_entity(name, etype) if G.has_node(candidate): hits.append(candidate) if hits: return hits # 2. Case-insensitive display_name match name_lower = name.lower() for node_id, data in G.nodes(data=True): display = data.get("display_name", "").lower() if display == name_lower or name_lower in display: hits.append(node_id) return hits