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
912 lines
36 KiB
Python
912 lines
36 KiB
Python
"""
|
|
Copyright 2024, Zep Software, Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from time import time
|
|
|
|
from pydantic import BaseModel
|
|
from typing_extensions import LiteralString
|
|
|
|
from graphiti_core.driver.driver import GraphDriver, GraphProvider
|
|
from graphiti_core.edges import (
|
|
CommunityEdge,
|
|
EntityEdge,
|
|
EpisodicEdge,
|
|
create_entity_edge_embeddings,
|
|
)
|
|
from graphiti_core.graphiti_types import GraphitiClients
|
|
from graphiti_core.helpers import semaphore_gather
|
|
from graphiti_core.llm_client import LLMClient
|
|
from graphiti_core.llm_client.config import ModelSize
|
|
from graphiti_core.nodes import CommunityNode, EntityNode, EpisodicNode
|
|
from graphiti_core.prompts import prompt_library
|
|
from graphiti_core.prompts.dedupe_edges import EdgeDuplicate
|
|
from graphiti_core.prompts.extract_edges import Edge as ExtractedEdge
|
|
from graphiti_core.prompts.extract_edges import EdgeTimestamps, ExtractedEdges
|
|
from graphiti_core.search.search import search
|
|
from graphiti_core.search.search_config import SearchResults
|
|
from graphiti_core.search.search_config_recipes import EDGE_HYBRID_SEARCH_RRF
|
|
from graphiti_core.search.search_filters import SearchFilters
|
|
from graphiti_core.utils.datetime_utils import ensure_utc, utc_now
|
|
from graphiti_core.utils.maintenance.attribute_utils import apply_capped_attributes
|
|
from graphiti_core.utils.maintenance.dedup_helpers import _normalize_string_exact
|
|
from graphiti_core.utils.text_utils import concatenate_episodes
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def build_episodic_edges(
|
|
entity_nodes: list[EntityNode],
|
|
episode_uuid: str | list[str],
|
|
created_at: datetime,
|
|
node_episode_index_map: dict[str, list[int]] | None = None,
|
|
) -> list[EpisodicEdge]:
|
|
"""Build episodic (MENTIONED_IN) edges between entity nodes and episodes.
|
|
|
|
Parameters
|
|
----------
|
|
entity_nodes : list[EntityNode]
|
|
Nodes to connect to episodes.
|
|
episode_uuid : str | list[str]
|
|
A single episode UUID or a list of episode UUIDs.
|
|
created_at : datetime
|
|
Timestamp for the edges.
|
|
node_episode_index_map : dict[str, list[int]] | None
|
|
Optional mapping from node UUID to 0-indexed episode positions.
|
|
When provided with a list of episode_uuids, each node is connected
|
|
only to its attributed episodes. When None, every node is connected
|
|
to all episodes.
|
|
"""
|
|
episode_uuids = [episode_uuid] if isinstance(episode_uuid, str) else episode_uuid
|
|
|
|
episodic_edges: list[EpisodicEdge] = []
|
|
for node in entity_nodes:
|
|
if node_episode_index_map and node.uuid in node_episode_index_map:
|
|
indices = node_episode_index_map[node.uuid]
|
|
else:
|
|
indices = list(range(len(episode_uuids)))
|
|
|
|
for idx in indices:
|
|
if 0 <= idx < len(episode_uuids):
|
|
episodic_edges.append(
|
|
EpisodicEdge(
|
|
source_node_uuid=episode_uuids[idx],
|
|
target_node_uuid=node.uuid,
|
|
created_at=created_at,
|
|
group_id=node.group_id,
|
|
)
|
|
)
|
|
|
|
logger.debug(f'Built {len(episodic_edges)} episodic edges')
|
|
|
|
return episodic_edges
|
|
|
|
|
|
def build_community_edges(
|
|
entity_nodes: list[EntityNode],
|
|
community_node: CommunityNode,
|
|
created_at: datetime,
|
|
) -> list[CommunityEdge]:
|
|
edges: list[CommunityEdge] = [
|
|
CommunityEdge(
|
|
source_node_uuid=community_node.uuid,
|
|
target_node_uuid=node.uuid,
|
|
created_at=created_at,
|
|
group_id=community_node.group_id,
|
|
)
|
|
for node in entity_nodes
|
|
]
|
|
|
|
return edges
|
|
|
|
|
|
async def extract_edges(
|
|
clients: GraphitiClients,
|
|
episode: EpisodicNode | list[EpisodicNode],
|
|
nodes: list[EntityNode],
|
|
previous_episodes: list[EpisodicNode],
|
|
edge_type_map: dict[tuple[str, str], list[str]],
|
|
group_id: str = '',
|
|
edge_types: dict[str, type[BaseModel]] | None = None,
|
|
custom_extraction_instructions: str | None = None,
|
|
) -> list[EntityEdge]:
|
|
"""Extract edges from one or more episodes.
|
|
|
|
Parameters
|
|
----------
|
|
episode : EpisodicNode | list[EpisodicNode]
|
|
A single episode or a list of episodes to extract edges from.
|
|
When a list is provided, their contents are concatenated for extraction
|
|
and edges are linked to all episode UUIDs.
|
|
"""
|
|
episodes = episode if isinstance(episode, list) else [episode]
|
|
primary_episode = episodes[0]
|
|
|
|
start = time()
|
|
|
|
extract_edges_max_tokens = 16384
|
|
llm_client = clients.llm_client
|
|
|
|
# Build mapping from edge type name to list of valid signatures
|
|
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)
|
|
|
|
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()
|
|
]
|
|
if edge_types is not None
|
|
else []
|
|
)
|
|
|
|
# Build name-to-node mapping for validation
|
|
name_to_node: dict[str, EntityNode] = {node.name: node for node in nodes}
|
|
|
|
# Build episode attribution instructions for multi-episode extraction
|
|
episode_attribution = ''
|
|
if len(episodes) > 1:
|
|
episode_attribution = (
|
|
'\n8. **Episode Attribution**: The CURRENT_MESSAGE contains multiple episodes labeled '
|
|
'[Episode 0], [Episode 1], etc. Each episode header includes a timestamp indicating '
|
|
'when that episode occurred. Use the per-episode timestamp to resolve relative time '
|
|
'mentions within each episode rather than relying solely on REFERENCE_TIME. '
|
|
'For each extracted fact, set `episode_indices` '
|
|
'to the 0-based list of episode numbers that the fact was derived from. '
|
|
'A fact sourced from Episodes 0 and 1 should have `episode_indices: [0, 1]`.'
|
|
)
|
|
|
|
# Prepare context for LLM
|
|
# Use the latest episode's timestamp as the primary reference time
|
|
latest_episode = max(episodes, key=lambda ep: ep.valid_at)
|
|
context = {
|
|
'episode_content': concatenate_episodes(episodes),
|
|
'nodes': [{'name': node.name, 'entity_types': node.labels} for node in nodes],
|
|
'previous_episodes': [
|
|
{
|
|
'content': ep.content,
|
|
'timestamp': ep.valid_at.isoformat() if ep.valid_at else None,
|
|
}
|
|
for ep in previous_episodes
|
|
],
|
|
'reference_time': latest_episode.valid_at,
|
|
'edge_types': edge_types_context,
|
|
'custom_extraction_instructions': (custom_extraction_instructions or '')
|
|
+ episode_attribution,
|
|
}
|
|
|
|
llm_response = await llm_client.generate_response(
|
|
prompt_library.extract_edges.edge(context),
|
|
response_model=ExtractedEdges,
|
|
max_tokens=extract_edges_max_tokens,
|
|
group_id=group_id or primary_episode.group_id,
|
|
prompt_name='extract_edges.edge',
|
|
)
|
|
all_edges_data = ExtractedEdges(**llm_response).edges
|
|
|
|
# Validate entity names
|
|
edges_data: list[ExtractedEdge] = []
|
|
for edge_data in all_edges_data:
|
|
source_name = edge_data.source_entity_name
|
|
target_name = edge_data.target_entity_name
|
|
|
|
# Validate LLM-returned names exist in the nodes list
|
|
if source_name not in name_to_node:
|
|
logger.warning(
|
|
'Source entity not found in nodes for edge relation: %s',
|
|
edge_data.relation_type,
|
|
)
|
|
continue
|
|
|
|
if target_name not in name_to_node:
|
|
logger.warning(
|
|
'Target entity not found in nodes for edge relation: %s',
|
|
edge_data.relation_type,
|
|
)
|
|
continue
|
|
|
|
# Drop self-edges where source and target resolve to the same node
|
|
source_node = name_to_node[source_name]
|
|
target_node = name_to_node[target_name]
|
|
if source_node.uuid == target_node.uuid:
|
|
logger.info(
|
|
'Dropping self-edge for node %s (source and target resolve to same node)',
|
|
source_node.uuid,
|
|
)
|
|
continue
|
|
|
|
edges_data.append(edge_data)
|
|
|
|
end = time()
|
|
logger.debug(f'Extracted {len(edges_data)} new edges in {(end - start) * 1000:.0f} ms')
|
|
|
|
if len(edges_data) == 0:
|
|
return []
|
|
|
|
# Convert the extracted data into EntityEdge objects
|
|
edges = []
|
|
for edge_data in edges_data:
|
|
# Validate Edge Date information
|
|
valid_at = edge_data.valid_at
|
|
invalid_at = edge_data.invalid_at
|
|
valid_at_datetime = None
|
|
invalid_at_datetime = None
|
|
|
|
# Filter out empty edges
|
|
if not edge_data.fact.strip():
|
|
continue
|
|
|
|
# Names already validated above
|
|
source_node = name_to_node.get(edge_data.source_entity_name)
|
|
target_node = name_to_node.get(edge_data.target_entity_name)
|
|
|
|
if source_node is None or target_node is None:
|
|
logger.warning('Could not find source or target node for extracted edge')
|
|
continue
|
|
|
|
source_node_uuid = source_node.uuid
|
|
target_node_uuid = target_node.uuid
|
|
|
|
if valid_at:
|
|
try:
|
|
valid_at_datetime = ensure_utc(
|
|
datetime.fromisoformat(valid_at.replace('Z', '+00:00'))
|
|
)
|
|
except ValueError:
|
|
logger.warning('Error parsing valid_at date, skipping')
|
|
|
|
if invalid_at:
|
|
try:
|
|
invalid_at_datetime = ensure_utc(
|
|
datetime.fromisoformat(invalid_at.replace('Z', '+00:00'))
|
|
)
|
|
except ValueError as e:
|
|
logger.warning(f'WARNING: Error parsing invalid_at date: {e}. Input: {invalid_at}')
|
|
|
|
# Map episode_indices (0-indexed) to episode UUIDs.
|
|
# Clamp indices to valid range and fall back to all episodes if empty.
|
|
edge_episode_uuids = []
|
|
for idx in edge_data.episode_indices:
|
|
if 0 <= idx < len(episodes):
|
|
edge_episode_uuids.append(episodes[idx].uuid)
|
|
if not edge_episode_uuids:
|
|
edge_episode_uuids = [ep.uuid for ep in episodes]
|
|
|
|
edge = EntityEdge(
|
|
source_node_uuid=source_node_uuid,
|
|
target_node_uuid=target_node_uuid,
|
|
name=edge_data.relation_type,
|
|
group_id=group_id or primary_episode.group_id,
|
|
fact=edge_data.fact,
|
|
episodes=edge_episode_uuids,
|
|
created_at=utc_now(),
|
|
valid_at=valid_at_datetime,
|
|
invalid_at=invalid_at_datetime,
|
|
reference_time=(
|
|
episodes[edge_data.episode_indices[0]].valid_at
|
|
if edge_data.episode_indices and 0 <= edge_data.episode_indices[0] < len(episodes)
|
|
else primary_episode.valid_at
|
|
),
|
|
)
|
|
edges.append(edge)
|
|
logger.debug(
|
|
f'Created new edge {edge.uuid} from {edge.source_node_uuid} to {edge.target_node_uuid}'
|
|
)
|
|
|
|
logger.debug(f'Extracted edges: {[e.uuid for e in edges]}')
|
|
|
|
return edges
|
|
|
|
|
|
async def resolve_extracted_edges(
|
|
clients: GraphitiClients,
|
|
extracted_edges: list[EntityEdge],
|
|
episode: EpisodicNode,
|
|
entities: list[EntityNode],
|
|
edge_types: dict[str, type[BaseModel]],
|
|
edge_type_map: dict[tuple[str, str], list[str]],
|
|
existing_edges_override: list[EntityEdge] | None = None,
|
|
) -> tuple[list[EntityEdge], list[EntityEdge], list[EntityEdge]]:
|
|
"""Resolve extracted edges against existing graph context.
|
|
|
|
Returns
|
|
-------
|
|
tuple[list[EntityEdge], list[EntityEdge], list[EntityEdge]]
|
|
A tuple of (resolved_edges, invalidated_edges, new_edges) where:
|
|
- resolved_edges: All edges after resolution (may include existing edges if duplicates found)
|
|
- invalidated_edges: Edges that were invalidated/contradicted by new information
|
|
- new_edges: Only edges that are new to the graph (not duplicates of existing edges)
|
|
"""
|
|
# Fast path: deduplicate exact matches within the extracted edges before parallel processing
|
|
seen: dict[tuple[str, str, str], EntityEdge] = {}
|
|
deduplicated_edges: list[EntityEdge] = []
|
|
|
|
for edge in extracted_edges:
|
|
key = (
|
|
edge.source_node_uuid,
|
|
edge.target_node_uuid,
|
|
_normalize_string_exact(edge.fact),
|
|
)
|
|
if key not in seen:
|
|
seen[key] = edge
|
|
deduplicated_edges.append(edge)
|
|
|
|
extracted_edges = deduplicated_edges
|
|
|
|
driver = clients.driver
|
|
llm_client = clients.llm_client
|
|
embedder = clients.embedder
|
|
await create_entity_edge_embeddings(embedder, extracted_edges)
|
|
|
|
valid_edges_list: list[list[EntityEdge]] = await semaphore_gather(
|
|
*[
|
|
EntityEdge.get_between_nodes(driver, edge.source_node_uuid, edge.target_node_uuid)
|
|
for edge in extracted_edges
|
|
]
|
|
)
|
|
|
|
# Merge override edges (e.g. from the recent Redis dedup cache) into
|
|
# the per-extracted-edge candidate lists so that recently resolved edges
|
|
# that are not yet visible in the graph-service indexes are still
|
|
# considered during deduplication.
|
|
if existing_edges_override:
|
|
override_by_pair: dict[tuple[str, str], list[EntityEdge]] = {}
|
|
for oe in existing_edges_override:
|
|
key = (oe.source_node_uuid, oe.target_node_uuid)
|
|
override_by_pair.setdefault(key, []).append(oe)
|
|
|
|
for i, extracted_edge in enumerate(extracted_edges):
|
|
pair_key = (extracted_edge.source_node_uuid, extracted_edge.target_node_uuid)
|
|
overrides = override_by_pair.get(pair_key, [])
|
|
if overrides:
|
|
existing_uuids = {e.uuid for e in valid_edges_list[i]}
|
|
for oe in overrides:
|
|
if oe.uuid not in existing_uuids:
|
|
valid_edges_list[i].append(oe)
|
|
existing_uuids.add(oe.uuid)
|
|
|
|
related_edges_results: list[SearchResults] = await semaphore_gather(
|
|
*[
|
|
search(
|
|
clients,
|
|
extracted_edge.fact,
|
|
group_ids=[extracted_edge.group_id],
|
|
config=EDGE_HYBRID_SEARCH_RRF,
|
|
search_filter=SearchFilters(edge_uuids=[edge.uuid for edge in valid_edges]),
|
|
)
|
|
for extracted_edge, valid_edges in zip(extracted_edges, valid_edges_list, strict=True)
|
|
]
|
|
)
|
|
|
|
related_edges_lists: list[list[EntityEdge]] = [result.edges for result in related_edges_results]
|
|
|
|
edge_invalidation_candidate_results: list[SearchResults] = await semaphore_gather(
|
|
*[
|
|
search(
|
|
clients,
|
|
extracted_edge.fact,
|
|
group_ids=[extracted_edge.group_id],
|
|
config=EDGE_HYBRID_SEARCH_RRF,
|
|
search_filter=SearchFilters(),
|
|
)
|
|
for extracted_edge in extracted_edges
|
|
]
|
|
)
|
|
|
|
# Remove duplicates: if an edge appears in both duplicate candidates and invalidation candidates,
|
|
# keep it only in duplicate candidates
|
|
edge_invalidation_candidates: list[list[EntityEdge]] = []
|
|
for related_edges, invalidation_result in zip(
|
|
related_edges_lists, edge_invalidation_candidate_results, strict=True
|
|
):
|
|
related_uuids = {edge.uuid for edge in related_edges}
|
|
deduplicated = [
|
|
edge for edge in invalidation_result.edges if edge.uuid not in related_uuids
|
|
]
|
|
edge_invalidation_candidates.append(deduplicated)
|
|
|
|
logger.debug(
|
|
f'Related edges: {[e.uuid for edges_lst in related_edges_lists for e in edges_lst]}'
|
|
)
|
|
|
|
# Build entity hash table
|
|
uuid_entity_map: dict[str, EntityNode] = {entity.uuid: entity for entity in entities}
|
|
|
|
# Collect all node UUIDs referenced by edges that are not in the entities list
|
|
referenced_node_uuids = set()
|
|
for extracted_edge in extracted_edges:
|
|
if extracted_edge.source_node_uuid not in uuid_entity_map:
|
|
referenced_node_uuids.add(extracted_edge.source_node_uuid)
|
|
if extracted_edge.target_node_uuid not in uuid_entity_map:
|
|
referenced_node_uuids.add(extracted_edge.target_node_uuid)
|
|
|
|
# Fetch missing nodes from the database
|
|
if referenced_node_uuids:
|
|
# Pass group_id so graph-service implementations can scope the lookup
|
|
edge_group_id = extracted_edges[0].group_id
|
|
missing_nodes = await EntityNode.get_by_uuids(
|
|
driver, list(referenced_node_uuids), group_id=edge_group_id
|
|
)
|
|
for node in missing_nodes:
|
|
uuid_entity_map[node.uuid] = node
|
|
|
|
# Determine which edge types are relevant for each edge based on node signatures.
|
|
# `edge_types_lst` stores the subset of custom edge definitions whose
|
|
# node signature matches each extracted edge.
|
|
edge_types_lst: list[dict[str, type[BaseModel]]] = []
|
|
for extracted_edge in extracted_edges:
|
|
source_node = uuid_entity_map.get(extracted_edge.source_node_uuid)
|
|
target_node = uuid_entity_map.get(extracted_edge.target_node_uuid)
|
|
source_node_labels = (
|
|
source_node.labels + ['Entity'] if source_node is not None else ['Entity']
|
|
)
|
|
target_node_labels = (
|
|
target_node.labels + ['Entity'] if target_node is not None else ['Entity']
|
|
)
|
|
label_tuples = [
|
|
(source_label, target_label)
|
|
for source_label in source_node_labels
|
|
for target_label in target_node_labels
|
|
]
|
|
|
|
extracted_edge_types = {}
|
|
for label_tuple in label_tuples:
|
|
type_names = edge_type_map.get(label_tuple, [])
|
|
for type_name in type_names:
|
|
type_model = edge_types.get(type_name)
|
|
if type_model is None:
|
|
continue
|
|
|
|
extracted_edge_types[type_name] = type_model
|
|
|
|
edge_types_lst.append(extracted_edge_types)
|
|
|
|
# resolve edges with related edges in the graph and find invalidation candidates
|
|
results: list[tuple[EntityEdge, list[EntityEdge], list[EntityEdge]]] = list(
|
|
await semaphore_gather(
|
|
*[
|
|
resolve_extracted_edge(
|
|
llm_client,
|
|
extracted_edge,
|
|
related_edges,
|
|
existing_edges,
|
|
episode,
|
|
extracted_edge_types,
|
|
)
|
|
for extracted_edge, related_edges, existing_edges, extracted_edge_types in zip(
|
|
extracted_edges,
|
|
related_edges_lists,
|
|
edge_invalidation_candidates,
|
|
edge_types_lst,
|
|
strict=True,
|
|
)
|
|
]
|
|
)
|
|
)
|
|
|
|
resolved_edges: list[EntityEdge] = []
|
|
invalidated_edges: list[EntityEdge] = []
|
|
new_edges: list[EntityEdge] = []
|
|
for extracted_edge, result in zip(extracted_edges, results, strict=True):
|
|
resolved_edge = result[0]
|
|
invalidated_edge_chunk = result[1]
|
|
# result[2] is duplicate_edges list
|
|
|
|
resolved_edges.append(resolved_edge)
|
|
invalidated_edges.extend(invalidated_edge_chunk)
|
|
|
|
# Track edges that are new (not duplicates of existing edges)
|
|
# An edge is new if the resolved edge UUID matches the extracted edge UUID
|
|
if resolved_edge.uuid == extracted_edge.uuid:
|
|
new_edges.append(resolved_edge)
|
|
|
|
logger.debug(f'Resolved edges: {[e.uuid for e in resolved_edges]}')
|
|
logger.debug(f'New edges (non-duplicates): {[e.uuid for e in new_edges]}')
|
|
|
|
await semaphore_gather(
|
|
create_entity_edge_embeddings(embedder, resolved_edges),
|
|
create_entity_edge_embeddings(embedder, invalidated_edges),
|
|
)
|
|
|
|
return resolved_edges, invalidated_edges, new_edges
|
|
|
|
|
|
def resolve_edge_contradictions(
|
|
resolved_edge: EntityEdge, invalidation_candidates: list[EntityEdge]
|
|
) -> list[EntityEdge]:
|
|
if len(invalidation_candidates) == 0:
|
|
return []
|
|
|
|
# Determine which contradictory edges need to be expired
|
|
invalidated_edges: list[EntityEdge] = []
|
|
for edge in invalidation_candidates:
|
|
# (Edge invalid before new edge becomes valid) or (new edge invalid before edge becomes valid)
|
|
edge_invalid_at_utc = ensure_utc(edge.invalid_at)
|
|
resolved_edge_valid_at_utc = ensure_utc(resolved_edge.valid_at)
|
|
edge_valid_at_utc = ensure_utc(edge.valid_at)
|
|
resolved_edge_invalid_at_utc = ensure_utc(resolved_edge.invalid_at)
|
|
|
|
if (
|
|
edge_invalid_at_utc is not None
|
|
and resolved_edge_valid_at_utc is not None
|
|
and edge_invalid_at_utc <= resolved_edge_valid_at_utc
|
|
) or (
|
|
edge_valid_at_utc is not None
|
|
and resolved_edge_invalid_at_utc is not None
|
|
and resolved_edge_invalid_at_utc <= edge_valid_at_utc
|
|
):
|
|
continue
|
|
# New edge invalidates edge
|
|
elif (
|
|
edge_valid_at_utc is not None
|
|
and resolved_edge_valid_at_utc is not None
|
|
and edge_valid_at_utc < resolved_edge_valid_at_utc
|
|
):
|
|
edge.invalid_at = resolved_edge.valid_at
|
|
edge.expired_at = edge.expired_at if edge.expired_at is not None else utc_now()
|
|
invalidated_edges.append(edge)
|
|
|
|
return invalidated_edges
|
|
|
|
|
|
async def _extract_edge_timestamps(
|
|
llm_client: LLMClient,
|
|
edge: EntityEdge,
|
|
episode: EpisodicNode | None,
|
|
) -> None:
|
|
"""Extract valid_at / invalid_at timestamps for an edge via a lightweight LLM call.
|
|
|
|
Modifies the edge in place. Skips if the edge already has timestamps set
|
|
(e.g., from the extraction prompt in the separate-extraction path) or if
|
|
no reference time is available.
|
|
"""
|
|
if edge.valid_at is not None or edge.invalid_at is not None:
|
|
return
|
|
|
|
if episode is None or episode.valid_at is None:
|
|
return
|
|
|
|
context = {
|
|
'fact': edge.fact,
|
|
'reference_time': episode.valid_at.isoformat(),
|
|
}
|
|
try:
|
|
llm_response = await llm_client.generate_response(
|
|
prompt_library.extract_edges.extract_timestamps(context),
|
|
response_model=EdgeTimestamps,
|
|
model_size=ModelSize.small,
|
|
prompt_name='extract_edges.extract_timestamps',
|
|
)
|
|
timestamps = EdgeTimestamps(**llm_response)
|
|
if timestamps.valid_at:
|
|
try:
|
|
edge.valid_at = ensure_utc(
|
|
datetime.fromisoformat(timestamps.valid_at.replace('Z', '+00:00'))
|
|
)
|
|
except ValueError:
|
|
logger.debug(f'Error parsing valid_at: {timestamps.valid_at}')
|
|
if timestamps.invalid_at:
|
|
try:
|
|
edge.invalid_at = ensure_utc(
|
|
datetime.fromisoformat(timestamps.invalid_at.replace('Z', '+00:00'))
|
|
)
|
|
except ValueError:
|
|
logger.debug(f'Error parsing invalid_at: {timestamps.invalid_at}')
|
|
except Exception:
|
|
logger.warning('Failed to extract timestamps for edge %s', edge.uuid, exc_info=True)
|
|
|
|
|
|
async def resolve_extracted_edge(
|
|
llm_client: LLMClient,
|
|
extracted_edge: EntityEdge,
|
|
related_edges: list[EntityEdge],
|
|
existing_edges: list[EntityEdge],
|
|
episode: EpisodicNode,
|
|
edge_type_candidates: dict[str, type[BaseModel]] | None = None,
|
|
) -> tuple[EntityEdge, list[EntityEdge], list[EntityEdge]]:
|
|
"""Resolve an extracted edge against existing graph context.
|
|
|
|
Parameters
|
|
----------
|
|
llm_client : LLMClient
|
|
Client used to invoke the LLM for deduplication and attribute extraction.
|
|
extracted_edge : EntityEdge
|
|
Newly extracted edge whose canonical representation is being resolved.
|
|
related_edges : list[EntityEdge]
|
|
Candidate edges with identical endpoints used for duplicate detection.
|
|
existing_edges : list[EntityEdge]
|
|
Broader set of edges evaluated for contradiction / invalidation.
|
|
episode : EpisodicNode
|
|
Episode providing content context when extracting edge attributes.
|
|
edge_type_candidates : dict[str, type[BaseModel]] | None
|
|
Custom edge types permitted for the current source/target signature.
|
|
|
|
Returns
|
|
-------
|
|
tuple[EntityEdge, list[EntityEdge], list[EntityEdge]]
|
|
The resolved edge, any duplicates, and edges to invalidate.
|
|
"""
|
|
if len(related_edges) == 0 and len(existing_edges) == 0:
|
|
# Still extract custom attributes and timestamps even when no dedup needed
|
|
edge_model = edge_type_candidates.get(extracted_edge.name) if edge_type_candidates else None
|
|
if edge_model is not None and len(edge_model.model_fields) != 0:
|
|
edge_attributes_context = {
|
|
'fact': extracted_edge.fact,
|
|
'reference_time': episode.valid_at if episode is not None else None,
|
|
'existing_attributes': extracted_edge.attributes,
|
|
}
|
|
edge_attributes_response = await llm_client.generate_response(
|
|
prompt_library.extract_edges.extract_attributes(edge_attributes_context),
|
|
response_model=edge_model, # type: ignore
|
|
model_size=ModelSize.small,
|
|
prompt_name='extract_edges.extract_attributes',
|
|
attribute_extraction=True,
|
|
)
|
|
merged, _ = apply_capped_attributes(
|
|
edge_attributes_response,
|
|
edge_model,
|
|
extracted_edge.attributes,
|
|
merge_mode='replace',
|
|
prompt_name='extract_edges.extract_attributes',
|
|
entity_uuid=extracted_edge.uuid,
|
|
group_id=extracted_edge.group_id,
|
|
)
|
|
extracted_edge.attributes = merged
|
|
|
|
await _extract_edge_timestamps(llm_client, extracted_edge, episode)
|
|
|
|
return extracted_edge, [], []
|
|
|
|
# Fast path: if the fact text and endpoints already exist verbatim, reuse the matching edge.
|
|
normalized_fact = _normalize_string_exact(extracted_edge.fact)
|
|
for edge in related_edges:
|
|
if (
|
|
edge.source_node_uuid == extracted_edge.source_node_uuid
|
|
and edge.target_node_uuid == extracted_edge.target_node_uuid
|
|
and _normalize_string_exact(edge.fact) == normalized_fact
|
|
):
|
|
resolved = edge
|
|
if episode is not None and episode.uuid not in resolved.episodes:
|
|
resolved.episodes.append(episode.uuid)
|
|
return resolved, [], []
|
|
|
|
start = time()
|
|
|
|
# Prepare context for LLM with continuous indexing
|
|
related_edges_context = [{'idx': i, 'fact': edge.fact} for i, edge in enumerate(related_edges)]
|
|
|
|
# Invalidation candidates start where duplicate candidates end
|
|
invalidation_idx_offset = len(related_edges)
|
|
invalidation_edge_candidates_context = [
|
|
{'idx': invalidation_idx_offset + i, 'fact': existing_edge.fact}
|
|
for i, existing_edge in enumerate(existing_edges)
|
|
]
|
|
|
|
context = {
|
|
'existing_edges': related_edges_context,
|
|
'new_edge': extracted_edge.fact,
|
|
'edge_invalidation_candidates': invalidation_edge_candidates_context,
|
|
}
|
|
|
|
if related_edges or existing_edges:
|
|
logger.debug(
|
|
'Resolving edge: sent %d EXISTING FACTS%s and %d INVALIDATION CANDIDATES%s',
|
|
len(related_edges),
|
|
f' (idx 0-{len(related_edges) - 1})' if related_edges else '',
|
|
len(existing_edges),
|
|
f' (idx {invalidation_idx_offset}-{invalidation_idx_offset + len(existing_edges) - 1})'
|
|
if existing_edges
|
|
else '',
|
|
)
|
|
|
|
llm_response = await llm_client.generate_response(
|
|
prompt_library.dedupe_edges.resolve_edge(context),
|
|
response_model=EdgeDuplicate,
|
|
model_size=ModelSize.small,
|
|
prompt_name='dedupe_edges.resolve_edge',
|
|
)
|
|
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,
|
|
)
|
|
|
|
duplicate_fact_ids: list[int] = [i for i in duplicate_facts if 0 <= i < len(related_edges)]
|
|
|
|
resolved_edge = extracted_edge
|
|
for duplicate_fact_id in duplicate_fact_ids:
|
|
resolved_edge = related_edges[duplicate_fact_id]
|
|
break
|
|
|
|
if duplicate_fact_ids and episode is not None:
|
|
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(
|
|
'LLM returned invalid contradicted_facts idx values %s (valid range: 0-%d)',
|
|
invalid_contradictions,
|
|
max_valid_idx,
|
|
)
|
|
|
|
# Split contradicted facts into those from related_edges vs existing_edges based on offset
|
|
for idx in contradicted_facts:
|
|
if 0 <= idx < len(related_edges):
|
|
# From EXISTING FACTS (duplicate candidates)
|
|
invalidation_candidates.append(related_edges[idx])
|
|
elif invalidation_idx_offset <= idx <= max_valid_idx:
|
|
# From FACT INVALIDATION CANDIDATES (adjust index by offset)
|
|
invalidation_candidates.append(existing_edges[idx - invalidation_idx_offset])
|
|
|
|
# Only extract structured attributes if the edge's relation_type matches an allowed custom type
|
|
# AND the edge model exists for this node pair signature
|
|
edge_model = edge_type_candidates.get(resolved_edge.name) if edge_type_candidates else None
|
|
if edge_model is not None and len(edge_model.model_fields) != 0:
|
|
edge_attributes_context = {
|
|
'fact': resolved_edge.fact,
|
|
'reference_time': episode.valid_at if episode is not None else None,
|
|
'existing_attributes': resolved_edge.attributes,
|
|
}
|
|
|
|
edge_attributes_response = await llm_client.generate_response(
|
|
prompt_library.extract_edges.extract_attributes(edge_attributes_context),
|
|
response_model=edge_model, # type: ignore
|
|
model_size=ModelSize.small,
|
|
prompt_name='extract_edges.extract_attributes',
|
|
attribute_extraction=True,
|
|
)
|
|
|
|
merged, _ = apply_capped_attributes(
|
|
edge_attributes_response,
|
|
edge_model,
|
|
resolved_edge.attributes,
|
|
merge_mode='replace',
|
|
prompt_name='extract_edges.extract_attributes',
|
|
entity_uuid=resolved_edge.uuid,
|
|
group_id=resolved_edge.group_id,
|
|
)
|
|
resolved_edge.attributes = merged
|
|
else:
|
|
# No matching edge schema → no structured attributes apply; clear any stale
|
|
# attributes left from a prior schema. Intentionally not merged.
|
|
resolved_edge.attributes = {}
|
|
|
|
# Extract timestamps for new edges (duplicated edges retain their existing timestamps)
|
|
if resolved_edge.uuid == extracted_edge.uuid:
|
|
await _extract_edge_timestamps(llm_client, resolved_edge, episode)
|
|
|
|
end = time()
|
|
logger.debug(
|
|
f'Resolved Edge: {extracted_edge.uuid} -> {resolved_edge.uuid}, in {(end - start) * 1000} ms'
|
|
)
|
|
|
|
now = utc_now()
|
|
|
|
if resolved_edge.invalid_at and not resolved_edge.expired_at:
|
|
resolved_edge.expired_at = now
|
|
|
|
# Determine if the new_edge needs to be expired
|
|
if resolved_edge.expired_at is None:
|
|
invalidation_candidates.sort(key=lambda c: (c.valid_at is None, ensure_utc(c.valid_at)))
|
|
for candidate in invalidation_candidates:
|
|
candidate_valid_at_utc = ensure_utc(candidate.valid_at)
|
|
resolved_edge_valid_at_utc = ensure_utc(resolved_edge.valid_at)
|
|
if (
|
|
candidate_valid_at_utc is not None
|
|
and resolved_edge_valid_at_utc is not None
|
|
and candidate_valid_at_utc > resolved_edge_valid_at_utc
|
|
):
|
|
# Expire new edge since we have information about more recent events
|
|
resolved_edge.invalid_at = candidate.valid_at
|
|
resolved_edge.expired_at = now
|
|
break
|
|
|
|
# Determine which contradictory edges need to be expired
|
|
invalidated_edges: list[EntityEdge] = resolve_edge_contradictions(
|
|
resolved_edge, invalidation_candidates
|
|
)
|
|
duplicate_edges: list[EntityEdge] = [related_edges[idx] for idx in duplicate_fact_ids]
|
|
|
|
return resolved_edge, invalidated_edges, duplicate_edges
|
|
|
|
|
|
async def filter_existing_duplicate_of_edges(
|
|
driver: GraphDriver, duplicates_node_tuples: list[tuple[EntityNode, EntityNode]]
|
|
) -> list[tuple[EntityNode, EntityNode]]:
|
|
if not duplicates_node_tuples:
|
|
return []
|
|
|
|
duplicate_nodes_map = {
|
|
(source.uuid, target.uuid): (source, target) for source, target in duplicates_node_tuples
|
|
}
|
|
|
|
if driver.provider == GraphProvider.NEPTUNE:
|
|
query: LiteralString = """
|
|
UNWIND $duplicate_node_uuids AS duplicate_tuple
|
|
MATCH (n:Entity {uuid: duplicate_tuple.source})-[r:RELATES_TO {name: 'IS_DUPLICATE_OF'}]->(m:Entity {uuid: duplicate_tuple.target})
|
|
RETURN DISTINCT
|
|
n.uuid AS source_uuid,
|
|
m.uuid AS target_uuid
|
|
"""
|
|
|
|
duplicate_nodes = [
|
|
{'source': source.uuid, 'target': target.uuid}
|
|
for source, target in duplicates_node_tuples
|
|
]
|
|
|
|
records, _, _ = await driver.execute_query(
|
|
query,
|
|
duplicate_node_uuids=duplicate_nodes,
|
|
routing_='r',
|
|
)
|
|
else:
|
|
if driver.provider == GraphProvider.KUZU:
|
|
query = """
|
|
UNWIND $duplicate_node_uuids AS duplicate
|
|
MATCH (n:Entity {uuid: duplicate.src})-[:RELATES_TO]->(e:RelatesToNode_ {name: 'IS_DUPLICATE_OF'})-[:RELATES_TO]->(m:Entity {uuid: duplicate.dst})
|
|
RETURN DISTINCT
|
|
n.uuid AS source_uuid,
|
|
m.uuid AS target_uuid
|
|
"""
|
|
duplicate_node_uuids = [{'src': src, 'dst': dst} for src, dst in duplicate_nodes_map]
|
|
else:
|
|
query: LiteralString = """
|
|
UNWIND $duplicate_node_uuids AS duplicate_tuple
|
|
MATCH (n:Entity {uuid: duplicate_tuple[0]})-[r:RELATES_TO {name: 'IS_DUPLICATE_OF'}]->(m:Entity {uuid: duplicate_tuple[1]})
|
|
RETURN DISTINCT
|
|
n.uuid AS source_uuid,
|
|
m.uuid AS target_uuid
|
|
"""
|
|
duplicate_node_uuids = list(duplicate_nodes_map.keys())
|
|
|
|
records, _, _ = await driver.execute_query(
|
|
query,
|
|
duplicate_node_uuids=duplicate_node_uuids,
|
|
routing_='r',
|
|
)
|
|
|
|
# Remove duplicates that already have the IS_DUPLICATE_OF edge
|
|
for record in records:
|
|
duplicate_tuple = (record.get('source_uuid'), record.get('target_uuid'))
|
|
if duplicate_nodes_map.get(duplicate_tuple):
|
|
duplicate_nodes_map.pop(duplicate_tuple)
|
|
|
|
return list(duplicate_nodes_map.values())
|