grafeo-memory 0.2.1__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.
Files changed (90) hide show
  1. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/CHANGELOG.md +44 -0
  2. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/PKG-INFO +1 -1
  3. grafeo_memory-0.2.2/examples/temporal_bench.py +129 -0
  4. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/pyproject.toml +1 -1
  5. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/__init__.py +17 -1
  6. grafeo_memory-0.2.2/src/grafeo_memory/communities.py +283 -0
  7. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/__init__.py +3 -0
  8. grafeo_memory-0.2.2/src/grafeo_memory/extraction/temporal.py +113 -0
  9. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/manager.py +464 -29
  10. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/tools.py +5 -1
  11. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/prompts.py +52 -0
  12. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/schemas.py +27 -0
  13. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/search/graph.py +21 -8
  14. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/search/vector.py +6 -0
  15. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/temporal.py +14 -0
  16. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/types.py +56 -0
  17. grafeo_memory-0.2.2/tests/test_bitemporal.py +380 -0
  18. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_cli.py +3 -1
  19. grafeo_memory-0.2.2/tests/test_communities.py +405 -0
  20. grafeo_memory-0.2.2/tests/test_episodes.py +356 -0
  21. grafeo_memory-0.2.2/tests/test_graph_name.py +338 -0
  22. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_manager.py +224 -0
  23. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_multi_tenant.py +8 -3
  24. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_stats.py +36 -0
  25. grafeo_memory-0.2.2/tests/test_temporal_integration.py +123 -0
  26. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/uv.lock +1 -1
  27. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/.env.example +0 -0
  28. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/.github/workflows/ci.yml +0 -0
  29. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/.github/workflows/pypi.yml +0 -0
  30. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/.gitignore +0 -0
  31. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/CONTRIBUTING.md +0 -0
  32. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/LICENCE +0 -0
  33. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/README.md +0 -0
  34. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/coverage.xml +0 -0
  35. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/async_memory.py +0 -0
  36. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/importance_scoring.py +0 -0
  37. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/mistral_quickstart.py +0 -0
  38. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/multimodal_vision.py +0 -0
  39. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/procedural_memory.py +0 -0
  40. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/quickstart.py +0 -0
  41. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/topology_boost.py +0 -0
  42. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/usage_tracking.py +0 -0
  43. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/__main__.py +0 -0
  44. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/_compat.py +0 -0
  45. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/_tracing.py +0 -0
  46. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/cli.py +0 -0
  47. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/embedding.py +0 -0
  48. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/entities.py +0 -0
  49. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/facts.py +0 -0
  50. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/history.py +0 -0
  51. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/__init__.py +0 -0
  52. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/prompts.py +0 -0
  53. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/resources.py +0 -0
  54. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/server.py +0 -0
  55. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/messages.py +0 -0
  56. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/protocol.py +0 -0
  57. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/__init__.py +0 -0
  58. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/memories.py +0 -0
  59. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/relations.py +0 -0
  60. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/reranker.py +0 -0
  61. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/scoring.py +0 -0
  62. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/search/__init__.py +0 -0
  63. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/vision.py +0 -0
  64. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/__init__.py +0 -0
  65. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/conftest.py +0 -0
  66. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/mock_llm.py +0 -0
  67. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_concurrency.py +0 -0
  68. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_config_validation.py +0 -0
  69. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_coverage_gaps.py +0 -0
  70. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_db_injection.py +0 -0
  71. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_error_surfacing.py +0 -0
  72. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_explain.py +0 -0
  73. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_extractor.py +0 -0
  74. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_history.py +0 -0
  75. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_instrumentation.py +0 -0
  76. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_mcp_tools.py +0 -0
  77. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_messages.py +0 -0
  78. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_multi_hop.py +0 -0
  79. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_multi_session.py +0 -0
  80. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_new_features.py +0 -0
  81. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_persistence_lifecycle.py +0 -0
  82. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_reconciler.py +0 -0
  83. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_reranker.py +0 -0
  84. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_scoring.py +0 -0
  85. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_search_quality.py +0 -0
  86. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_temporal.py +0 -0
  87. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_topology_scoring.py +0 -0
  88. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_types.py +0 -0
  89. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_usage.py +0 -0
  90. {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_vision.py +0 -0
@@ -5,6 +5,48 @@ 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
+
8
50
  ## [0.2.1] - 2026-04-07
9
51
 
10
52
  Test quality, error surfacing, documentation improvements, and shared database support.
@@ -233,6 +275,8 @@ Initial release.
233
275
  - **Windows async compatibility**: persistent `asyncio.Runner` and `ProactorEventLoop` safety net for Python 3.13+
234
276
  - 230 tests, 83% coverage
235
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
236
280
  [0.2.0]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.1.5...v0.2.0
237
281
  [0.1.5]: https://github.com/GrafeoDB/grafeo-memory/compare/v0.1.4...v0.1.5
238
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.1
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
@@ -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()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "grafeo-memory"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "AI memory layer powered by GrafeoDB's hybrid graph and vector database"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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.0"
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",
@@ -0,0 +1,113 @@
1
+ """Temporal annotation extraction: identify when facts became true/ceased being true."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from collections.abc import Callable
8
+ from datetime import UTC, date, datetime
9
+ from typing import TYPE_CHECKING
10
+
11
+ from pydantic_ai import Agent
12
+
13
+ from .._compat import run_sync
14
+ from ..prompts import TEMPORAL_ANNOTATION_SYSTEM, TEMPORAL_ANNOTATION_USER
15
+ from ..schemas import TemporalAnnotationOutput
16
+ from ..types import Fact, ModelType
17
+
18
+ if TYPE_CHECKING:
19
+ from pydantic_ai.usage import RunUsage
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _YEAR_ONLY_RE = re.compile(r"^\d{4}$")
24
+
25
+
26
+ def _parse_date_to_epoch_ms(date_str: str | None) -> int | None:
27
+ """Parse an ISO-8601 date string (or year-only) to epoch milliseconds.
28
+
29
+ Returns None for unparseable or null input.
30
+ """
31
+ if not date_str:
32
+ return None
33
+
34
+ date_str = date_str.strip()
35
+
36
+ # Year-only: "2024" -> Jan 1 of that year
37
+ if _YEAR_ONLY_RE.match(date_str):
38
+ try:
39
+ dt = datetime(int(date_str), 1, 1, tzinfo=UTC)
40
+ return int(dt.timestamp() * 1000)
41
+ except (ValueError, OverflowError):
42
+ return None
43
+
44
+ # ISO-8601 date: "2024-01-15"
45
+ try:
46
+ d = date.fromisoformat(date_str)
47
+ dt = datetime(d.year, d.month, d.day, tzinfo=UTC)
48
+ return int(dt.timestamp() * 1000)
49
+ except ValueError:
50
+ pass
51
+
52
+ # ISO-8601 datetime: "2024-01-15T10:30:00"
53
+ try:
54
+ dt = datetime.fromisoformat(date_str)
55
+ if dt.tzinfo is None:
56
+ dt = dt.replace(tzinfo=UTC)
57
+ return int(dt.timestamp() * 1000)
58
+ except ValueError:
59
+ pass
60
+
61
+ logger.debug("Could not parse date string: %r", date_str)
62
+ return None
63
+
64
+
65
+ async def annotate_temporal_async(
66
+ model: ModelType,
67
+ facts: list[Fact],
68
+ original_text: str,
69
+ *,
70
+ today: str | None = None,
71
+ _on_usage: Callable[[str, RunUsage], None] | None = None,
72
+ ) -> dict[int, tuple[int | None, int | None]]:
73
+ """Annotate extracted facts with real-world validity timestamps.
74
+
75
+ Returns a mapping of fact_index -> (valid_at_ms, invalid_at_ms).
76
+ Only facts with detected temporal information are included.
77
+ """
78
+ if not facts:
79
+ return {}
80
+
81
+ today_str = today or date.today().isoformat()
82
+ facts_text = "\n".join(f"{i}. {f.text}" for i, f in enumerate(facts))
83
+
84
+ agent = Agent(model, system_prompt=TEMPORAL_ANNOTATION_SYSTEM, output_type=TemporalAnnotationOutput)
85
+ try:
86
+ result = await agent.run(TEMPORAL_ANNOTATION_USER.format(today=today_str, text=original_text, facts=facts_text))
87
+ except Exception:
88
+ logger.warning("Temporal annotation failed", exc_info=True)
89
+ return {}
90
+
91
+ if _on_usage is not None:
92
+ _on_usage("annotate_temporal", result.usage())
93
+
94
+ annotations: dict[int, tuple[int | None, int | None]] = {}
95
+ for ann in result.output.annotations:
96
+ valid_ms = _parse_date_to_epoch_ms(ann.valid_at)
97
+ invalid_ms = _parse_date_to_epoch_ms(ann.invalid_at)
98
+ if valid_ms is not None or invalid_ms is not None:
99
+ annotations[ann.fact_index] = (valid_ms, invalid_ms)
100
+
101
+ return annotations
102
+
103
+
104
+ def annotate_temporal(
105
+ model: ModelType,
106
+ facts: list[Fact],
107
+ original_text: str,
108
+ *,
109
+ today: str | None = None,
110
+ _on_usage: Callable[[str, RunUsage], None] | None = None,
111
+ ) -> dict[int, tuple[int | None, int | None]]:
112
+ """Annotate extracted facts with real-world validity timestamps (sync)."""
113
+ return run_sync(annotate_temporal_async(model, facts, original_text, today=today, _on_usage=_on_usage))