graph_engine.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. #!/usr/bin/env python3
  2. """
  3. Entity Relation Graph Engine
  4. Loads relations from wiki compilation, provides N-hop traversal
  5. for query expansion and relation-based retrieval.
  6. Lambda: Tool("graph_walk", λ(entities, n_hops). BFS → subgraph)
  7. """
  8. import json
  9. import os
  10. import re
  11. from collections import defaultdict, deque
  12. from pathlib import Path
  13. class EntityGraph:
  14. """
  15. Adjacency-list graph built from wiki relations.json.
  16. Each relation triple: {subject, relation, object, description, source}
  17. Example: 海事局 --隶属--> 交通运输部
  18. """
  19. def __init__(self):
  20. # entity → [(relation_type, target, source_doc, description)]
  21. self.adjacency = defaultdict(list)
  22. # entity → [(relation_type, source_entity, source_doc)]
  23. self.reverse = defaultdict(list)
  24. # entity metadata
  25. self.entity_meta = {}
  26. # all unique relation types
  27. self.relation_types = set()
  28. # stats
  29. self.num_entities = 0
  30. self.num_relations = 0
  31. def load(self, relations_path=None, progress_path=None, wiki_dir=None):
  32. """Load graph from relations.json or compile_progress.json"""
  33. loaded = False
  34. # Try relations.json first
  35. if relations_path and Path(relations_path).exists():
  36. with open(relations_path, 'r', encoding='utf-8') as f:
  37. relations = json.load(f)
  38. self._ingest_relations(relations)
  39. loaded = True
  40. # Try .compile_progress.json
  41. if progress_path and Path(progress_path).exists():
  42. with open(progress_path, 'r', encoding='utf-8') as f:
  43. progress = json.load(f)
  44. relations = progress.get('relations', [])
  45. if relations:
  46. self._ingest_relations(relations)
  47. loaded = True
  48. # Also load entity metadata
  49. for ename, einfo in progress.get('entities', {}).items():
  50. if ename not in self.entity_meta:
  51. self.entity_meta[ename] = {
  52. 'type': einfo.get('type', 'unknown'),
  53. 'refs_count': len(einfo.get('refs', [])),
  54. }
  55. # Supplement from wiki entity pages
  56. if wiki_dir:
  57. entities_dir = Path(wiki_dir) / 'entities'
  58. if entities_dir.exists():
  59. for f in entities_dir.glob('*.md'):
  60. ename = f.stem
  61. if ename not in self.entity_meta:
  62. self.entity_meta[ename] = {'type': 'unknown', 'refs_count': 0}
  63. self.num_entities = max(self.num_entities, len(self.entity_meta))
  64. if loaded:
  65. self.num_entities = len(set(
  66. list(self.adjacency.keys()) + list(self.reverse.keys()) + list(self.entity_meta.keys())
  67. ))
  68. return loaded
  69. def _ingest_relations(self, relations):
  70. """Add relation triples to graph"""
  71. for r in relations:
  72. subj = r.get('subject', '').strip()
  73. rel = r.get('relation', '').strip()
  74. obj = r.get('object', '').strip()
  75. desc = r.get('description', '')
  76. src = r.get('source', '')
  77. if not subj or not rel or not obj:
  78. continue
  79. self.adjacency[subj].append((rel, obj, src, desc))
  80. self.reverse[obj].append((rel, subj, src))
  81. self.relation_types.add(rel)
  82. self.num_relations += 1
  83. def find_entities(self, text, max_results=10):
  84. """Fuzzy match text against known entity names"""
  85. text_lower = text.lower()
  86. all_entities = set(self.adjacency.keys()) | set(self.reverse.keys()) | set(self.entity_meta.keys())
  87. matches = []
  88. for ename in all_entities:
  89. ename_lower = ename.lower()
  90. # Exact substring match
  91. if ename_lower in text_lower or text_lower in ename_lower:
  92. # Score: longer match = better
  93. score = len(ename) / max(len(text), 1)
  94. matches.append((ename, score))
  95. # Sort by score descending, deduplicate
  96. matches.sort(key=lambda x: -x[1])
  97. seen = set()
  98. result = []
  99. for name, score in matches:
  100. if name not in seen:
  101. seen.add(name)
  102. result.append(name)
  103. if len(result) >= max_results:
  104. break
  105. return result
  106. def walk(self, seed_entities, max_hops=2, max_nodes=20):
  107. """
  108. BFS N-hop traversal from seed entities.
  109. Returns subgraph with entities, relations, and paths.
  110. """
  111. visited = set()
  112. queue = deque()
  113. hop_counts = {}
  114. paths = {}
  115. found_relations = []
  116. # Initialize with seeds
  117. for e in seed_entities:
  118. if e in self.adjacency or e in self.reverse or e in self.entity_meta:
  119. queue.append((e, 0, []))
  120. visited.add(e)
  121. hop_counts[e] = 0
  122. paths[e] = []
  123. while queue and len(visited) < max_nodes:
  124. entity, hop, path = queue.popleft()
  125. if hop >= max_hops:
  126. continue
  127. # Forward edges
  128. for rel, target, src, desc in self.adjacency.get(entity, []):
  129. found_relations.append({
  130. 'subject': entity,
  131. 'relation': rel,
  132. 'object': target,
  133. 'source': src,
  134. })
  135. if target not in visited and len(visited) < max_nodes:
  136. visited.add(target)
  137. hop_counts[target] = hop + 1
  138. new_path = path + [f"{entity} --{rel}--> {target}"]
  139. paths[target] = new_path
  140. queue.append((target, hop + 1, new_path))
  141. # Reverse edges
  142. for rel, source_entity, src in self.reverse.get(entity, []):
  143. found_relations.append({
  144. 'subject': source_entity,
  145. 'relation': rel,
  146. 'object': entity,
  147. 'source': src,
  148. })
  149. if source_entity not in visited and len(visited) < max_nodes:
  150. visited.add(source_entity)
  151. hop_counts[source_entity] = hop + 1
  152. new_path = path + [f"{source_entity} --{rel}--> {entity}"]
  153. paths[source_entity] = new_path
  154. queue.append((source_entity, hop + 1, new_path))
  155. # Deduplicate relations
  156. seen_rels = set()
  157. unique_rels = []
  158. for r in found_relations:
  159. key = (r['subject'], r['relation'], r['object'])
  160. if key not in seen_rels:
  161. seen_rels.add(key)
  162. unique_rels.append(r)
  163. return {
  164. 'entities': list(visited),
  165. 'relations': unique_rels,
  166. 'paths': paths,
  167. 'hop_counts': hop_counts,
  168. }
  169. def expand_query(self, query, seed_entities=None, max_hops=2):
  170. """
  171. Expand a query using graph-discovered related entities.
  172. Returns expanded query string + relation context.
  173. """
  174. if seed_entities is None:
  175. seed_entities = self.find_entities(query, max_results=5)
  176. if not seed_entities:
  177. return {'expanded_query': query, 'entities': [], 'relations': [], 'relation_context': ''}
  178. walked = self.walk(seed_entities, max_hops=max_hops)
  179. # Build relation context for LLM prompt
  180. relation_lines = []
  181. for r in walked['relations'][:15]:
  182. relation_lines.append(f"- {r['subject']} --{r['relation']}--> {r['object']}")
  183. relation_context = '\n'.join(relation_lines)
  184. # Expand query with discovered entity names
  185. new_terms = [e for e in walked['entities'] if e not in seed_entities][:10]
  186. expanded_query = query
  187. if new_terms:
  188. expanded_query += '\n\n[Related: ' + ', '.join(new_terms) + ']'
  189. return {
  190. 'expanded_query': expanded_query,
  191. 'entities': walked['entities'],
  192. 'relations': walked['relations'],
  193. 'relation_context': relation_context,
  194. 'seed_entities': seed_entities,
  195. }
  196. def shortest_path(self, entity_a, entity_b, max_depth=5):
  197. """Find shortest relation path between two entities"""
  198. if entity_a == entity_b:
  199. return [entity_a]
  200. visited = {entity_a}
  201. queue = deque([(entity_a, [entity_a])])
  202. while queue:
  203. current, path = queue.popleft()
  204. if len(path) > max_depth:
  205. break
  206. for rel, target, _, _ in self.adjacency.get(current, []):
  207. if target == entity_b:
  208. return path + [f"--{rel}-->", target]
  209. if target not in visited:
  210. visited.add(target)
  211. queue.append((target, path + [f"--{rel}-->", target]))
  212. for rel, source, _ in self.reverse.get(current, []):
  213. if source == entity_b:
  214. return path + [f"<--{rel}--", source]
  215. if source not in visited:
  216. visited.add(source)
  217. queue.append((source, path + [f"<--{rel}--", source]))
  218. return None # No path found
  219. def stats(self):
  220. return {
  221. 'entities': self.num_entities,
  222. 'relations': self.num_relations,
  223. 'relation_types': len(self.relation_types),
  224. 'top_relation_types': sorted(
  225. [(rt, sum(1 for edges in self.adjacency.values() for e in edges if e[0] == rt))
  226. for rt in self.relation_types],
  227. key=lambda x: -x[1]
  228. )[:10],
  229. }
  230. # ── Singleton loader ──
  231. _graph_cache = None
  232. def get_graph(wiki_dir=None, config_path=None):
  233. """Load or return cached EntityGraph"""
  234. global _graph_cache
  235. if _graph_cache is not None:
  236. return _graph_cache
  237. graph = EntityGraph()
  238. # Resolve paths from config
  239. if config_path:
  240. import yaml
  241. with open(config_path) as f:
  242. cfg = yaml.safe_load(f)
  243. wiki_cfg = cfg.get('wiki', cfg.get('retrieval', {}).get('skills', {}).get('wiki', {}))
  244. wiki_dir = wiki_cfg.get('dir', wiki_dir)
  245. if wiki_dir is None:
  246. wiki_dir = os.environ.get('WIKI_DIR', '/app/agentexample/qaagent67wiki/wiki')
  247. wiki_path = Path(wiki_dir)
  248. relations_file = wiki_path / 'relations.json'
  249. progress_file = wiki_path / '.compile_progress.json'
  250. graph.load(
  251. relations_path=str(relations_file) if relations_file.exists() else None,
  252. progress_path=str(progress_file) if progress_file.exists() else None,
  253. wiki_dir=str(wiki_path),
  254. )
  255. _graph_cache = graph
  256. return graph
  257. if __name__ == '__main__':
  258. import sys
  259. wiki_dir = sys.argv[1] if len(sys.argv) > 1 else '/app/agentexample/qaagent67wiki/wiki'
  260. graph = get_graph(wiki_dir=wiki_dir)
  261. stats = graph.stats()
  262. print(f"Graph loaded: {stats['entities']} entities, {stats['relations']} relations")
  263. print(f"Relation types: {stats['relation_types']}")
  264. for rt, count in stats['top_relation_types'][:5]:
  265. print(f" {rt}: {count}")
  266. # Test query expansion
  267. query = sys.argv[2] if len(sys.argv) > 2 else "海上交通安全法与水污染防治法"
  268. print(f"\nExpanding: {query}")
  269. result = graph.expand_query(query)
  270. print(f"Seed entities: {result['seed_entities']}")
  271. print(f"All entities ({len(result['entities'])}): {result['entities'][:10]}")
  272. print(f"Relations ({len(result['relations'])}):")
  273. for r in result['relations'][:5]:
  274. print(f" {r['subject']} --{r['relation']}--> {r['object']}")