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.
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/CHANGELOG.md +44 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/PKG-INFO +1 -1
- grafeo_memory-0.2.2/examples/temporal_bench.py +129 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/pyproject.toml +1 -1
- {grafeo_memory-0.2.1 → 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.1 → 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.1 → grafeo_memory-0.2.2}/src/grafeo_memory/manager.py +464 -29
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/tools.py +5 -1
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/prompts.py +52 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/schemas.py +27 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/search/graph.py +21 -8
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/search/vector.py +6 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/temporal.py +14 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/types.py +56 -0
- grafeo_memory-0.2.2/tests/test_bitemporal.py +380 -0
- {grafeo_memory-0.2.1 → 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_episodes.py +356 -0
- grafeo_memory-0.2.2/tests/test_graph_name.py +338 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_manager.py +224 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_multi_tenant.py +8 -3
- {grafeo_memory-0.2.1 → 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.1 → grafeo_memory-0.2.2}/uv.lock +1 -1
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/.env.example +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/.github/workflows/ci.yml +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/.github/workflows/pypi.yml +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/.gitignore +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/CONTRIBUTING.md +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/LICENCE +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/README.md +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/coverage.xml +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/async_memory.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/importance_scoring.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/mistral_quickstart.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/multimodal_vision.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/procedural_memory.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/quickstart.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/topology_boost.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/examples/usage_tracking.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/__main__.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/_compat.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/_tracing.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/cli.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/embedding.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/entities.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/extraction/facts.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/history.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/__init__.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/prompts.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/resources.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/mcp/server.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/messages.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/protocol.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/__init__.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/memories.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/reconciliation/relations.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/reranker.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/scoring.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/search/__init__.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/src/grafeo_memory/vision.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/__init__.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/conftest.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/mock_llm.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_concurrency.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_config_validation.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_coverage_gaps.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_db_injection.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_error_surfacing.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_explain.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_extractor.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_history.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_instrumentation.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_mcp_tools.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_messages.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_multi_hop.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_multi_session.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_new_features.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_persistence_lifecycle.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_reconciler.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_reranker.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_scoring.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_search_quality.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_temporal.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_topology_scoring.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_types.py +0 -0
- {grafeo_memory-0.2.1 → grafeo_memory-0.2.2}/tests/test_usage.py +0 -0
- {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.
|
|
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()
|
|
@@ -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",
|
|
@@ -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))
|