grafeo-memory 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/CHANGELOG.md +80 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/PKG-INFO +16 -4
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/README.md +15 -3
- grafeo_memory-0.2.2/examples/temporal_bench.py +129 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/pyproject.toml +1 -1
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/__init__.py +17 -1
- grafeo_memory-0.2.2/src/grafeo_memory/communities.py +283 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/__init__.py +3 -0
- grafeo_memory-0.2.2/src/grafeo_memory/extraction/temporal.py +113 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/manager.py +514 -49
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/resources.py +13 -11
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/tools.py +5 -1
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/prompts.py +52 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/schemas.py +27 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/search/graph.py +33 -19
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/search/vector.py +8 -2
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/temporal.py +14 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/types.py +56 -0
- grafeo_memory-0.2.2/tests/conftest.py +21 -0
- grafeo_memory-0.2.2/tests/test_bitemporal.py +380 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_cli.py +3 -1
- grafeo_memory-0.2.2/tests/test_communities.py +405 -0
- grafeo_memory-0.2.2/tests/test_concurrency.py +119 -0
- grafeo_memory-0.2.2/tests/test_db_injection.py +117 -0
- grafeo_memory-0.2.2/tests/test_episodes.py +356 -0
- grafeo_memory-0.2.2/tests/test_error_surfacing.py +105 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_extractor.py +32 -0
- grafeo_memory-0.2.2/tests/test_graph_name.py +338 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_manager.py +387 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_mcp_tools.py +62 -0
- grafeo_memory-0.2.2/tests/test_multi_session.py +80 -0
- grafeo_memory-0.2.2/tests/test_multi_tenant.py +208 -0
- grafeo_memory-0.2.2/tests/test_persistence_lifecycle.py +97 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_reconciler.py +53 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_search_quality.py +46 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_stats.py +36 -0
- grafeo_memory-0.2.2/tests/test_temporal_integration.py +123 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/uv.lock +1 -1
- grafeo_memory-0.2.0/tests/conftest.py +0 -7
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/.env.example +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/.github/workflows/ci.yml +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/.github/workflows/pypi.yml +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/.gitignore +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/CONTRIBUTING.md +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/LICENCE +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/coverage.xml +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/async_memory.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/importance_scoring.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/mistral_quickstart.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/multimodal_vision.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/procedural_memory.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/quickstart.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/topology_boost.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/examples/usage_tracking.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/__main__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/_compat.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/_tracing.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/cli.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/embedding.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/entities.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/facts.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/history.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/__init__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/prompts.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/server.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/messages.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/protocol.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/__init__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/memories.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/relations.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/reranker.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/scoring.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/search/__init__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/src/grafeo_memory/vision.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/__init__.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/mock_llm.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_config_validation.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_coverage_gaps.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_explain.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_history.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_instrumentation.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_messages.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_multi_hop.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_new_features.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_reranker.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_scoring.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_temporal.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_topology_scoring.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_types.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_usage.py +0 -0
- {grafeo_memory-0.2.0 → grafeo_memory-0.2.2}/tests/test_vision.py +0 -0
|
@@ -5,6 +5,84 @@ All notable changes to grafeo-memory are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.2] - 2026-04-08
|
|
9
|
+
|
|
10
|
+
Bi-temporal validity tracking, episode-based provenance, and community summaries for richer temporal knowledge graphs.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Bi-temporal model** (opt-in): every Memory node can now carry `valid_at` (when the fact became true in reality) and `invalid_at` (when it ceased being true), orthogonal to the existing system-time properties `created_at`/`expired_at`. Enable with `enable_bitemporal=True`
|
|
15
|
+
- **Temporal annotation extraction**: new LLM step parses real-world dates from message context (ISO-8601, year-only, relative dates) and attaches them to extracted facts. New module `extraction/temporal.py` with `annotate_temporal_async()`
|
|
16
|
+
- **Point-in-time queries**: `search(query, point_in_time=epoch_ms)` filters to facts that were valid at a specific moment. Memories with no `valid_at` are included permissively (backward compatible)
|
|
17
|
+
- **Point-in-time hint detection**: `detect_temporal_hints()` now recognizes "as of", "at that time", "back then", "in YYYY" patterns and sets `point_in_time_hint=True` with automatic expired-memory inclusion
|
|
18
|
+
- **Episode provenance** (opt-in): `add()` creates `:Episode` nodes storing the raw message text, linked to produced memories via `PRODUCED` edges and to extracted entities via `MENTIONS` edges. Enable with `enable_episodes=True`
|
|
19
|
+
- **Session replay via episodes**: `NEXT_EPISODE` edges chain episodes within a run. When episodes are enabled, `LEADS_TO` edges between memories are no longer created (episodes handle session ordering)
|
|
20
|
+
- **`get_episodes()`**: retrieve all episodes for a user, optionally filtered by `session_id`
|
|
21
|
+
- **`get_provenance(memory_id)`**: trace any memory back to the raw episode that produced it
|
|
22
|
+
- **`episode_chain(episode_id, direction)`**: follow `NEXT_EPISODE` edges forward, backward, or both for session replay
|
|
23
|
+
- **Community summaries** (opt-in): after Louvain community detection, creates `:Community` nodes with LLM-generated `name` and `summary` for entity clusters (>= 2 members). Lazy materialization on next `search()`. Enable with `enable_community_summaries=True` (requires `enable_graph_algorithms=True`)
|
|
24
|
+
- **`get_communities()`**: retrieve all detected communities and their summaries
|
|
25
|
+
- **Community context in search**: relevant community summaries are discoverable via `get_community_context()` for entity-based lookups
|
|
26
|
+
- New node labels: `Episode`, `Community`
|
|
27
|
+
- New edge types: `PRODUCED`, `MENTIONS`, `NEXT_EPISODE`, `HAS_MEMBER`
|
|
28
|
+
- New config options: `enable_bitemporal`, `enable_episodes`, `enable_community_summaries`
|
|
29
|
+
- New types: `EpisodeResult`, `CommunityInfo`, `TemporalAnnotation`, `TemporalAnnotationOutput`, `CommunitySummaryOutput`
|
|
30
|
+
- New exports: `EPISODE_LABEL`, `PRODUCED_EDGE`, `MENTIONS_EDGE`, `NEXT_EPISODE_EDGE`, `COMMUNITY_LABEL`, `HAS_MEMBER_EDGE`, `EpisodeResult`, `CommunityInfo`
|
|
31
|
+
- New modules: `extraction/temporal.py`, `communities.py`
|
|
32
|
+
- New prompts: `TEMPORAL_ANNOTATION_SYSTEM/USER`, `COMMUNITY_SUMMARY_SYSTEM/USER`
|
|
33
|
+
- **`graph_name` scoping**: optional `graph_name` on `MemoryConfig` isolates memories and entities per subgraph when multiple callers share a database via `db=`. Stamped on all Memory and Entity creates, filtered on all read paths (`_build_filters`, `graph_search`, `temporal_chain`, session linking, `stats`). No behavior change when unset ([#23](https://github.com/GrafeoDB/grafeo-memory/issues/23), [#24](https://github.com/GrafeoDB/grafeo-memory/pull/24), thanks [@Michaelzag](https://github.com/Michaelzag))
|
|
34
|
+
- `graph_name` added to property index list for fast scoped lookups
|
|
35
|
+
- 15 new tests covering `graph_name` write stamping, read isolation, entity separation, stats scoping, temporal chain boundaries, and backward compatibility
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- `SearchResult` gains two new fields: `valid_at`, `invalid_at` (both default `None`, backward compatible)
|
|
40
|
+
- `MemoryEvent` gains `valid_at` field (default `None`, backward compatible)
|
|
41
|
+
- `ExtractionResult` gains `temporal_annotations` field (default empty dict, backward compatible)
|
|
42
|
+
- `MemoryStats` gains `episode_count` and `community_count` fields (both default 0)
|
|
43
|
+
- `search()`, `explain()` gain `point_in_time` keyword parameter on both `MemoryManager` and `AsyncMemoryManager`
|
|
44
|
+
- `_expire_memory()` sets `invalid_at` on the expired node when bi-temporal mode is enabled
|
|
45
|
+
- `_execute_decisions()` passes `valid_at` from temporal annotations to new memories and sets `invalid_at` on superseded memories
|
|
46
|
+
- `_stats_impl()` now counts Episode and Community nodes
|
|
47
|
+
- `db_info` in stats includes `episode_node_count` and `community_node_count`
|
|
48
|
+
- MCP `memory_search` tool gains `point_in_time` parameter
|
|
49
|
+
|
|
50
|
+
## [0.2.1] - 2026-04-07
|
|
51
|
+
|
|
52
|
+
Test quality, error surfacing, documentation improvements, and shared database support.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **Shared database support**: `MemoryManager` and `AsyncMemoryManager` accept an optional `db=` keyword argument to inject an existing `GrafeoDB` instance instead of creating one internally. `close()` respects ownership and leaves externally provided databases open ([#18](https://github.com/GrafeoDB/grafeo-memory/issues/18), [#19](https://github.com/GrafeoDB/grafeo-memory/pull/19), thanks [@Michaelzag](https://github.com/Michaelzag))
|
|
57
|
+
- **Multi-tenant safety**: all internal operations are now scoped to Memory and Entity nodes, making it safe to share a database with non-memory data
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **Graph metrics pollution**: `_recompute_graph_metrics()` now writes `_pagerank`, `_betweenness`, `_community` only to Memory/Entity nodes instead of every node in the database
|
|
62
|
+
- **Unlabeled Cypher queries**: `temporal_chain()` and `_get_node_relations()` now require `:Memory` labels on match patterns, preventing traversal from foreign nodes
|
|
63
|
+
- **N+1 entity lookup**: `_find_or_create_entity` and `graph_search` replaced global `find_nodes_by_property` + Python filtering with single label-scoped Cypher queries, reducing FFI crossings
|
|
64
|
+
- **Stats data leak**: `_stats_impl()` no longer exposes raw `db.info()`, returning only memory-scoped counts. MCP `memory://stats` resource now delegates to the manager's scoped stats
|
|
65
|
+
- **Error surfacing tests**: broken embedder on add/search now verified to raise, not silently return empty results
|
|
66
|
+
- **Persistence lifecycle tests**: write, close, reopen round-trip with `tmp_path`
|
|
67
|
+
- **Multi-session regression tests**: sequential open/close/open guards against event loop corruption
|
|
68
|
+
- **Concurrency tests**: concurrent async adds for same user (lock verification) and different users (isolation)
|
|
69
|
+
- **Reconciliation boundary test**: controlled embeddings at threshold boundary verify UPDATE triggers
|
|
70
|
+
- **History ordering test**: LEADS_TO chain verified for session-order retrieval
|
|
71
|
+
- **Delete cascade test**: orphaned entity behavior after memory deletion documented
|
|
72
|
+
- **Memory type filter test**: search with `memory_type` filter verified
|
|
73
|
+
- **Summarize test**: non-empty summary from related memories
|
|
74
|
+
- **MCP tool smoke tests**: all tool functions imported and called with mock context
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
|
|
78
|
+
- `test_search_with_broken_embedder` now uses `pytest.raises` instead of silently passing
|
|
79
|
+
- Concurrency tests use `async with` context manager for guaranteed cleanup
|
|
80
|
+
- Error surfacing tests wrapped in `try/finally` for manager cleanup
|
|
81
|
+
- Shared `make_manager` helper extracted to `conftest.py`
|
|
82
|
+
- README: added model compatibility table (OpenAI, Anthropic, Mistral, Groq, Google)
|
|
83
|
+
- README: documented reconciliation threshold tuning and `history()` return type
|
|
84
|
+
- 435 tests passing
|
|
85
|
+
|
|
8
86
|
## [0.2.0] - 2026-03-26
|
|
9
87
|
|
|
10
88
|
Temporal reasoning, multi-hop search and engine integration for better long running memory performance.
|
|
@@ -197,6 +275,8 @@ Initial release.
|
|
|
197
275
|
- **Windows async compatibility**: persistent `asyncio.Runner` and `ProactorEventLoop` safety net for Python 3.13+
|
|
198
276
|
- 230 tests, 83% coverage
|
|
199
277
|
|
|
278
|
+
[Unreleased]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.2.1...HEAD
|
|
279
|
+
[0.2.1]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.2.0...v0.2.1
|
|
200
280
|
[0.2.0]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.1.5...v0.2.0
|
|
201
281
|
[0.1.5]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.1.4...v0.1.5
|
|
202
282
|
[0.1.4]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.1.3...v0.1.4
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: grafeo-memory
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: AI memory layer powered by GrafeoDB's hybrid graph and vector database
|
|
5
5
|
Project-URL: Homepage, https://github.com/GrafeoDB/grafeo-memory
|
|
6
6
|
Project-URL: Repository, https://github.com/GrafeoDB/grafeo-memory
|
|
@@ -183,6 +183,18 @@ MemoryManager("google-gla:gemini-2.0-flash", config, embedder=embedder)
|
|
|
183
183
|
|
|
184
184
|
Both accept an optional `model` parameter to override the default.
|
|
185
185
|
|
|
186
|
+
### Which model works best?
|
|
187
|
+
|
|
188
|
+
| Provider | Combined extraction | Individual extraction | Notes |
|
|
189
|
+
| --- | --- | --- | --- |
|
|
190
|
+
| OpenAI (gpt-4o-mini) | Works | Works | Recommended default |
|
|
191
|
+
| Anthropic (claude-sonnet) | Works | Works | Untested in CI |
|
|
192
|
+
| Mistral (mistral-small) | Falls back | Works | 3 API calls instead of 1 |
|
|
193
|
+
| Groq | Unknown | Unknown | Needs testing |
|
|
194
|
+
| Google | Unknown | Unknown | Needs testing |
|
|
195
|
+
|
|
196
|
+
grafeo-memory tries a single "combined" LLM call first (facts + entities + relations in one schema). If the model cannot handle the combined schema, it falls back to separate calls automatically. Mistral models typically trigger this fallback, which means 3 API calls per `add()` instead of 1. The fallback is silent (logged at DEBUG level, no traceback printed).
|
|
197
|
+
|
|
186
198
|
## Custom Embeddings
|
|
187
199
|
|
|
188
200
|
Implement the `EmbeddingClient` protocol to use any embedding provider:
|
|
@@ -306,7 +318,7 @@ config = MemoryConfig(instrument=InstrumentationSettings(
|
|
|
306
318
|
- `.delete(memory_id)` → `bool`
|
|
307
319
|
- `.delete_all(user_id=None)` → `int` (count deleted)
|
|
308
320
|
- `.summarize(user_id=None, *, preserve_recent=5, batch_size=20)` → `AddResult`
|
|
309
|
-
- `.history(memory_id)` → `list[HistoryEntry]
|
|
321
|
+
- `.history(memory_id)` → `list[HistoryEntry]`: returns change history sorted by timestamp (oldest first)
|
|
310
322
|
- `.set_importance(memory_id, importance)` → `bool`
|
|
311
323
|
- `.close()`: close the database
|
|
312
324
|
|
|
@@ -318,7 +330,7 @@ Use as a context manager: `with MemoryManager(...) as memory:`. Multiple session
|
|
|
318
330
|
- `user_id`: default user scope (default `"default"`)
|
|
319
331
|
- `session_id`: default session scope
|
|
320
332
|
- `agent_id`: default agent scope
|
|
321
|
-
- `reconciliation_threshold`: minimum similarity for reconciliation candidates (default 0.
|
|
333
|
+
- `reconciliation_threshold`: minimum cosine similarity (0.0 to 1.0) for a memory to be considered a reconciliation candidate during `add()`. Lower values find more candidates, higher values require closer matches. At 0.3 (default), loosely related memories are considered. At 0.7+, only very similar memories trigger UPDATE/DELETE decisions
|
|
322
334
|
- `search_min_score`: minimum score for search results, 0.0 returns everything (default 0.0)
|
|
323
335
|
- `agreement_bonus`: score boost when both vector and graph find the same memory (default 0.1)
|
|
324
336
|
- `embedding_dimensions`: vector dimensions (default 1536)
|
|
@@ -340,7 +352,7 @@ Use as a context manager: `with MemoryManager(...) as memory:`. Multiple session
|
|
|
340
352
|
- **`SearchResponse`**: list subclass of `SearchResult`, with `.usage` for LLM token counts
|
|
341
353
|
- **`MemoryEvent`**: `.action` (ADD/UPDATE/DELETE/NONE), `.memory_id`, `.text`, `.old_text`
|
|
342
354
|
- **`SearchResult`**: `.memory_id`, `.text`, `.score`, `.user_id`, `.metadata`, `.relations`, `.memory_type`
|
|
343
|
-
- **`HistoryEntry`**: `.event
|
|
355
|
+
- **`HistoryEntry`**: `.event` (ADD/UPDATE/DELETE), `.old_text`, `.new_text`, `.timestamp` (epoch ms), `.actor_id`, `.role`. Returned sorted by timestamp ascending (oldest first)
|
|
344
356
|
|
|
345
357
|
### Iteration
|
|
346
358
|
|
|
@@ -145,6 +145,18 @@ MemoryManager("google-gla:gemini-2.0-flash", config, embedder=embedder)
|
|
|
145
145
|
|
|
146
146
|
Both accept an optional `model` parameter to override the default.
|
|
147
147
|
|
|
148
|
+
### Which model works best?
|
|
149
|
+
|
|
150
|
+
| Provider | Combined extraction | Individual extraction | Notes |
|
|
151
|
+
| --- | --- | --- | --- |
|
|
152
|
+
| OpenAI (gpt-4o-mini) | Works | Works | Recommended default |
|
|
153
|
+
| Anthropic (claude-sonnet) | Works | Works | Untested in CI |
|
|
154
|
+
| Mistral (mistral-small) | Falls back | Works | 3 API calls instead of 1 |
|
|
155
|
+
| Groq | Unknown | Unknown | Needs testing |
|
|
156
|
+
| Google | Unknown | Unknown | Needs testing |
|
|
157
|
+
|
|
158
|
+
grafeo-memory tries a single "combined" LLM call first (facts + entities + relations in one schema). If the model cannot handle the combined schema, it falls back to separate calls automatically. Mistral models typically trigger this fallback, which means 3 API calls per `add()` instead of 1. The fallback is silent (logged at DEBUG level, no traceback printed).
|
|
159
|
+
|
|
148
160
|
## Custom Embeddings
|
|
149
161
|
|
|
150
162
|
Implement the `EmbeddingClient` protocol to use any embedding provider:
|
|
@@ -268,7 +280,7 @@ config = MemoryConfig(instrument=InstrumentationSettings(
|
|
|
268
280
|
- `.delete(memory_id)` → `bool`
|
|
269
281
|
- `.delete_all(user_id=None)` → `int` (count deleted)
|
|
270
282
|
- `.summarize(user_id=None, *, preserve_recent=5, batch_size=20)` → `AddResult`
|
|
271
|
-
- `.history(memory_id)` → `list[HistoryEntry]
|
|
283
|
+
- `.history(memory_id)` → `list[HistoryEntry]`: returns change history sorted by timestamp (oldest first)
|
|
272
284
|
- `.set_importance(memory_id, importance)` → `bool`
|
|
273
285
|
- `.close()`: close the database
|
|
274
286
|
|
|
@@ -280,7 +292,7 @@ Use as a context manager: `with MemoryManager(...) as memory:`. Multiple session
|
|
|
280
292
|
- `user_id`: default user scope (default `"default"`)
|
|
281
293
|
- `session_id`: default session scope
|
|
282
294
|
- `agent_id`: default agent scope
|
|
283
|
-
- `reconciliation_threshold`: minimum similarity for reconciliation candidates (default 0.
|
|
295
|
+
- `reconciliation_threshold`: minimum cosine similarity (0.0 to 1.0) for a memory to be considered a reconciliation candidate during `add()`. Lower values find more candidates, higher values require closer matches. At 0.3 (default), loosely related memories are considered. At 0.7+, only very similar memories trigger UPDATE/DELETE decisions
|
|
284
296
|
- `search_min_score`: minimum score for search results, 0.0 returns everything (default 0.0)
|
|
285
297
|
- `agreement_bonus`: score boost when both vector and graph find the same memory (default 0.1)
|
|
286
298
|
- `embedding_dimensions`: vector dimensions (default 1536)
|
|
@@ -302,7 +314,7 @@ Use as a context manager: `with MemoryManager(...) as memory:`. Multiple session
|
|
|
302
314
|
- **`SearchResponse`**: list subclass of `SearchResult`, with `.usage` for LLM token counts
|
|
303
315
|
- **`MemoryEvent`**: `.action` (ADD/UPDATE/DELETE/NONE), `.memory_id`, `.text`, `.old_text`
|
|
304
316
|
- **`SearchResult`**: `.memory_id`, `.text`, `.score`, `.user_id`, `.metadata`, `.relations`, `.memory_type`
|
|
305
|
-
- **`HistoryEntry`**: `.event
|
|
317
|
+
- **`HistoryEntry`**: `.event` (ADD/UPDATE/DELETE), `.old_text`, `.new_text`, `.timestamp` (epoch ms), `.actor_id`, `.role`. Returned sorted by timestamp ascending (oldest first)
|
|
306
318
|
|
|
307
319
|
### Iteration
|
|
308
320
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Quick benchmark for temporal knowledge graph features.
|
|
2
|
+
|
|
3
|
+
Exercises:
|
|
4
|
+
1. Bi-temporal model (valid_at/invalid_at, point-in-time queries)
|
|
5
|
+
2. Episode provenance (Episode nodes, PRODUCED edges, session replay)
|
|
6
|
+
|
|
7
|
+
Requires:
|
|
8
|
+
MISTRAL_API_KEY in .env
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from dotenv import load_dotenv
|
|
16
|
+
|
|
17
|
+
load_dotenv()
|
|
18
|
+
|
|
19
|
+
from mistralai import Mistral # noqa: E402
|
|
20
|
+
|
|
21
|
+
from grafeo_memory import MemoryConfig, MemoryManager, MistralEmbedder # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
client = Mistral()
|
|
26
|
+
embedder = MistralEmbedder(client)
|
|
27
|
+
|
|
28
|
+
config = MemoryConfig(
|
|
29
|
+
user_id="markus",
|
|
30
|
+
enable_bitemporal=True,
|
|
31
|
+
enable_episodes=True,
|
|
32
|
+
run_id="bench_run_1",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
t0 = time.perf_counter()
|
|
36
|
+
|
|
37
|
+
with MemoryManager("mistral:mistral-small-latest", config, embedder=embedder) as mem:
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
# Phase 1: Bi-temporal -- add facts with real-world dates
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
print("=== Phase 1: Bi-Temporal ===\n")
|
|
42
|
+
|
|
43
|
+
msgs = [
|
|
44
|
+
"Markus joined Acme Corp as a software engineer in March 2022.",
|
|
45
|
+
"Markus was promoted to senior engineer at Acme Corp in January 2024.",
|
|
46
|
+
"Markus left Acme and joined Globex Industries in September 2024.",
|
|
47
|
+
"Markus started learning Rust in 2023, he previously only knew Python and Go.",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
for msg in msgs:
|
|
51
|
+
t1 = time.perf_counter()
|
|
52
|
+
events = mem.add(msg)
|
|
53
|
+
dt = (time.perf_counter() - t1) * 1000
|
|
54
|
+
for e in events:
|
|
55
|
+
va = f" valid_at={e.valid_at}" if e.valid_at else ""
|
|
56
|
+
print(f" [{e.action.value.upper():6s}] {e.text}{va}")
|
|
57
|
+
if e.old_text:
|
|
58
|
+
print(f" was: {e.old_text}")
|
|
59
|
+
print(f" ({dt:.0f}ms)\n")
|
|
60
|
+
|
|
61
|
+
# Point-in-time search: "as of mid-2023"
|
|
62
|
+
from datetime import UTC, datetime
|
|
63
|
+
|
|
64
|
+
mid_2023 = int(datetime(2023, 7, 1, tzinfo=UTC).timestamp() * 1000)
|
|
65
|
+
print("Search: 'Where does Markus work?' (point_in_time=2023-07-01)")
|
|
66
|
+
results = mem.search("Where does Markus work?", point_in_time=mid_2023)
|
|
67
|
+
for r in results[:3]:
|
|
68
|
+
va = f" valid_at={r.valid_at}" if r.valid_at else ""
|
|
69
|
+
ia = f" invalid_at={r.invalid_at}" if r.invalid_at else ""
|
|
70
|
+
print(f" [{r.score:.2f}] {r.text}{va}{ia}")
|
|
71
|
+
print()
|
|
72
|
+
|
|
73
|
+
# Current search (no point_in_time)
|
|
74
|
+
print("Search: 'Where does Markus work?' (current)")
|
|
75
|
+
results = mem.search("Where does Markus work?")
|
|
76
|
+
for r in results[:3]:
|
|
77
|
+
va = f" valid_at={r.valid_at}" if r.valid_at else ""
|
|
78
|
+
print(f" [{r.score:.2f}] {r.text}{va}")
|
|
79
|
+
print()
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# Phase 2: Episode provenance
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
print("=== Phase 2: Episode Provenance ===\n")
|
|
85
|
+
|
|
86
|
+
episodes = mem.get_episodes()
|
|
87
|
+
print(f"Episodes created: {len(episodes)}")
|
|
88
|
+
for ep in episodes:
|
|
89
|
+
print(f" [{ep.episode_id}] {ep.content[:60]}...")
|
|
90
|
+
print(f" produced: {ep.produced_memories}")
|
|
91
|
+
print(f" mentions: {ep.mentioned_entities}")
|
|
92
|
+
print()
|
|
93
|
+
|
|
94
|
+
# Session replay
|
|
95
|
+
if episodes:
|
|
96
|
+
print("Episode chain (forward from first):")
|
|
97
|
+
chain = mem.episode_chain(episodes[0].episode_id, direction="forward")
|
|
98
|
+
for ep in chain:
|
|
99
|
+
print(f" -> [{ep.episode_id}] {ep.content[:50]}...")
|
|
100
|
+
print()
|
|
101
|
+
|
|
102
|
+
# Provenance
|
|
103
|
+
memories = mem.get_all()
|
|
104
|
+
if memories:
|
|
105
|
+
mid = memories[0].memory_id
|
|
106
|
+
prov = mem.get_provenance(mid)
|
|
107
|
+
print(f"Provenance for memory '{memories[0].text[:40]}...':")
|
|
108
|
+
for ep in prov:
|
|
109
|
+
print(f" <- Episode [{ep.episode_id}] {ep.content[:50]}...")
|
|
110
|
+
print()
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Stats
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
print("=== Stats ===\n")
|
|
116
|
+
stats = mem.stats()
|
|
117
|
+
print(f" Memories: {stats.total_memories}")
|
|
118
|
+
print(f" Entities: {stats.entity_count}")
|
|
119
|
+
print(f" Relations: {stats.relation_count}")
|
|
120
|
+
print(f" Episodes: {stats.episode_count}")
|
|
121
|
+
print(f" Communities: {stats.community_count}")
|
|
122
|
+
print()
|
|
123
|
+
|
|
124
|
+
total = (time.perf_counter() - t0) * 1000
|
|
125
|
+
print(f"Total time: {total:.0f}ms")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -16,11 +16,19 @@ from .scoring import (
|
|
|
16
16
|
)
|
|
17
17
|
from .temporal import TemporalHints, detect_temporal_hints
|
|
18
18
|
from .types import (
|
|
19
|
+
COMMUNITY_LABEL,
|
|
20
|
+
EPISODE_LABEL,
|
|
21
|
+
HAS_MEMBER_EDGE,
|
|
19
22
|
LEADS_TO_EDGE,
|
|
23
|
+
MENTIONS_EDGE,
|
|
24
|
+
NEXT_EPISODE_EDGE,
|
|
25
|
+
PRODUCED_EDGE,
|
|
20
26
|
SUPERSEDES_EDGE,
|
|
21
27
|
AddResult,
|
|
28
|
+
CommunityInfo,
|
|
22
29
|
EntitiesOutput,
|
|
23
30
|
Entity,
|
|
31
|
+
EpisodeResult,
|
|
24
32
|
ExplainResult,
|
|
25
33
|
ExplainStep,
|
|
26
34
|
ExtractionResult,
|
|
@@ -39,13 +47,21 @@ from .types import (
|
|
|
39
47
|
)
|
|
40
48
|
|
|
41
49
|
__all__ = [
|
|
50
|
+
"COMMUNITY_LABEL",
|
|
51
|
+
"EPISODE_LABEL",
|
|
52
|
+
"HAS_MEMBER_EDGE",
|
|
42
53
|
"LEADS_TO_EDGE",
|
|
54
|
+
"MENTIONS_EDGE",
|
|
55
|
+
"NEXT_EPISODE_EDGE",
|
|
56
|
+
"PRODUCED_EDGE",
|
|
43
57
|
"SUPERSEDES_EDGE",
|
|
44
58
|
"AddResult",
|
|
45
59
|
"AsyncMemoryManager",
|
|
60
|
+
"CommunityInfo",
|
|
46
61
|
"EmbeddingClient",
|
|
47
62
|
"EntitiesOutput",
|
|
48
63
|
"Entity",
|
|
64
|
+
"EpisodeResult",
|
|
49
65
|
"ExplainResult",
|
|
50
66
|
"ExplainStep",
|
|
51
67
|
"ExtractionResult",
|
|
@@ -80,4 +96,4 @@ __all__ = [
|
|
|
80
96
|
"detect_temporal_hints",
|
|
81
97
|
]
|
|
82
98
|
|
|
83
|
-
__version__ = "0.2.
|
|
99
|
+
__version__ = "0.2.2"
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Community lifecycle: detect, summarize, and query entity clusters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from pydantic_ai import Agent
|
|
11
|
+
|
|
12
|
+
from .prompts import COMMUNITY_SUMMARY_SYSTEM, COMMUNITY_SUMMARY_USER
|
|
13
|
+
from .schemas import CommunitySummaryOutput
|
|
14
|
+
from .search.vector import _get_props
|
|
15
|
+
from .types import (
|
|
16
|
+
COMMUNITY_LABEL,
|
|
17
|
+
ENTITY_LABEL,
|
|
18
|
+
HAS_MEMBER_EDGE,
|
|
19
|
+
RELATION_EDGE,
|
|
20
|
+
CommunityInfo,
|
|
21
|
+
ModelType,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from pydantic_ai.usage import RunUsage
|
|
26
|
+
|
|
27
|
+
from .protocol import GrafeoDBProtocol
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def materialize_communities_async(
|
|
33
|
+
db: GrafeoDBProtocol,
|
|
34
|
+
model: ModelType,
|
|
35
|
+
user_id: str,
|
|
36
|
+
louvain_result: dict,
|
|
37
|
+
*,
|
|
38
|
+
_on_usage: Callable[[str, RunUsage], None] | None = None,
|
|
39
|
+
) -> list[CommunityInfo]:
|
|
40
|
+
"""Create/update Community nodes from Louvain partition results.
|
|
41
|
+
|
|
42
|
+
Only communities with >= 2 members are materialized.
|
|
43
|
+
Summaries are regenerated only when membership changes.
|
|
44
|
+
"""
|
|
45
|
+
communities_map = louvain_result.get("communities", {})
|
|
46
|
+
if not communities_map:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
# Group entity node IDs by community_id
|
|
50
|
+
clusters: dict[int, list[int]] = {}
|
|
51
|
+
for node_id, community_id in communities_map.items():
|
|
52
|
+
clusters.setdefault(community_id, []).append(node_id)
|
|
53
|
+
|
|
54
|
+
# Only process entities belonging to this user
|
|
55
|
+
entity_nodes: dict[int, dict] = {}
|
|
56
|
+
try:
|
|
57
|
+
for node_id, props in db.get_nodes_by_label(ENTITY_LABEL):
|
|
58
|
+
if props.get("user_id") == user_id:
|
|
59
|
+
entity_nodes[node_id] = props
|
|
60
|
+
except Exception:
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
# Existing community nodes
|
|
64
|
+
existing_communities: dict[int, tuple[int, int]] = {} # community_id -> (node_id, member_count)
|
|
65
|
+
try:
|
|
66
|
+
for node_id, props in db.get_nodes_by_label(COMMUNITY_LABEL):
|
|
67
|
+
if props.get("user_id") == user_id:
|
|
68
|
+
cid = props.get("community_id")
|
|
69
|
+
if cid is not None:
|
|
70
|
+
existing_communities[int(cid)] = (node_id, int(props.get("member_count", 0)))
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
results: list[CommunityInfo] = []
|
|
75
|
+
active_cids: set[int] = set()
|
|
76
|
+
|
|
77
|
+
for cid, member_ids in clusters.items():
|
|
78
|
+
# Filter to only entity nodes for this user
|
|
79
|
+
user_members = [nid for nid in member_ids if nid in entity_nodes]
|
|
80
|
+
if len(user_members) < 2:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
active_cids.add(cid)
|
|
84
|
+
member_names = [entity_nodes[nid].get("name", "") for nid in user_members]
|
|
85
|
+
|
|
86
|
+
# Check if community already exists with same membership
|
|
87
|
+
if cid in existing_communities:
|
|
88
|
+
existing_nid, existing_count = existing_communities[cid]
|
|
89
|
+
if existing_count == len(user_members):
|
|
90
|
+
# Membership unchanged, read existing summary
|
|
91
|
+
node = db.get_node(existing_nid)
|
|
92
|
+
if node is not None:
|
|
93
|
+
props = _get_props(node)
|
|
94
|
+
results.append(
|
|
95
|
+
CommunityInfo(
|
|
96
|
+
community_id=str(cid),
|
|
97
|
+
name=props.get("name", ""),
|
|
98
|
+
summary=props.get("summary", ""),
|
|
99
|
+
member_count=existing_count,
|
|
100
|
+
member_entities=member_names,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
# Collect relations between members for context
|
|
106
|
+
relation_texts: list[str] = []
|
|
107
|
+
for nid in user_members:
|
|
108
|
+
try:
|
|
109
|
+
query = (
|
|
110
|
+
f"MATCH (a:{ENTITY_LABEL})-[r:{RELATION_EDGE}]->(b:{ENTITY_LABEL}) "
|
|
111
|
+
f"WHERE id(a) = $nid RETURN a.name, r.relation_type, b.name"
|
|
112
|
+
)
|
|
113
|
+
for row in db.execute(query, {"nid": nid}):
|
|
114
|
+
if isinstance(row, dict):
|
|
115
|
+
vals = list(row.values())
|
|
116
|
+
if len(vals) >= 3:
|
|
117
|
+
relation_texts.append(f"{vals[0]} -> {vals[1]} -> {vals[2]}")
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
# Generate summary via LLM
|
|
122
|
+
entities_text = "\n".join(f"- {n}" for n in member_names)
|
|
123
|
+
relations_text = "\n".join(f"- {r}" for r in relation_texts) if relation_texts else "(no relationships)"
|
|
124
|
+
|
|
125
|
+
agent = Agent(model, system_prompt=COMMUNITY_SUMMARY_SYSTEM, output_type=CommunitySummaryOutput)
|
|
126
|
+
try:
|
|
127
|
+
result = await agent.run(COMMUNITY_SUMMARY_USER.format(entities=entities_text, relations=relations_text))
|
|
128
|
+
except Exception:
|
|
129
|
+
logger.warning("Community summary generation failed for cid=%s", cid, exc_info=True)
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if _on_usage is not None:
|
|
133
|
+
_on_usage("community_summary", result.usage())
|
|
134
|
+
|
|
135
|
+
name = result.output.name
|
|
136
|
+
summary = result.output.summary
|
|
137
|
+
|
|
138
|
+
import time
|
|
139
|
+
|
|
140
|
+
now_ms = int(time.time() * 1000)
|
|
141
|
+
|
|
142
|
+
# Create or update community node
|
|
143
|
+
if cid in existing_communities:
|
|
144
|
+
existing_nid, _ = existing_communities[cid]
|
|
145
|
+
db.set_node_property(existing_nid, "name", name)
|
|
146
|
+
db.set_node_property(existing_nid, "summary", summary)
|
|
147
|
+
db.set_node_property(existing_nid, "member_count", len(user_members))
|
|
148
|
+
db.set_node_property(existing_nid, "updated_at", now_ms)
|
|
149
|
+
community_nid = existing_nid
|
|
150
|
+
# Delete old HAS_MEMBER edges
|
|
151
|
+
try:
|
|
152
|
+
del_query = (
|
|
153
|
+
f"MATCH (c:{COMMUNITY_LABEL})-[r:{HAS_MEMBER_EDGE}]->(:{ENTITY_LABEL}) "
|
|
154
|
+
f"WHERE id(c) = $cid RETURN id(r)"
|
|
155
|
+
)
|
|
156
|
+
for row in db.execute(del_query, {"cid": existing_nid}):
|
|
157
|
+
if isinstance(row, dict):
|
|
158
|
+
vals = list(row.values())
|
|
159
|
+
if vals:
|
|
160
|
+
with contextlib.suppress(Exception):
|
|
161
|
+
db.delete_edge(int(vals[0]))
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
else:
|
|
165
|
+
node = db.create_node(
|
|
166
|
+
[COMMUNITY_LABEL],
|
|
167
|
+
{
|
|
168
|
+
"name": name,
|
|
169
|
+
"summary": summary,
|
|
170
|
+
"community_id": cid,
|
|
171
|
+
"member_count": len(user_members),
|
|
172
|
+
"user_id": user_id,
|
|
173
|
+
"created_at": now_ms,
|
|
174
|
+
"updated_at": now_ms,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
community_nid = node.id if hasattr(node, "id") else node
|
|
178
|
+
|
|
179
|
+
# Create HAS_MEMBER edges
|
|
180
|
+
for nid in user_members:
|
|
181
|
+
with contextlib.suppress(Exception):
|
|
182
|
+
db.create_edge(community_nid, nid, HAS_MEMBER_EDGE)
|
|
183
|
+
|
|
184
|
+
results.append(
|
|
185
|
+
CommunityInfo(
|
|
186
|
+
community_id=str(cid),
|
|
187
|
+
name=name,
|
|
188
|
+
summary=summary,
|
|
189
|
+
member_count=len(user_members),
|
|
190
|
+
member_entities=member_names,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Delete dissolved communities (existing but no longer active)
|
|
195
|
+
for cid, (existing_nid, _) in existing_communities.items():
|
|
196
|
+
if cid not in active_cids:
|
|
197
|
+
with contextlib.suppress(Exception):
|
|
198
|
+
db.delete_node(existing_nid)
|
|
199
|
+
|
|
200
|
+
return results
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_communities(db: GrafeoDBProtocol, user_id: str) -> list[CommunityInfo]:
|
|
204
|
+
"""Retrieve all Community nodes for a user."""
|
|
205
|
+
try:
|
|
206
|
+
nodes = db.get_nodes_by_label(COMMUNITY_LABEL)
|
|
207
|
+
except Exception:
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
results: list[CommunityInfo] = []
|
|
211
|
+
for node_id, props in nodes:
|
|
212
|
+
if props.get("user_id") != user_id:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
# Collect member entity names
|
|
216
|
+
member_names: list[str] = []
|
|
217
|
+
try:
|
|
218
|
+
query = (
|
|
219
|
+
f"MATCH (c:{COMMUNITY_LABEL})-[:{HAS_MEMBER_EDGE}]->(e:{ENTITY_LABEL}) WHERE id(c) = $cid RETURN e.name"
|
|
220
|
+
)
|
|
221
|
+
for row in db.execute(query, {"cid": node_id}):
|
|
222
|
+
if isinstance(row, dict):
|
|
223
|
+
vals = list(row.values())
|
|
224
|
+
if vals and vals[0]:
|
|
225
|
+
member_names.append(str(vals[0]))
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
results.append(
|
|
230
|
+
CommunityInfo(
|
|
231
|
+
community_id=str(props.get("community_id", node_id)),
|
|
232
|
+
name=props.get("name", ""),
|
|
233
|
+
summary=props.get("summary", ""),
|
|
234
|
+
member_count=int(props.get("member_count", 0)),
|
|
235
|
+
member_entities=member_names,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return results
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def get_community_context(db: GrafeoDBProtocol, entity_names: list[str], user_id: str) -> list[CommunityInfo]:
|
|
242
|
+
"""Find communities that contain any of the given entities."""
|
|
243
|
+
if not entity_names:
|
|
244
|
+
return []
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
nodes = db.get_nodes_by_label(COMMUNITY_LABEL)
|
|
248
|
+
except Exception:
|
|
249
|
+
return []
|
|
250
|
+
|
|
251
|
+
name_set = {n.lower() for n in entity_names}
|
|
252
|
+
results: list[CommunityInfo] = []
|
|
253
|
+
|
|
254
|
+
for node_id, props in nodes:
|
|
255
|
+
if props.get("user_id") != user_id:
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
# Check if any member entity matches
|
|
259
|
+
member_names: list[str] = []
|
|
260
|
+
try:
|
|
261
|
+
query = (
|
|
262
|
+
f"MATCH (c:{COMMUNITY_LABEL})-[:{HAS_MEMBER_EDGE}]->(e:{ENTITY_LABEL}) WHERE id(c) = $cid RETURN e.name"
|
|
263
|
+
)
|
|
264
|
+
for row in db.execute(query, {"cid": node_id}):
|
|
265
|
+
if isinstance(row, dict):
|
|
266
|
+
vals = list(row.values())
|
|
267
|
+
if vals and vals[0]:
|
|
268
|
+
member_names.append(str(vals[0]))
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
if any(m.lower() in name_set for m in member_names):
|
|
273
|
+
results.append(
|
|
274
|
+
CommunityInfo(
|
|
275
|
+
community_id=str(props.get("community_id", node_id)),
|
|
276
|
+
name=props.get("name", ""),
|
|
277
|
+
summary=props.get("summary", ""),
|
|
278
|
+
member_count=int(props.get("member_count", 0)),
|
|
279
|
+
member_entities=member_names,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return results
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from .entities import extract, extract_async, extract_entities, extract_entities_async
|
|
4
4
|
from .facts import extract_facts, extract_facts_async
|
|
5
|
+
from .temporal import annotate_temporal, annotate_temporal_async
|
|
5
6
|
|
|
6
7
|
__all__ = [
|
|
8
|
+
"annotate_temporal",
|
|
9
|
+
"annotate_temporal_async",
|
|
7
10
|
"extract",
|
|
8
11
|
"extract_async",
|
|
9
12
|
"extract_entities",
|