Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 29 additions & 19 deletions graphiti_core/utils/maintenance/edge_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,32 +732,40 @@
response_object = EdgeDuplicate(**llm_response)
duplicate_facts = response_object.duplicate_facts

# Validate duplicate_facts are in valid range for EXISTING FACTS
invalid_duplicates = [i for i in duplicate_facts if i < 0 or i >= len(related_edges)]
if invalid_duplicates:
logger.warning(
'LLM returned invalid duplicate_facts idx values %s (valid range: 0-%d for EXISTING FACTS)',
invalid_duplicates,
len(related_edges) - 1,
)
# Process contradicted facts (continuous indexing across both lists)
contradicted_facts: list[int] = response_object.contradicted_facts
invalidation_candidates: list[EntityEdge] = []

# Only process contradictions if there are edges to check against
max_valid_idx = len(related_edges) + len(existing_edges) - 1

duplicate_fact_ids: list[int] = [i for i in duplicate_facts if 0 <= i < len(related_edges)]
# Validate duplicate_facts are in valid range across both lists
duplicate_fact_ids = [i for i in duplicate_facts if 0 <= i < len(related_edges)]
cross_list_duplicates = [i for i in duplicate_facts if len(related_edges) <= i <= max_valid_idx]
truly_invalid_duplicates = [i for i in duplicate_facts if i < 0 or i > max_valid_idx]

if truly_invalid_duplicates:
logger.warning(
'LLM returned invalid duplicate_facts idx values %s (valid range: 0-%d)',
truly_invalid_duplicates,
max_valid_idx,
)

resolved_edge = extracted_edge
for duplicate_fact_id in duplicate_fact_ids:
resolved_edge = related_edges[duplicate_fact_id]
for duplicate_facts_id in duplicate_fact_ids:
resolved_edge = related_edges[duplicate_facts_id]
break

if duplicate_fact_ids and episode is not None:
resolved_edge.episodes.append(episode.uuid)
if not duplicate_fact_ids and cross_list_duplicates:
# Duplicate cited only against an invalidation candidate — merge into it
# directly instead of discarding a correct verdict as "invalid."
resolved_edge = existing_edges[cross_list_duplicates[0] - len(related_edges)]

if (duplicate_fact_ids or cross_list_duplicates) and episode is not None:
if episode.uuid not in resolved_edge.episodes:

Check failure on line 765 in graphiti_core/utils/maintenance/edge_operations.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (SIM102)

graphiti_core/utils/maintenance/edge_operations.py:764:5: SIM102 Use a single `if` statement instead of nested `if` statements
resolved_edge.episodes.append(episode.uuid)

# Process contradicted facts (continuous indexing across both lists)
contradicted_facts: list[int] = response_object.contradicted_facts
invalidation_candidates: list[EntityEdge] = []

# Only process contradictions if there are edges to check against
if related_edges or existing_edges:
max_valid_idx = len(related_edges) + len(existing_edges) - 1
invalid_contradictions = [i for i in contradicted_facts if i < 0 or i > max_valid_idx]
if invalid_contradictions:
logger.warning(
Expand Down Expand Up @@ -843,6 +851,8 @@
resolved_edge, invalidation_candidates
)
duplicate_edges: list[EntityEdge] = [related_edges[idx] for idx in duplicate_fact_ids]
if not duplicate_fact_ids and cross_list_duplicates:
duplicate_edges = [existing_edges[idx - len(related_edges)] for idx in cross_list_duplicates]

return resolved_edge, invalidated_edges, duplicate_edges

Expand Down
63 changes: 63 additions & 0 deletions tests/utils/maintenance/test_edge_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,69 @@ async def test_resolve_extracted_edge_uses_integer_indices_for_duplicates(mock_l
assert resolved_edge.uuid == related_edge_0.uuid
assert episode.uuid in resolved_edge.episodes

@pytest.mark.asyncio
async def test_resolve_extracted_edge_resolves_cross_list_duplicates(mock_llm_client):
"""Test that resolve_extracted_edge correctly handles duplicate citations pointing to invalidation candidates (existing_edges)."""
# LLM identifies the candidate in existing_edges as a duplicate
# len(related_edges) = 0, so index 0 corresponds to existing_edges[0]
mock_llm_client.generate_response.return_value = {
'duplicate_facts': [0],
'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),
)

existing_edge_0 = EntityEdge(
source_node_uuid='source_uuid',
target_node_uuid='other_target_uuid',
name='test_edge',
group_id='group_1',
fact='User practices yoga',
episodes=['episode_1'],
created_at=datetime.now(timezone.utc) - timedelta(days=1),
valid_at=None,
invalid_at=None,
)

resolved_edge, invalidated, duplicates = await resolve_extracted_edge(
mock_llm_client,
extracted_edge,
[], # empty related_edges
[existing_edge_0], # existing_edges (invalidation candidates)
episode,
edge_type_candidates=None,
)

# Verify LLM was called
mock_llm_client.generate_response.assert_called_once()

# Verify that the duplicate was correctly identified from existing_edges
assert len(duplicates) == 1
assert existing_edge_0 in duplicates
assert resolved_edge.uuid == existing_edge_0.uuid
assert episode.uuid in resolved_edge.episodes
assert invalidated == []


@pytest.mark.asyncio
async def test_resolve_extracted_edges_fast_path_deduplication(monkeypatch):
Expand Down
Loading