conductor-checkpoint-start
This commit is contained in:
parent
20816c3c19
commit
6984f7a55f
5 changed files with 226 additions and 5 deletions
5
.cursor/worktrees.json
Normal file
5
.cursor/worktrees.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"setup-worktree": [
|
||||||
|
"make install"
|
||||||
|
]
|
||||||
|
}
|
||||||
126
SUMMARY_PIPELINE_STATE.md
Normal file
126
SUMMARY_PIPELINE_STATE.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# Graphiti Node Summary Pipelines
|
||||||
|
|
||||||
|
This document outlines how entity summaries are generated today and the pragmatic changes proposed to gate expensive LLM calls using novelty and recency heuristics.
|
||||||
|
|
||||||
|
## Current Execution Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------+ +-------------------------------+
|
||||||
|
| Episode Ingest | ----> | retrieve_episodes(last=3) |
|
||||||
|
+------------------+ +-------------------------------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+----------------------+ +---------------------------+
|
||||||
|
| extract_nodes | | extract_edges |
|
||||||
|
| (LLM + reflexion) | | (LLM fact extraction) |
|
||||||
|
+----------------------+ +---------------------------+
|
||||||
|
| |
|
||||||
|
| dedupe candidates |
|
||||||
|
v v
|
||||||
|
+--------------------------+ +---------------------------+
|
||||||
|
| resolve_extracted_nodes | | resolve_extracted_edges |
|
||||||
|
| (deterministic + LLM) | | (LLM dedupe, invalidates)|
|
||||||
|
+--------------------------+ +---------------------------+
|
||||||
|
| |
|
||||||
|
+----------- parallel ----+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+-----------------------------------------+
|
||||||
|
| extract_attributes_from_nodes |
|
||||||
|
| - LLM attribute fill (optional) |
|
||||||
|
| - LLM summary refresh (always runs) |
|
||||||
|
+-----------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Current Data & Timing Characteristics
|
||||||
|
|
||||||
|
- **Previous context**: only the latest `EPISODE_WINDOW_LEN = 3` episodes are retrieved before any LLM call.
|
||||||
|
- **Fact availability**: raw `EntityEdge.fact` strings exist immediately after edge extraction; embeddings are produced inside `resolve_extracted_edges`, concurrently with summary generation.
|
||||||
|
- **Summary inputs**: the summary prompt only sees `node.summary`, node attributes, episode content, and the three-episode window. It does *not* observe resolved fact invalidations or final edge embeddings.
|
||||||
|
- **LLM usage**: every node that survives dedupe invokes the summary prompt, even when the episode is low-information or repeat content.
|
||||||
|
|
||||||
|
## Proposed Execution Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------+ +-------------------------------+
|
||||||
|
| Episode Ingest | ----> | retrieve_episodes(last=3) |
|
||||||
|
+------------------+ +-------------------------------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+----------------------+ +---------------------------+
|
||||||
|
| extract_nodes | | extract_edges |
|
||||||
|
| (LLM + reflexion) | | (LLM fact extraction) |
|
||||||
|
+----------------------+ +---------------------------+
|
||||||
|
| |
|
||||||
|
| dedupe candidates |
|
||||||
|
v v
|
||||||
|
+--------------------------+ +---------------------------+
|
||||||
|
| resolve_extracted_nodes | | build_node_deltas |
|
||||||
|
| (deterministic + LLM) | | - new facts per node |
|
||||||
|
+--------------------------+ | - embed facts upfront |
|
||||||
|
| | - track candidate flips |
|
||||||
|
| +-------------+-------------+
|
||||||
|
| |
|
||||||
|
| v
|
||||||
|
| +---------------------------+
|
||||||
|
| | resolve_extracted_edges |
|
||||||
|
| | (uses prebuilt embeddings|
|
||||||
|
| | updates NodeDelta state)|
|
||||||
|
| +-------------+-------------+
|
||||||
|
| |
|
||||||
|
| v
|
||||||
|
| +---------------------------+
|
||||||
|
| | summary_gate.should_refresh|
|
||||||
|
| | - fact hash drift |
|
||||||
|
| | - embedding drift |
|
||||||
|
| | - negation / invalidation |
|
||||||
|
| | - burst & staleness rules |
|
||||||
|
| +------+------+------------+
|
||||||
|
| | |
|
||||||
|
| | +------------------------------+
|
||||||
|
| | |
|
||||||
|
| v v
|
||||||
|
| +---------------------------+ +-----------------------------+
|
||||||
|
| | skip summary (log cause) | | extract_summary LLM call |
|
||||||
|
| | update metadata only | | update summary & metadata |
|
||||||
|
| +---------------------------+ +-----------------------------+
|
||||||
|
v
|
||||||
|
+-------------------------------+
|
||||||
|
| add_nodes_and_edges_bulk |
|
||||||
|
+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Proposed Changes
|
||||||
|
|
||||||
|
1. **NodeDelta staging**
|
||||||
|
- Generate fact embeddings immediately after edge extraction (`create_entity_edge_embeddings`) and group fact deltas by target node.
|
||||||
|
- Record potential invalidations and episode timestamps within the delta so the summary gate has full context.
|
||||||
|
|
||||||
|
2. **Deterministic novelty checks**
|
||||||
|
- Maintain a stable hash of active facts (new facts minus invalidated). Skip summarisation when the hash matches the stored value.
|
||||||
|
- Compare pooled fact embeddings to the persisted summary embedding; trigger refresh only when cosine drift exceeds a tuned threshold.
|
||||||
|
- Force refresh whenever the delta indicates polarity changes (contradiction/negation cues).
|
||||||
|
|
||||||
|
3. **Recency & burst handling**
|
||||||
|
- Track recent episode timestamps per node in metadata. If multiple episodes arrive within a short window, accumulate deltas and defer the summary until the burst ends or a hard cap is reached.
|
||||||
|
- Enforce a staleness SLA (`last_summary_ts`) so long-lived nodes eventually refresh even if novelty remains low.
|
||||||
|
|
||||||
|
4. **Metadata persistence**
|
||||||
|
- Persist gate state on each entity (`_graphiti_meta`: `fact_hash`, `summary_embedding`, `last_summary_ts`, `_recent_episode_times`, `_burst_active`).
|
||||||
|
- Update metadata whether the summary runs or is skipped to keep the gating logic deterministic across ingests.
|
||||||
|
|
||||||
|
5. **Observability**
|
||||||
|
- Emit counters for `summary_skipped`, `summary_refreshed`, and reasons (unchanged hash, low drift, burst deferral, staleness). Sample a small percentage of skipped cases to validate heuristics.
|
||||||
|
|
||||||
|
## Implementation Snapshot
|
||||||
|
|
||||||
|
| Area | Current | Proposed |
|
||||||
|
| ---- | ------- | -------- |
|
||||||
|
| Summary trigger | Always per deduped node | Controlled by `summary_gate` using fact hash, embedding drift, negation, burst, staleness |
|
||||||
|
| Fact embeddings | Produced during edge resolution (parallel) | Produced immediately after extraction and reused downstream |
|
||||||
|
| Fact availability in summaries | Not available | Encapsulated in `NodeDelta` passed into summary gate |
|
||||||
|
| Metadata on node | Summary text + organic attributes | Summary text + `_graphiti_meta` (hash, embedding, timestamps, burst state) |
|
||||||
|
| Recency handling | None | Deque of recent episode timestamps + burst deferral |
|
||||||
|
| Negation detection | LLM-only inside edge resolution | Propagated into gate to force summary refresh |
|
||||||
|
|
||||||
|
These adjustments retain the existing inline execution model—no scheduled jobs—while reducing unnecessary LLM calls and improving determinism by grounding summaries in the same fact set that backs the graph.
|
||||||
|
|
@ -18,6 +18,7 @@ import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
import inspect
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from falkordb import Graph as FalkorGraph
|
from falkordb import Graph as FalkorGraph
|
||||||
|
|
@ -101,12 +102,11 @@ class FalkorDriverSession(GraphDriverSession):
|
||||||
if isinstance(query, list):
|
if isinstance(query, list):
|
||||||
for cypher, params in query:
|
for cypher, params in query:
|
||||||
params = convert_datetimes_to_strings(params)
|
params = convert_datetimes_to_strings(params)
|
||||||
await self.graph.query(str(cypher), params) # type: ignore[reportUnknownArgumentType]
|
await _await_graph_query(self.graph, str(cypher), params) # type: ignore[reportUnknownArgumentType]
|
||||||
else:
|
else:
|
||||||
params = dict(kwargs)
|
params = dict(kwargs)
|
||||||
params = convert_datetimes_to_strings(params)
|
params = convert_datetimes_to_strings(params)
|
||||||
await self.graph.query(str(query), params) # type: ignore[reportUnknownArgumentType]
|
await _await_graph_query(self.graph, str(query), params) # type: ignore[reportUnknownArgumentType]
|
||||||
# Assuming `graph.query` is async (ideal); otherwise, wrap in executor
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,6 +122,9 @@ class FalkorDriver(GraphDriver):
|
||||||
password: str | None = None,
|
password: str | None = None,
|
||||||
falkor_db: FalkorDB | None = None,
|
falkor_db: FalkorDB | None = None,
|
||||||
database: str = 'default_db',
|
database: str = 'default_db',
|
||||||
|
*,
|
||||||
|
lite: bool = False,
|
||||||
|
lite_db_path: str | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the FalkorDB driver.
|
Initialize the FalkorDB driver.
|
||||||
|
|
@ -137,7 +140,23 @@ class FalkorDriver(GraphDriver):
|
||||||
# If a FalkorDB instance is provided, use it directly
|
# If a FalkorDB instance is provided, use it directly
|
||||||
self.client = falkor_db
|
self.client = falkor_db
|
||||||
else:
|
else:
|
||||||
self.client = FalkorDB(host=host, port=port, username=username, password=password)
|
if lite:
|
||||||
|
# Lazy import to avoid mandatory dependency when not using Lite
|
||||||
|
try:
|
||||||
|
from redislite.falkordb_client import FalkorDB as LiteFalkorDB # type: ignore
|
||||||
|
except Exception as e: # broad to surface helpful message
|
||||||
|
raise ImportError(
|
||||||
|
'falkordblite is required for FalkorDB Lite. Install it with: '\
|
||||||
|
'pip install falkordblite'
|
||||||
|
) from e
|
||||||
|
|
||||||
|
db_path = lite_db_path or '/tmp/falkordb.db'
|
||||||
|
lite_client = LiteFalkorDB(db_path)
|
||||||
|
self.client = _AsyncLiteClientAdapter(lite_client)
|
||||||
|
self._is_lite = True
|
||||||
|
else:
|
||||||
|
self.client = FalkorDB(host=host, port=port, username=username, password=password)
|
||||||
|
self._is_lite = False
|
||||||
|
|
||||||
self.fulltext_syntax = '@' # FalkorDB uses a redisearch-like syntax for fulltext queries see https://redis.io/docs/latest/develop/ai/search-and-query/query/full-text/
|
self.fulltext_syntax = '@' # FalkorDB uses a redisearch-like syntax for fulltext queries see https://redis.io/docs/latest/develop/ai/search-and-query/query/full-text/
|
||||||
|
|
||||||
|
|
@ -154,7 +173,7 @@ class FalkorDriver(GraphDriver):
|
||||||
params = convert_datetimes_to_strings(dict(kwargs))
|
params = convert_datetimes_to_strings(dict(kwargs))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await graph.query(cypher_query_, params) # type: ignore[reportUnknownArgumentType]
|
result = await _await_graph_query(graph, cypher_query_, params) # type: ignore[reportUnknownArgumentType]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if 'already indexed' in str(e):
|
if 'already indexed' in str(e):
|
||||||
# check if index already exists
|
# check if index already exists
|
||||||
|
|
@ -191,6 +210,9 @@ class FalkorDriver(GraphDriver):
|
||||||
await self.client.connection.aclose()
|
await self.client.connection.aclose()
|
||||||
elif hasattr(self.client.connection, 'close'):
|
elif hasattr(self.client.connection, 'close'):
|
||||||
await self.client.connection.close()
|
await self.client.connection.close()
|
||||||
|
else:
|
||||||
|
# Lite adapter exposes no-op aclose; nothing to do otherwise
|
||||||
|
pass
|
||||||
|
|
||||||
async def delete_all_indexes(self) -> None:
|
async def delete_all_indexes(self) -> None:
|
||||||
result = await self.execute_query('CALL db.indexes()')
|
result = await self.execute_query('CALL db.indexes()')
|
||||||
|
|
@ -329,3 +351,45 @@ class FalkorDriver(GraphDriver):
|
||||||
full_query = group_filter + ' (' + sanitized_query + ')'
|
full_query = group_filter + ' (' + sanitized_query + ')'
|
||||||
|
|
||||||
return full_query
|
return full_query
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------
|
||||||
|
# Internal helpers
|
||||||
|
# -----------------
|
||||||
|
|
||||||
|
async def _await_graph_query(graph: Any, cypher: str, params: dict[str, Any] | None):
|
||||||
|
"""
|
||||||
|
Await a graph.query call whether it's native-async or sync (Lite).
|
||||||
|
"""
|
||||||
|
query_callable = getattr(graph, 'query')
|
||||||
|
result = query_callable(cypher, params)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
return await result
|
||||||
|
# Sync path: run in a thread to avoid blocking the event loop
|
||||||
|
return await asyncio.to_thread(query_callable, cypher, params)
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncLiteGraphAdapter:
|
||||||
|
def __init__(self, sync_graph: Any):
|
||||||
|
self._sync_graph = sync_graph
|
||||||
|
|
||||||
|
async def query(self, cypher: str, params: dict[str, Any] | None = None):
|
||||||
|
return await asyncio.to_thread(self._sync_graph.query, cypher, params)
|
||||||
|
|
||||||
|
async def ro_query(self, cypher: str, params: dict[str, Any] | None = None):
|
||||||
|
return await asyncio.to_thread(self._sync_graph.ro_query, cypher, params)
|
||||||
|
|
||||||
|
async def delete(self):
|
||||||
|
return await asyncio.to_thread(self._sync_graph.delete)
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncLiteClientAdapter:
|
||||||
|
def __init__(self, sync_db: Any):
|
||||||
|
self._sync_db = sync_db
|
||||||
|
|
||||||
|
def select_graph(self, name: str):
|
||||||
|
return _AsyncLiteGraphAdapter(self._sync_db.select_graph(name))
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
# redislite does not expose explicit close; rely on GC. No-op.
|
||||||
|
return None
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ groq = ["groq>=0.2.0"]
|
||||||
google-genai = ["google-genai>=1.8.0"]
|
google-genai = ["google-genai>=1.8.0"]
|
||||||
kuzu = ["kuzu>=0.11.3"]
|
kuzu = ["kuzu>=0.11.3"]
|
||||||
falkordb = ["falkordb>=1.1.2,<2.0.0"]
|
falkordb = ["falkordb>=1.1.2,<2.0.0"]
|
||||||
|
falkordb-lite = ["falkordblite>=0.1.0"]
|
||||||
voyageai = ["voyageai>=0.2.3"]
|
voyageai = ["voyageai>=0.2.3"]
|
||||||
neo4j-opensearch = ["boto3>=1.39.16", "opensearch-py>=3.0.0"]
|
neo4j-opensearch = ["boto3>=1.39.16", "opensearch-py>=3.0.0"]
|
||||||
sentence-transformers = ["sentence-transformers>=3.2.1"]
|
sentence-transformers = ["sentence-transformers>=3.2.1"]
|
||||||
|
|
@ -42,6 +43,7 @@ dev = [
|
||||||
"anthropic>=0.49.0",
|
"anthropic>=0.49.0",
|
||||||
"google-genai>=1.8.0",
|
"google-genai>=1.8.0",
|
||||||
"falkordb>=1.1.2,<2.0.0",
|
"falkordb>=1.1.2,<2.0.0",
|
||||||
|
"falkordblite>=0.1.0",
|
||||||
"kuzu>=0.11.3",
|
"kuzu>=0.11.3",
|
||||||
"boto3>=1.39.16",
|
"boto3>=1.39.16",
|
||||||
"opensearch-py>=3.0.0",
|
"opensearch-py>=3.0.0",
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,30 @@ class TestFalkorDriver:
|
||||||
"""Test driver provider identification."""
|
"""Test driver provider identification."""
|
||||||
assert self.driver.provider == GraphProvider.FALKORDB
|
assert self.driver.provider == GraphProvider.FALKORDB
|
||||||
|
|
||||||
|
def test_init_with_lite_uses_redislite_adapter(self):
|
||||||
|
"""Test initialization with lite=True uses redislite client adapter.
|
||||||
|
|
||||||
|
We don't require falkordb for this test as we patch the redislite import path.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
# Create a fake redislite.falkordb_client module with FalkorDB class
|
||||||
|
fake_redislite = types.ModuleType('redislite')
|
||||||
|
fake_client_mod = types.ModuleType('redislite.falkordb_client')
|
||||||
|
fake_client_mod.FalkorDB = MagicMock()
|
||||||
|
|
||||||
|
with patch.dict(sys.modules, {
|
||||||
|
'redislite': fake_redislite,
|
||||||
|
'redislite.falkordb_client': fake_client_mod,
|
||||||
|
}):
|
||||||
|
from graphiti_core.driver.falkordb_driver import FalkorDriver as _FD
|
||||||
|
|
||||||
|
try:
|
||||||
|
_FD(lite=True, database='default_db')
|
||||||
|
except Exception as e:
|
||||||
|
pytest.fail(f'Lite initialization raised unexpectedly: {e}')
|
||||||
|
|
||||||
@unittest.skipIf(not HAS_FALKORDB, 'FalkorDB is not installed')
|
@unittest.skipIf(not HAS_FALKORDB, 'FalkorDB is not installed')
|
||||||
def test_get_graph_with_name(self):
|
def test_get_graph_with_name(self):
|
||||||
"""Test _get_graph with specific graph name."""
|
"""Test _get_graph with specific graph name."""
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue