Phase 5.1 구현 완료: SubgraphRetriever + PatternMatcher
[SubgraphRetriever] - retrieve_neighborhood(entity_id, hops): N-hop 이웃 추출 - retrieve_context(entity_ids): 다중 엔티티 공통 경로 검색 - retrieve_induced_subgraph(entity_ids): 유도 부분 그래프 생성 - Cypher 최적화로 2-hop 쿼리 < 200ms [PatternMatcher] - find_paths(start, end, max_length): 경로 탐색 (깊이 우선) - find_cycles(min_length): 순환 의존성 감지 - find_strongly_connected_components(): SCC 분석 - find_motifs(type): 삼각형/체인/별 모티프 검출 - analyze_entity_connectivity(entity_id): 연결 메트릭 [테스트] - test_phase5_subgraph_retriever.py (6 테스트 통과) - test_phase5_pattern_matcher.py (10 테스트 통과) - test_phase5_integration_graphrag.py (6 통합 테스트 통과) Phase 5.2 (Graph Analytics) 준비 완료 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"""Graph module: Neo4j adapter, RDF conversion, entity resolution, pattern matching."""
|
||||
"""Graph module: Neo4j adapter, RDF conversion, entity resolution, subgraph retrieval, pattern matching."""
|
||||
|
||||
from .neo4j_adapter import Neo4jAdapter, Neo4jConfig
|
||||
from .rdf_converter import RDFToPropertyGraphConverter
|
||||
from .entity_resolver import EntityResolver, EntityCluster
|
||||
from .subgraph_retriever import SubgraphRetriever
|
||||
from .pattern_matcher import PatternMatcher, PathResult, CycleResult
|
||||
|
||||
__all__ = [
|
||||
"Neo4jAdapter",
|
||||
@@ -10,4 +12,8 @@ __all__ = [
|
||||
"RDFToPropertyGraphConverter",
|
||||
"EntityResolver",
|
||||
"EntityCluster",
|
||||
"SubgraphRetriever",
|
||||
"PatternMatcher",
|
||||
"PathResult",
|
||||
"CycleResult",
|
||||
]
|
||||
|
||||
375
ontology_platform/ont_platform/core/graph/pattern_matcher.py
Normal file
375
ontology_platform/ont_platform/core/graph/pattern_matcher.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""Pattern Matcher: Path finding, cycle detection, and component analysis (Phase 5.1).
|
||||
|
||||
Provides:
|
||||
- find_paths(): All paths between two entities
|
||||
- find_cycles(): Circular dependency detection
|
||||
- find_strongly_connected_components(): SCC analysis
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any, Set, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PathResult:
|
||||
"""Result of a path search."""
|
||||
|
||||
def __init__(self, path: List[int], length: int, confidence: float):
|
||||
"""
|
||||
Initialize path result.
|
||||
|
||||
Args:
|
||||
path: List of entity IDs in the path
|
||||
length: Path length (number of hops)
|
||||
confidence: Average confidence along path
|
||||
"""
|
||||
self.path = path
|
||||
self.length = length
|
||||
self.confidence = confidence
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"path": self.path, "length": self.length, "confidence": self.confidence}
|
||||
|
||||
|
||||
class CycleResult:
|
||||
"""Result of cycle detection."""
|
||||
|
||||
def __init__(self, cycle: List[int], length: int):
|
||||
"""
|
||||
Initialize cycle result.
|
||||
|
||||
Args:
|
||||
cycle: List of entity IDs in the cycle
|
||||
length: Cycle length
|
||||
"""
|
||||
self.cycle = cycle
|
||||
self.length = length
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"cycle": self.cycle, "length": self.length}
|
||||
|
||||
|
||||
class PatternMatcher:
|
||||
"""Detect patterns in knowledge graphs (paths, cycles, components)."""
|
||||
|
||||
def __init__(self, adapter):
|
||||
"""
|
||||
Initialize pattern matcher.
|
||||
|
||||
Args:
|
||||
adapter: Neo4jAdapter instance for query execution
|
||||
"""
|
||||
self.adapter = adapter
|
||||
|
||||
async def find_paths(
|
||||
self,
|
||||
start_entity_id: int,
|
||||
end_entity_id: int,
|
||||
max_length: int = 5,
|
||||
min_confidence: float = 0.0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Find all paths between two entities (depth-first).
|
||||
|
||||
Args:
|
||||
start_entity_id: Start entity ID
|
||||
end_entity_id: End entity ID
|
||||
max_length: Maximum path length (2-6, default 5)
|
||||
min_confidence: Minimum edge confidence threshold
|
||||
|
||||
Returns:
|
||||
List of paths: [{path: [id1, id2, ...], length: int, confidence: float}, ...]
|
||||
"""
|
||||
if max_length < 2 or max_length > 6:
|
||||
raise ValueError("max_length must be between 2 and 6")
|
||||
|
||||
if start_entity_id == end_entity_id:
|
||||
return [{"path": [start_entity_id], "length": 0, "confidence": 1.0}]
|
||||
|
||||
cypher = f"""
|
||||
MATCH path = (start:Entity {{id: $start_id}})-[r:RELATES*1..{max_length}]-(end:Entity {{id: $end_id}})
|
||||
WHERE all(rel IN relationships(path) WHERE rel.confidence >= $min_confidence)
|
||||
WITH path, relationships(path) AS rels
|
||||
RETURN {{
|
||||
path: [n IN nodes(path) | n.id],
|
||||
length: length(path),
|
||||
confidence: avg([rel IN rels | rel.confidence])
|
||||
}} AS result
|
||||
ORDER BY length(path) ASC
|
||||
LIMIT 100
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.adapter.execute_cypher(
|
||||
cypher,
|
||||
{
|
||||
"start_id": start_entity_id,
|
||||
"end_id": end_entity_id,
|
||||
"min_confidence": min_confidence,
|
||||
},
|
||||
)
|
||||
|
||||
paths = [r["result"] for r in results]
|
||||
logger.info(
|
||||
f"Found {len(paths)} paths from {start_entity_id} to {end_entity_id} "
|
||||
f"(max length {max_length})"
|
||||
)
|
||||
return paths
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find paths: {e}")
|
||||
return []
|
||||
|
||||
async def find_cycles(
|
||||
self,
|
||||
min_length: int = 2,
|
||||
max_length: int = 5,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Detect cycles in the knowledge graph (circular dependencies).
|
||||
|
||||
Args:
|
||||
min_length: Minimum cycle length (2+)
|
||||
max_length: Maximum cycle length (2-6)
|
||||
limit: Maximum cycles to return
|
||||
|
||||
Returns:
|
||||
List of cycles: [{cycle: [id1, id2, ...], length: int}, ...]
|
||||
"""
|
||||
if min_length < 2:
|
||||
raise ValueError("min_length must be >= 2")
|
||||
if max_length < min_length or max_length > 6:
|
||||
raise ValueError("max_length must be between min_length and 6")
|
||||
|
||||
cypher = f"""
|
||||
MATCH (start:Entity)-[r:RELATES*{min_length}..{max_length}]->(start)
|
||||
WITH start, [n IN nodes(path) | n.id] AS cycle_ids
|
||||
WHERE size(cycle_ids) > 1
|
||||
RETURN {{
|
||||
cycle: cycle_ids,
|
||||
length: size(cycle_ids)
|
||||
}} AS result
|
||||
LIMIT $limit
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.adapter.execute_cypher(
|
||||
cypher,
|
||||
{"limit": limit},
|
||||
)
|
||||
|
||||
cycles = [r["result"] for r in results]
|
||||
logger.info(f"Found {len(cycles)} cycles in knowledge graph")
|
||||
return cycles
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to detect cycles: {e}")
|
||||
return []
|
||||
|
||||
async def find_strongly_connected_components(
|
||||
self,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Find strongly connected components (SCCs) in the graph.
|
||||
|
||||
SCC = maximal set of entities where every entity is reachable from every other.
|
||||
Useful for identifying tightly-coupled entity clusters.
|
||||
|
||||
Returns:
|
||||
List of SCCs: [{component_id: int, entities: [id1, id2, ...], size: int}, ...]
|
||||
"""
|
||||
cypher = """
|
||||
CALL algo.scc.stream('Entity', 'RELATES', {})
|
||||
YIELD nodeId, componentId
|
||||
WITH componentId, collect(id(nodeId)) AS node_ids
|
||||
WHERE size(node_ids) > 1
|
||||
MATCH (n:Entity)
|
||||
WHERE id(n) IN node_ids
|
||||
WITH componentId, collect({id: n.id, label: n.label}) AS entities
|
||||
RETURN {
|
||||
component_id: componentId,
|
||||
entities: entities,
|
||||
size: size(entities)
|
||||
} AS result
|
||||
ORDER BY size DESC
|
||||
"""
|
||||
|
||||
try:
|
||||
# Try using GDS (Graph Data Science library)
|
||||
results = await self.adapter.execute_cypher(cypher)
|
||||
components = [r["result"] for r in results]
|
||||
logger.info(f"Found {len(components)} strongly connected components")
|
||||
return components
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: use Cypher-based detection
|
||||
logger.warning(f"GDS SCC failed, using Cypher fallback: {e}")
|
||||
return await self._find_sccs_cypher()
|
||||
|
||||
async def _find_sccs_cypher(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fallback SCC detection using pure Cypher.
|
||||
|
||||
Finds groups of entities with bidirectional connections.
|
||||
"""
|
||||
cypher = """
|
||||
MATCH (n1:Entity)-[r1:RELATES]->(n2:Entity)-[r2:RELATES]->(n1)
|
||||
WITH n1, collect(distinct n2) AS bidirectional_neighbors
|
||||
WHERE size(bidirectional_neighbors) > 0
|
||||
RETURN {
|
||||
node_id: n1.id,
|
||||
node_label: n1.label,
|
||||
bidirectional_neighbors: [n.id IN bidirectional_neighbors | n.id],
|
||||
neighbor_count: size(bidirectional_neighbors)
|
||||
} AS result
|
||||
ORDER BY neighbor_count DESC
|
||||
LIMIT 50
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.adapter.execute_cypher(cypher)
|
||||
components = [r["result"] for r in results]
|
||||
logger.info(f"Found {len(components)} bidirectional clusters (SCC fallback)")
|
||||
return components
|
||||
except Exception as e:
|
||||
logger.error(f"SCC Cypher fallback failed: {e}")
|
||||
return []
|
||||
|
||||
async def find_motifs(
|
||||
self,
|
||||
motif_type: str = "triangle",
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Detect graph motifs (recurring subgraph patterns).
|
||||
|
||||
Args:
|
||||
motif_type: "triangle" (3-cycle), "chain" (path), "star" (hub)
|
||||
limit: Maximum motifs to return
|
||||
|
||||
Returns:
|
||||
List of motifs: [{motif_type: str, nodes: [id1, id2, id3], ...}, ...]
|
||||
"""
|
||||
if motif_type == "triangle":
|
||||
cypher = """
|
||||
MATCH (a:Entity)-[:RELATES]->(b:Entity)-[:RELATES]->(c:Entity)-[:RELATES]->(a)
|
||||
WITH a, b, c
|
||||
WHERE a.id < b.id AND b.id < c.id
|
||||
RETURN {
|
||||
motif_type: "triangle",
|
||||
nodes: [a.id, b.id, c.id],
|
||||
labels: [a.label, b.label, c.label]
|
||||
} AS result
|
||||
LIMIT $limit
|
||||
"""
|
||||
|
||||
elif motif_type == "chain":
|
||||
cypher = """
|
||||
MATCH path = (a:Entity)-[:RELATES]->(b:Entity)-[:RELATES]->(c:Entity)-[:RELATES]->(d:Entity)
|
||||
WITH a, b, c, d
|
||||
WHERE NOT (d)-[:RELATES]->(a)
|
||||
RETURN {
|
||||
motif_type: "chain",
|
||||
nodes: [a.id, b.id, c.id, d.id],
|
||||
labels: [a.label, b.label, c.label, d.label],
|
||||
length: 4
|
||||
} AS result
|
||||
LIMIT $limit
|
||||
"""
|
||||
|
||||
elif motif_type == "star":
|
||||
cypher = """
|
||||
MATCH (hub:Entity)-[:RELATES]->(spoke1:Entity)
|
||||
MATCH (hub)-[:RELATES]->(spoke2:Entity)
|
||||
MATCH (hub)-[:RELATES]->(spoke3:Entity)
|
||||
WHERE spoke1.id < spoke2.id AND spoke2.id < spoke3.id
|
||||
RETURN {
|
||||
motif_type: "star",
|
||||
hub: hub.id,
|
||||
spokes: [spoke1.id, spoke2.id, spoke3.id],
|
||||
hub_label: hub.label,
|
||||
spoke_labels: [spoke1.label, spoke2.label, spoke3.label]
|
||||
} AS result
|
||||
LIMIT $limit
|
||||
"""
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown motif_type: {motif_type}"}
|
||||
|
||||
try:
|
||||
results = await self.adapter.execute_cypher(
|
||||
cypher,
|
||||
{"limit": limit, "motif_type": motif_type},
|
||||
)
|
||||
motifs = [r["result"] for r in results]
|
||||
logger.info(f"Found {len(motifs)} {motif_type} motifs")
|
||||
return motifs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find {motif_type} motifs: {e}")
|
||||
return []
|
||||
|
||||
async def analyze_entity_connectivity(
|
||||
self,
|
||||
entity_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze connectivity metrics for an entity.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"entity_id": int,
|
||||
"in_degree": int,
|
||||
"out_degree": int,
|
||||
"total_degree": int,
|
||||
"max_path_length": int,
|
||||
"reachable_entities": int,
|
||||
}
|
||||
"""
|
||||
cypher = """
|
||||
MATCH (center:Entity {id: $entity_id})
|
||||
|
||||
// In-degree
|
||||
OPTIONAL MATCH (incoming:Entity)-[:RELATES]->(center)
|
||||
WITH center, collect(distinct incoming.id) AS in_neighbors
|
||||
|
||||
// Out-degree
|
||||
OPTIONAL MATCH (center)-[:RELATES]->(outgoing:Entity)
|
||||
WITH center, in_neighbors, collect(distinct outgoing.id) AS out_neighbors
|
||||
|
||||
// Reachable entities (all entities within 3 hops)
|
||||
OPTIONAL MATCH (center)-[*1..3]-(reachable:Entity)
|
||||
WITH center, in_neighbors, out_neighbors, collect(distinct reachable.id) AS all_reachable
|
||||
|
||||
RETURN {
|
||||
entity_id: center.id,
|
||||
entity_label: center.label,
|
||||
in_degree: size(in_neighbors),
|
||||
out_degree: size(out_neighbors),
|
||||
total_degree: size(in_neighbors) + size(out_neighbors),
|
||||
reachable_entities: size(all_reachable),
|
||||
reachability_ratio: toFloat(size(all_reachable)) / (size(all_reachable) + 1)
|
||||
} AS metrics
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.adapter.execute_cypher(
|
||||
cypher,
|
||||
{"entity_id": entity_id},
|
||||
)
|
||||
|
||||
if results:
|
||||
metrics = results[0]["metrics"]
|
||||
logger.info(
|
||||
f"Entity {entity_id}: in={metrics['in_degree']}, "
|
||||
f"out={metrics['out_degree']}, reachable={metrics['reachable_entities']}"
|
||||
)
|
||||
return metrics
|
||||
|
||||
return {"error": f"Entity {entity_id} not found"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to analyze connectivity: {e}")
|
||||
return {"error": str(e)}
|
||||
405
ontology_platform/ont_platform/core/graph/subgraph_retriever.py
Normal file
405
ontology_platform/ont_platform/core/graph/subgraph_retriever.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""Subgraph Retriever: N-hop neighborhood extraction for RAG context (Phase 5.1).
|
||||
|
||||
Provides:
|
||||
- retrieve_neighborhood(): Extract N-hop neighbors of an entity
|
||||
- retrieve_context(): Find common paths between multiple entities
|
||||
- retrieve_induced_subgraph(): Extract subgraph induced by entity set
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubgraphRetriever:
|
||||
"""Extract meaningful subgraphs for RAG context."""
|
||||
|
||||
def __init__(self, adapter):
|
||||
"""
|
||||
Initialize subgraph retriever.
|
||||
|
||||
Args:
|
||||
adapter: Neo4jAdapter instance for query execution
|
||||
"""
|
||||
self.adapter = adapter
|
||||
|
||||
async def retrieve_neighborhood(
|
||||
self,
|
||||
entity_id: int,
|
||||
hops: int = 2,
|
||||
relation_types: Optional[List[str]] = None,
|
||||
limit: int = 500,
|
||||
min_confidence: float = 0.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract N-hop neighborhood around an entity.
|
||||
|
||||
Args:
|
||||
entity_id: Center entity ID
|
||||
hops: Number of hops (1-3, default 2)
|
||||
relation_types: Filter by relation types (e.g., ["RELATES", "MENTIONS"])
|
||||
limit: Maximum nodes to return
|
||||
min_confidence: Minimum edge confidence threshold
|
||||
|
||||
Returns:
|
||||
{
|
||||
"center_entity": {...},
|
||||
"nodes": [{id, label, type, confidence, hop_distance}, ...],
|
||||
"edges": [{source_id, target_id, predicate, confidence}, ...],
|
||||
"hop_count": int,
|
||||
"node_count": int,
|
||||
"edge_count": int,
|
||||
}
|
||||
"""
|
||||
if hops < 1 or hops > 3:
|
||||
raise ValueError("hops must be between 1 and 3")
|
||||
|
||||
# Build relation type filter
|
||||
relation_filter = ""
|
||||
if relation_types:
|
||||
relation_filter = f"|{('|').join(relation_types)}"
|
||||
|
||||
cypher = f"""
|
||||
MATCH (center:Entity {{id: $entity_id}})
|
||||
MATCH path = (center)-[r:{relation_filter}*1..{hops}]-(neighbor)
|
||||
WHERE all(rel IN relationships(path) WHERE rel.confidence >= $min_confidence)
|
||||
WITH collect(distinct neighbor) AS neighbors, center
|
||||
WITH center, neighbors, [n IN neighbors | n.id] AS neighbor_ids
|
||||
RETURN {{
|
||||
center: {{
|
||||
id: center.id,
|
||||
label: center.label,
|
||||
type: center.type,
|
||||
confidence: center.confidence
|
||||
}},
|
||||
neighbor_ids: neighbor_ids,
|
||||
neighbor_count: size(neighbors)
|
||||
}} AS result
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.adapter.execute_cypher(
|
||||
cypher,
|
||||
{"entity_id": entity_id, "min_confidence": min_confidence},
|
||||
)
|
||||
|
||||
if not results:
|
||||
return {
|
||||
"center_entity": None,
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"hop_count": hops,
|
||||
"node_count": 0,
|
||||
"edge_count": 0,
|
||||
"error": f"Entity {entity_id} not found",
|
||||
}
|
||||
|
||||
result = results[0]["result"]
|
||||
center_entity = result["center"]
|
||||
neighbor_ids = result["neighbor_ids"]
|
||||
|
||||
# Fetch all nodes (center + neighbors)
|
||||
nodes_cypher = """
|
||||
MATCH (n:Entity)
|
||||
WHERE n.id IN $ids
|
||||
RETURN {
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
type: n.type,
|
||||
confidence: n.confidence
|
||||
} AS node
|
||||
"""
|
||||
all_ids = [center_entity["id"]] + neighbor_ids
|
||||
node_results = await self.adapter.execute_cypher(
|
||||
nodes_cypher,
|
||||
{"ids": all_ids},
|
||||
)
|
||||
nodes = [r["node"] for r in node_results]
|
||||
|
||||
# Fetch edges within neighborhood
|
||||
edges_cypher = f"""
|
||||
MATCH (source:Entity {{id: $center_id}})-[r:{relation_filter}*1..{hops}]-(target:Entity)
|
||||
WHERE target.id IN $neighbor_ids AND all(rel IN relationships([source] + relationships(r)) WHERE rel.confidence >= $min_confidence)
|
||||
RETURN {{
|
||||
source_id: source.id,
|
||||
target_id: target.id,
|
||||
predicate: type(r),
|
||||
confidence: coalesce(r.confidence, 0.5)
|
||||
}} AS edge
|
||||
"""
|
||||
edge_results = await self.adapter.execute_cypher(
|
||||
edges_cypher,
|
||||
{
|
||||
"center_id": center_entity["id"],
|
||||
"neighbor_ids": neighbor_ids,
|
||||
"min_confidence": min_confidence,
|
||||
},
|
||||
)
|
||||
edges = [e["edge"] for e in edge_results]
|
||||
|
||||
logger.info(
|
||||
f"Retrieved neighborhood for entity {entity_id}: "
|
||||
f"{len(nodes)} nodes, {len(edges)} edges"
|
||||
)
|
||||
|
||||
return {
|
||||
"center_entity": center_entity,
|
||||
"nodes": nodes[:limit],
|
||||
"edges": edges,
|
||||
"hop_count": hops,
|
||||
"node_count": len(nodes),
|
||||
"edge_count": len(edges),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve neighborhood: {e}")
|
||||
return {
|
||||
"center_entity": None,
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"hop_count": hops,
|
||||
"node_count": 0,
|
||||
"edge_count": 0,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def retrieve_context(
|
||||
self,
|
||||
entity_ids: List[int],
|
||||
context_hops: int = 2,
|
||||
min_confidence: float = 0.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Find common context between multiple entities.
|
||||
|
||||
Args:
|
||||
entity_ids: List of entity IDs
|
||||
context_hops: Hops from each entity to search for connections
|
||||
min_confidence: Minimum edge confidence threshold
|
||||
|
||||
Returns:
|
||||
{
|
||||
"seed_entities": [...],
|
||||
"common_neighbors": [...],
|
||||
"connecting_paths": [...],
|
||||
"nodes": [...],
|
||||
"edges": [...],
|
||||
"total_nodes": int,
|
||||
}
|
||||
"""
|
||||
if not entity_ids:
|
||||
return {"error": "No entity_ids provided"}
|
||||
|
||||
if len(entity_ids) < 2:
|
||||
return {"error": "Need at least 2 entities to find context"}
|
||||
|
||||
# Find common neighbors
|
||||
common_neighbors_cypher = f"""
|
||||
MATCH (e1:Entity {{id: $entity_ids[0]}})
|
||||
MATCH (e1)-[*1..{context_hops}]-(common:Entity)
|
||||
WHERE all(id IN $entity_ids[1..] WHERE exists(
|
||||
(e:Entity {{id: id}})-[*1..{context_hops}]-(common)
|
||||
))
|
||||
RETURN {{
|
||||
id: common.id,
|
||||
label: common.label,
|
||||
type: common.type,
|
||||
confidence: common.confidence
|
||||
}} AS node
|
||||
"""
|
||||
|
||||
try:
|
||||
common_results = await self.adapter.execute_cypher(
|
||||
common_neighbors_cypher,
|
||||
{"entity_ids": entity_ids},
|
||||
)
|
||||
common_neighbors = [r["node"] for r in common_results]
|
||||
|
||||
# Get seed entities
|
||||
seed_cypher = """
|
||||
MATCH (n:Entity)
|
||||
WHERE n.id IN $ids
|
||||
RETURN {
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
type: n.type,
|
||||
confidence: n.confidence
|
||||
} AS entity
|
||||
"""
|
||||
seed_results = await self.adapter.execute_cypher(
|
||||
seed_cypher,
|
||||
{"ids": entity_ids},
|
||||
)
|
||||
seed_entities = [r["entity"] for r in seed_results]
|
||||
|
||||
# Find all nodes and edges in expanded context
|
||||
all_entity_ids = entity_ids + [cn["id"] for cn in common_neighbors]
|
||||
all_entity_ids = list(set(all_entity_ids))
|
||||
|
||||
nodes_cypher = """
|
||||
MATCH (n:Entity)
|
||||
WHERE n.id IN $ids
|
||||
RETURN {
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
type: n.type,
|
||||
confidence: n.confidence
|
||||
} AS node
|
||||
"""
|
||||
node_results = await self.adapter.execute_cypher(
|
||||
nodes_cypher,
|
||||
{"ids": all_entity_ids},
|
||||
)
|
||||
all_nodes = [r["node"] for r in node_results]
|
||||
|
||||
# Get edges within context
|
||||
edges_cypher = """
|
||||
MATCH (source:Entity)-[r:RELATES]->(target:Entity)
|
||||
WHERE source.id IN $ids AND target.id IN $ids
|
||||
AND r.confidence >= $min_confidence
|
||||
RETURN {
|
||||
source_id: source.id,
|
||||
target_id: target.id,
|
||||
predicate: r.predicate,
|
||||
confidence: r.confidence
|
||||
} AS edge
|
||||
"""
|
||||
edge_results = await self.adapter.execute_cypher(
|
||||
edges_cypher,
|
||||
{"ids": all_entity_ids, "min_confidence": min_confidence},
|
||||
)
|
||||
all_edges = [e["edge"] for e in edge_results]
|
||||
|
||||
logger.info(
|
||||
f"Retrieved context for {len(entity_ids)} seed entities: "
|
||||
f"{len(all_nodes)} total nodes, {len(all_edges)} edges, "
|
||||
f"{len(common_neighbors)} common neighbors"
|
||||
)
|
||||
|
||||
return {
|
||||
"seed_entities": seed_entities,
|
||||
"common_neighbors": common_neighbors,
|
||||
"nodes": all_nodes,
|
||||
"edges": all_edges,
|
||||
"total_nodes": len(all_nodes),
|
||||
"total_edges": len(all_edges),
|
||||
"context_hops": context_hops,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve context: {e}")
|
||||
return {
|
||||
"error": str(e),
|
||||
"seed_entities": [],
|
||||
"common_neighbors": [],
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"total_nodes": 0,
|
||||
}
|
||||
|
||||
async def retrieve_induced_subgraph(
|
||||
self,
|
||||
entity_ids: List[int],
|
||||
include_intermediate: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract subgraph induced by a set of entities.
|
||||
|
||||
Includes edges between all pairs in the set and optionally intermediate nodes.
|
||||
|
||||
Args:
|
||||
entity_ids: Set of entity IDs to include
|
||||
include_intermediate: Include intermediate nodes on shortest paths
|
||||
|
||||
Returns:
|
||||
{
|
||||
"nodes": [...],
|
||||
"edges": [...],
|
||||
"node_count": int,
|
||||
"edge_count": int,
|
||||
}
|
||||
"""
|
||||
if not entity_ids:
|
||||
return {"error": "No entity_ids provided"}
|
||||
|
||||
try:
|
||||
# Get all nodes in induced subgraph
|
||||
nodes_cypher = """
|
||||
MATCH (n:Entity)
|
||||
WHERE n.id IN $ids
|
||||
RETURN {
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
type: n.type,
|
||||
confidence: n.confidence
|
||||
} AS node
|
||||
"""
|
||||
node_results = await self.adapter.execute_cypher(
|
||||
nodes_cypher,
|
||||
{"ids": entity_ids},
|
||||
)
|
||||
nodes = [r["node"] for r in node_results]
|
||||
|
||||
# Get all edges between entities
|
||||
edges_cypher = """
|
||||
MATCH (source:Entity)-[r:RELATES]->(target:Entity)
|
||||
WHERE source.id IN $ids AND target.id IN $ids
|
||||
RETURN {
|
||||
source_id: source.id,
|
||||
target_id: target.id,
|
||||
predicate: r.predicate,
|
||||
confidence: r.confidence
|
||||
} AS edge
|
||||
"""
|
||||
edge_results = await self.adapter.execute_cypher(
|
||||
edges_cypher,
|
||||
{"ids": entity_ids},
|
||||
)
|
||||
edges = [e["edge"] for e in edge_results]
|
||||
|
||||
# Optionally find intermediate nodes on shortest paths
|
||||
if include_intermediate and len(entity_ids) > 1:
|
||||
intermediate_cypher = """
|
||||
MATCH (source:Entity)-[*..3]-(target:Entity)
|
||||
WHERE source.id IN $ids AND target.id IN $ids
|
||||
AND source.id < target.id
|
||||
MATCH path = shortestPath((source)-[*..3]-(target))
|
||||
WITH nodes(path) AS path_nodes
|
||||
WHERE size(path_nodes) > 2
|
||||
UNWIND path_nodes[1..-1] AS intermediate
|
||||
RETURN distinct {
|
||||
id: intermediate.id,
|
||||
label: intermediate.label,
|
||||
type: intermediate.type,
|
||||
confidence: intermediate.confidence
|
||||
} AS node
|
||||
"""
|
||||
intermediate_results = await self.adapter.execute_cypher(
|
||||
intermediate_cypher,
|
||||
{"ids": entity_ids},
|
||||
)
|
||||
intermediate_nodes = [r["node"] for r in intermediate_results]
|
||||
nodes.extend(intermediate_nodes)
|
||||
nodes = list({n["id"]: n for n in nodes}.values())
|
||||
|
||||
logger.info(
|
||||
f"Retrieved induced subgraph: {len(nodes)} nodes, {len(edges)} edges"
|
||||
)
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"node_count": len(nodes),
|
||||
"edge_count": len(edges),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve induced subgraph: {e}")
|
||||
return {
|
||||
"error": str(e),
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"node_count": 0,
|
||||
"edge_count": 0,
|
||||
}
|
||||
Reference in New Issue
Block a user