Files
wehub-resource-sync 4a19d70af1
Lint with Ruff / ruff (push) Has been cancelled
MCP Server Tests / live-mcp-tests (push) Has been cancelled
Tests / unit-tests (push) Has been cancelled
Tests / database-integration-tests (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Server Tests / live-server-tests (push) Has been cancelled
Pyright Type Check / pyright (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:54 +08:00

774 lines
24 KiB
Python

from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from pydantic import BaseModel
from graphiti_core.edges import EntityEdge
from graphiti_core.nodes import EntityNode, EpisodicNode
from graphiti_core.search.search_config import SearchResults
from graphiti_core.utils.maintenance.edge_operations import (
extract_edges,
resolve_extracted_edge,
resolve_extracted_edges,
)
@pytest.fixture
def mock_llm_client():
client = MagicMock()
client.generate_response = AsyncMock()
return client
@pytest.fixture
def mock_extracted_edge():
return EntityEdge(
source_node_uuid='source_uuid',
target_node_uuid='target_uuid',
name='test_edge',
group_id='group_1',
fact='Test fact',
episodes=['episode_1'],
created_at=datetime.now(timezone.utc),
valid_at=None,
invalid_at=None,
)
@pytest.fixture
def mock_related_edges():
return [
EntityEdge(
source_node_uuid='source_uuid_2',
target_node_uuid='target_uuid_2',
name='related_edge',
group_id='group_1',
fact='Related fact',
episodes=['episode_2'],
created_at=datetime.now(timezone.utc) - timedelta(days=1),
valid_at=datetime.now(timezone.utc) - timedelta(days=1),
invalid_at=None,
)
]
@pytest.fixture
def mock_existing_edges():
return [
EntityEdge(
source_node_uuid='source_uuid_3',
target_node_uuid='target_uuid_3',
name='existing_edge',
group_id='group_1',
fact='Existing fact',
episodes=['episode_3'],
created_at=datetime.now(timezone.utc) - timedelta(days=2),
valid_at=datetime.now(timezone.utc) - timedelta(days=2),
invalid_at=None,
)
]
@pytest.fixture
def mock_current_episode():
return EpisodicNode(
uuid='episode_1',
content='Current episode content',
valid_at=datetime.now(timezone.utc),
name='Current Episode',
group_id='group_1',
source='message',
source_description='Test source description',
)
@pytest.fixture
def mock_previous_episodes():
return [
EpisodicNode(
uuid='episode_2',
content='Previous episode content',
valid_at=datetime.now(timezone.utc) - timedelta(days=1),
name='Previous Episode',
group_id='group_1',
source='message',
source_description='Test source description',
)
]
# Run the tests
if __name__ == '__main__':
pytest.main([__file__])
@pytest.mark.asyncio
async def test_resolve_extracted_edge_exact_fact_short_circuit(
mock_llm_client,
mock_existing_edges,
mock_current_episode,
):
extracted = EntityEdge(
source_node_uuid='source_uuid',
target_node_uuid='target_uuid',
name='test_edge',
group_id='group_1',
fact='Related fact',
episodes=['episode_1'],
created_at=datetime.now(timezone.utc),
valid_at=None,
invalid_at=None,
)
related_edges = [
EntityEdge(
source_node_uuid='source_uuid',
target_node_uuid='target_uuid',
name='related_edge',
group_id='group_1',
fact=' related FACT ',
episodes=['episode_2'],
created_at=datetime.now(timezone.utc) - timedelta(days=1),
valid_at=None,
invalid_at=None,
)
]
resolved_edge, duplicate_edges, invalidated = await resolve_extracted_edge(
mock_llm_client,
extracted,
related_edges,
mock_existing_edges,
mock_current_episode,
edge_type_candidates=None,
)
assert resolved_edge is related_edges[0]
assert resolved_edge.episodes.count(mock_current_episode.uuid) == 1
assert duplicate_edges == []
assert invalidated == []
mock_llm_client.generate_response.assert_not_called()
class OccurredAtEdge(BaseModel):
"""Edge model stub for OCCURRED_AT."""
@pytest.mark.asyncio
async def test_resolve_extracted_edges_keeps_unknown_names(monkeypatch):
from graphiti_core.utils.maintenance import edge_operations as edge_ops
monkeypatch.setattr(edge_ops, 'create_entity_edge_embeddings', AsyncMock(return_value=None))
monkeypatch.setattr(EntityEdge, 'get_between_nodes', AsyncMock(return_value=[]))
async def immediate_gather(*aws, max_coroutines=None):
return [await aw for aw in aws]
monkeypatch.setattr(edge_ops, 'semaphore_gather', immediate_gather)
monkeypatch.setattr(edge_ops, 'search', AsyncMock(return_value=SearchResults()))
llm_client = MagicMock()
llm_client.generate_response = AsyncMock(
return_value={
'duplicate_facts': [],
'contradicted_facts': [],
}
)
clients = SimpleNamespace(
driver=MagicMock(),
llm_client=llm_client,
embedder=MagicMock(),
cross_encoder=MagicMock(),
)
source_node = EntityNode(
uuid='source_uuid',
name='User Node',
group_id='group_1',
labels=['User'],
)
target_node = EntityNode(
uuid='target_uuid',
name='Topic Node',
group_id='group_1',
labels=['Topic'],
)
extracted_edge = EntityEdge(
source_node_uuid=source_node.uuid,
target_node_uuid=target_node.uuid,
name='INTERACTED_WITH',
group_id='group_1',
fact='User interacted with topic',
episodes=[],
created_at=datetime.now(timezone.utc),
valid_at=None,
invalid_at=None,
)
episode = EpisodicNode(
uuid='episode_uuid',
name='Episode',
group_id='group_1',
source='message',
source_description='desc',
content='Episode content',
valid_at=datetime.now(timezone.utc),
)
edge_types = {'OCCURRED_AT': OccurredAtEdge}
edge_type_map = {('Event', 'Entity'): ['OCCURRED_AT']}
resolved_edges, invalidated_edges, new_edges = await resolve_extracted_edges(
clients,
[extracted_edge],
episode,
[source_node, target_node],
edge_types,
edge_type_map,
)
assert resolved_edges[0].name == 'INTERACTED_WITH'
assert invalidated_edges == []
assert new_edges == resolved_edges # No duplicates, so all edges are new
@pytest.mark.asyncio
async def test_resolve_extracted_edge_uses_integer_indices_for_duplicates(mock_llm_client):
"""Test that resolve_extracted_edge correctly uses integer indices for LLM duplicate detection."""
# Mock LLM to return duplicate_facts with integer indices
mock_llm_client.generate_response.return_value = {
'duplicate_facts': [0, 1], # LLM identifies first two related edges as duplicates
'contradicted_facts': [],
}
extracted_edge = EntityEdge(
source_node_uuid='source_uuid',
target_node_uuid='target_uuid',
name='test_edge',
group_id='group_1',
fact='User likes yoga',
episodes=[],
created_at=datetime.now(timezone.utc),
valid_at=None,
invalid_at=None,
)
episode = EpisodicNode(
uuid='episode_uuid',
name='Episode',
group_id='group_1',
source='message',
source_description='desc',
content='Episode content',
valid_at=datetime.now(timezone.utc),
)
# Create multiple related edges - LLM should receive these with integer indices
related_edge_0 = EntityEdge(
source_node_uuid='source_uuid',
target_node_uuid='target_uuid',
name='test_edge',
group_id='group_1',
fact='User enjoys yoga',
episodes=['episode_1'],
created_at=datetime.now(timezone.utc) - timedelta(days=1),
valid_at=None,
invalid_at=None,
)
related_edge_1 = EntityEdge(
source_node_uuid='source_uuid',
target_node_uuid='target_uuid',
name='test_edge',
group_id='group_1',
fact='User practices yoga',
episodes=['episode_2'],
created_at=datetime.now(timezone.utc) - timedelta(days=2),
valid_at=None,
invalid_at=None,
)
related_edge_2 = EntityEdge(
source_node_uuid='source_uuid',
target_node_uuid='target_uuid',
name='test_edge',
group_id='group_1',
fact='User loves swimming',
episodes=['episode_3'],
created_at=datetime.now(timezone.utc) - timedelta(days=3),
valid_at=None,
invalid_at=None,
)
related_edges = [related_edge_0, related_edge_1, related_edge_2]
resolved_edge, invalidated, duplicates = await resolve_extracted_edge(
mock_llm_client,
extracted_edge,
related_edges,
[],
episode,
edge_type_candidates=None,
)
# Verify LLM was called
mock_llm_client.generate_response.assert_called_once()
# Verify the system correctly identified duplicates using integer indices
# The LLM returned [0, 1], so related_edge_0 and related_edge_1 should be marked as duplicates
assert len(duplicates) == 2
assert related_edge_0 in duplicates
assert related_edge_1 in duplicates
assert invalidated == []
# Verify that the resolved edge is one of the duplicates (the first one found)
# Check UUID since the episode list gets modified
assert resolved_edge.uuid == related_edge_0.uuid
assert episode.uuid in resolved_edge.episodes
@pytest.mark.asyncio
async def test_resolve_extracted_edges_fast_path_deduplication(monkeypatch):
"""Test that resolve_extracted_edges deduplicates exact matches before parallel processing."""
from graphiti_core.utils.maintenance import edge_operations as edge_ops
monkeypatch.setattr(edge_ops, 'create_entity_edge_embeddings', AsyncMock(return_value=None))
monkeypatch.setattr(EntityEdge, 'get_between_nodes', AsyncMock(return_value=[]))
# Track how many times resolve_extracted_edge is called
resolve_call_count = 0
async def mock_resolve_extracted_edge(
llm_client,
extracted_edge,
related_edges,
existing_edges,
episode,
edge_type_candidates=None,
):
nonlocal resolve_call_count
resolve_call_count += 1
return extracted_edge, [], []
# Mock semaphore_gather to execute awaitable immediately
async def immediate_gather(*aws, max_coroutines=None):
results = []
for aw in aws:
results.append(await aw)
return results
monkeypatch.setattr(edge_ops, 'semaphore_gather', immediate_gather)
monkeypatch.setattr(edge_ops, 'search', AsyncMock(return_value=SearchResults()))
monkeypatch.setattr(edge_ops, 'resolve_extracted_edge', mock_resolve_extracted_edge)
llm_client = MagicMock()
clients = SimpleNamespace(
driver=MagicMock(),
llm_client=llm_client,
embedder=MagicMock(),
cross_encoder=MagicMock(),
)
source_node = EntityNode(
uuid='source_uuid',
name='Assistant',
group_id='group_1',
labels=['Entity'],
)
target_node = EntityNode(
uuid='target_uuid',
name='User',
group_id='group_1',
labels=['Entity'],
)
# Create 3 identical edges
edge1 = EntityEdge(
source_node_uuid=source_node.uuid,
target_node_uuid=target_node.uuid,
name='recommends',
group_id='group_1',
fact='assistant recommends yoga poses',
episodes=[],
created_at=datetime.now(timezone.utc),
valid_at=None,
invalid_at=None,
)
edge2 = EntityEdge(
source_node_uuid=source_node.uuid,
target_node_uuid=target_node.uuid,
name='recommends',
group_id='group_1',
fact=' Assistant Recommends YOGA Poses ', # Different whitespace/case
episodes=[],
created_at=datetime.now(timezone.utc),
valid_at=None,
invalid_at=None,
)
edge3 = EntityEdge(
source_node_uuid=source_node.uuid,
target_node_uuid=target_node.uuid,
name='recommends',
group_id='group_1',
fact='assistant recommends yoga poses',
episodes=[],
created_at=datetime.now(timezone.utc),
valid_at=None,
invalid_at=None,
)
episode = EpisodicNode(
uuid='episode_uuid',
name='Episode',
group_id='group_1',
source='message',
source_description='desc',
content='Episode content',
valid_at=datetime.now(timezone.utc),
)
resolved_edges, invalidated_edges, new_edges = await resolve_extracted_edges(
clients,
[edge1, edge2, edge3],
episode,
[source_node, target_node],
{},
{},
)
# Fast path should have deduplicated the 3 identical edges to 1
# So resolve_extracted_edge should only be called once
assert resolve_call_count == 1
assert len(resolved_edges) == 1
assert invalidated_edges == []
assert new_edges == resolved_edges # All edges are new (no graph duplicates)
class InterpersonalRelationship(BaseModel):
"""A relationship between two people."""
class LocatedIn(BaseModel):
"""A relationship indicating something is located in a place."""
def test_edge_type_signatures_map_preserves_multiple_signatures():
"""Test that edge types used across multiple node type pairs preserve all signatures.
This tests the fix for the bug where dict comprehension would overwrite
previous signatures when the same edge type appeared in multiple node pairs.
"""
# Edge type map where the same edge type is used for multiple node pair signatures
# This is the scenario that was broken before the fix
edge_type_map: dict[tuple[str, str], list[str]] = {
('Person', 'Person'): ['InterpersonalRelationship'],
('Person', 'Entity'): ['InterpersonalRelationship'], # Same type, different signature
('Person', 'City'): ['LocatedIn'],
('Entity', 'City'): ['LocatedIn'], # Same type, different signature
}
edge_types: dict[str, type[BaseModel]] = {
'InterpersonalRelationship': InterpersonalRelationship,
'LocatedIn': LocatedIn,
}
# Build the mapping the same way as in extract_edges (the fixed implementation)
edge_type_signatures_map: dict[str, list[tuple[str, str]]] = {}
for signature, edge_type_names in edge_type_map.items():
for edge_type in edge_type_names:
if edge_type not in edge_type_signatures_map:
edge_type_signatures_map[edge_type] = []
edge_type_signatures_map[edge_type].append(signature)
# Verify InterpersonalRelationship has BOTH signatures preserved
assert 'InterpersonalRelationship' in edge_type_signatures_map
interpersonal_signatures = edge_type_signatures_map['InterpersonalRelationship']
assert len(interpersonal_signatures) == 2
assert ('Person', 'Person') in interpersonal_signatures
assert ('Person', 'Entity') in interpersonal_signatures
# Verify LocatedIn has BOTH signatures preserved
assert 'LocatedIn' in edge_type_signatures_map
located_signatures = edge_type_signatures_map['LocatedIn']
assert len(located_signatures) == 2
assert ('Person', 'City') in located_signatures
assert ('Entity', 'City') in located_signatures
# Verify the edge_types_context structure
edge_types_context = [
{
'fact_type_name': type_name,
'fact_type_signatures': edge_type_signatures_map.get(type_name, [('Entity', 'Entity')]),
'fact_type_description': type_model.__doc__,
}
for type_name, type_model in edge_types.items()
]
# Verify the context has the correct structure with plural 'fact_type_signatures'
for ctx in edge_types_context:
assert 'fact_type_signatures' in ctx
assert isinstance(ctx['fact_type_signatures'], list)
assert len(ctx['fact_type_signatures']) == 2 # Each type has 2 signatures
def test_edge_type_signatures_map_single_signature_still_works():
"""Test that edge types with a single signature still work correctly."""
edge_type_map: dict[tuple[str, str], list[str]] = {
('Person', 'Organization'): ['WorksAt'],
('Person', 'City'): ['LivesIn'],
}
edge_types: dict[str, type[BaseModel]] = {
'WorksAt': BaseModel,
'LivesIn': BaseModel,
}
# Build the mapping
edge_type_signatures_map: dict[str, list[tuple[str, str]]] = {}
for signature, edge_type_names in edge_type_map.items():
for edge_type in edge_type_names:
if edge_type not in edge_type_signatures_map:
edge_type_signatures_map[edge_type] = []
edge_type_signatures_map[edge_type].append(signature)
# Verify each edge type has exactly one signature
assert len(edge_type_signatures_map['WorksAt']) == 1
assert ('Person', 'Organization') in edge_type_signatures_map['WorksAt']
assert len(edge_type_signatures_map['LivesIn']) == 1
assert ('Person', 'City') in edge_type_signatures_map['LivesIn']
# Verify the context structure
edge_types_context = [
{
'fact_type_name': type_name,
'fact_type_signatures': edge_type_signatures_map.get(type_name, [('Entity', 'Entity')]),
'fact_type_description': type_model.__doc__,
}
for type_name, type_model in edge_types.items()
]
for ctx in edge_types_context:
assert 'fact_type_signatures' in ctx
assert isinstance(ctx['fact_type_signatures'], list)
assert len(ctx['fact_type_signatures']) == 1
@pytest.mark.asyncio
async def test_extract_edges_drops_self_edges(monkeypatch):
"""Self-edges (source == target) are dropped during extraction."""
from graphiti_core.prompts.extract_edges import Edge as ExtractedEdge
from graphiti_core.prompts.extract_edges import ExtractedEdges
alice = EntityNode(
uuid='alice_uuid',
name='Alice',
group_id='group_1',
labels=['Person'],
)
bob = EntityNode(
uuid='bob_uuid',
name='Bob',
group_id='group_1',
labels=['Person'],
)
# LLM returns one valid edge and one self-edge
llm_response = ExtractedEdges(
edges=[
ExtractedEdge(
source_entity_name='Alice',
target_entity_name='Bob',
relation_type='CONGRATULATED',
fact='Alice congratulated Bob',
valid_at=None,
invalid_at=None,
),
ExtractedEdge(
source_entity_name='Alice',
target_entity_name='Alice',
relation_type='FEELS_HAPPY',
fact='Alice feels happy',
valid_at=None,
invalid_at=None,
),
]
).model_dump()
mock_llm = MagicMock()
mock_llm.generate_response = AsyncMock(return_value=llm_response)
clients = SimpleNamespace(
driver=MagicMock(),
llm_client=mock_llm,
embedder=MagicMock(),
cross_encoder=MagicMock(),
)
episode = EpisodicNode(
uuid='ep_uuid',
name='Episode',
group_id='group_1',
source='message',
source_description='desc',
content='Alice congratulated Bob. Alice feels happy.',
valid_at=datetime.now(timezone.utc),
)
edges = await extract_edges(
clients,
episode,
[alice, bob],
[],
{},
group_id='group_1',
)
# Only the Alice -> Bob edge survives; the Alice -> Alice self-edge is dropped
assert len(edges) == 1
assert edges[0].source_node_uuid == 'alice_uuid'
assert edges[0].target_node_uuid == 'bob_uuid'
assert edges[0].name == 'CONGRATULATED'
@pytest.mark.asyncio
async def test_extract_edges_keeps_valid_edges_with_same_name_different_nodes(monkeypatch):
"""Edges between different nodes that happen to share a name are NOT self-edges."""
from graphiti_core.prompts.extract_edges import Edge as ExtractedEdge
from graphiti_core.prompts.extract_edges import ExtractedEdges
alice = EntityNode(
uuid='alice_uuid',
name='Alice',
group_id='group_1',
labels=['Person'],
)
paris = EntityNode(
uuid='paris_uuid',
name='Paris',
group_id='group_1',
labels=['City'],
)
llm_response = ExtractedEdges(
edges=[
ExtractedEdge(
source_entity_name='Alice',
target_entity_name='Paris',
relation_type='LIVES_IN',
fact='Alice lives in Paris',
valid_at=None,
invalid_at=None,
),
]
).model_dump()
mock_llm = MagicMock()
mock_llm.generate_response = AsyncMock(return_value=llm_response)
clients = SimpleNamespace(
driver=MagicMock(),
llm_client=mock_llm,
embedder=MagicMock(),
cross_encoder=MagicMock(),
)
episode = EpisodicNode(
uuid='ep_uuid',
name='Episode',
group_id='group_1',
source='message',
source_description='desc',
content='Alice lives in Paris.',
valid_at=datetime.now(timezone.utc),
)
edges = await extract_edges(
clients,
episode,
[alice, paris],
[],
{},
group_id='group_1',
)
assert len(edges) == 1
assert edges[0].source_node_uuid == 'alice_uuid'
assert edges[0].target_node_uuid == 'paris_uuid'
class _EmploymentEdge(BaseModel):
"""Edge attribute schema mirroring a customer's EMPLOYMENT relation."""
title: str | None = None
is_current: str | None = None
@pytest.mark.asyncio
async def test_resolve_extracted_edge_overcap_attribute_preserves_prior(monkeypatch):
"""End-to-end: when the LLM bleeds meta-reasoning into one attribute, the
cap drops that field and the merge logic falls back to the prior on-edge
value — without affecting fields the LLM legitimately updated."""
from graphiti_core.utils.maintenance import edge_operations as edge_ops
# Avoid the timestamps LLM call that follows attribute extraction.
monkeypatch.setattr(edge_ops, '_extract_edge_timestamps', AsyncMock(return_value=None))
# The LLM returns a clean update for `title` but bleeds 9 KB of meta-reasoning into
# `is_current` — the kind of failure the customer reported.
bleed = (
'true (implied by context, but no new information explicitly stated. '
'I should ideally remove it or set it to null. However, the instruction '
'is to preserve existing values...) '
) * 30
llm_client = MagicMock()
llm_client.generate_response = AsyncMock(
return_value={'title': 'Senior Engineer', 'is_current': bleed}
)
extracted_edge = EntityEdge(
source_node_uuid='person_uuid',
target_node_uuid='company_uuid',
name='EMPLOYMENT',
group_id='group_1',
fact='Sam was promoted to Senior Engineer at Northwind',
episodes=[],
created_at=datetime.now(timezone.utc),
valid_at=None,
invalid_at=None,
attributes={'title': 'Engineer', 'is_current': 'true'},
)
episode = EpisodicNode(
uuid='episode_uuid',
name='Episode',
group_id='group_1',
source='message',
source_description='desc',
content='Sam was promoted to Senior Engineer.',
valid_at=datetime.now(timezone.utc),
)
resolved, dupes, invalidated = await resolve_extracted_edge(
llm_client,
extracted_edge,
related_edges=[],
existing_edges=[],
episode=episode,
edge_type_candidates={'EMPLOYMENT': _EmploymentEdge},
)
# Title should reflect the legitimate LLM update.
assert resolved.attributes['title'] == 'Senior Engineer'
# is_current was bleed-dropped, so the prior value must be preserved (not the
# 9 KB rant, not None, not absent).
assert resolved.attributes['is_current'] == 'true'
assert dupes == []
assert invalidated == []