recallweaver 1.2.0__py3-none-any.whl
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.
- plugins/dsh_adapter.py +87 -0
- recallweaver/__init__.py +25 -0
- recallweaver/api.py +513 -0
- recallweaver/cli.py +220 -0
- recallweaver/config.py +25 -0
- recallweaver/db.py +240 -0
- recallweaver/decay_engine.py +98 -0
- recallweaver/errors.py +37 -0
- recallweaver/extraction/__init__.py +6 -0
- recallweaver/extraction/base.py +20 -0
- recallweaver/extraction/llm_extractor.py +201 -0
- recallweaver/graph.py +201 -0
- recallweaver/memory_manager.py +461 -0
- recallweaver/migrations/0001_init.sql +150 -0
- recallweaver/migrations/__init__.py +0 -0
- recallweaver/migrations/runner.py +76 -0
- recallweaver/models.py +110 -0
- recallweaver/observability/README.md +7 -0
- recallweaver/observability/__init__.py +19 -0
- recallweaver/observability/logging.py +235 -0
- recallweaver/observability/metrics.py +90 -0
- recallweaver/plugins/README.md +1 -0
- recallweaver/plugins/__init__.py +0 -0
- recallweaver/plugins/base.py +0 -0
- recallweaver/plugins/registry.py +0 -0
- recallweaver/retrieval.py +165 -0
- recallweaver/server.py +533 -0
- recallweaver-1.2.0.dist-info/METADATA +205 -0
- recallweaver-1.2.0.dist-info/RECORD +31 -0
- recallweaver-1.2.0.dist-info/WHEEL +4 -0
- recallweaver-1.2.0.dist-info/entry_points.txt +2 -0
plugins/dsh_adapter.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""RecallWeaver 的 DSH(DeepSeek Harness)桌宠适配器。
|
|
2
|
+
|
|
3
|
+
给刚接触 Python 的 DSH 用户:
|
|
4
|
+
1. 先在运行 DSH 的同一个 Python 环境中安装 RecallWeaver:
|
|
5
|
+
``python -m pip install recallweaver``。如果使用尚未发布的源码版本,
|
|
6
|
+
请在 RecallWeaver 项目根目录运行 ``python -m pip install -e .``。
|
|
7
|
+
2. 将本文件复制到 DSH 文档指定的插件目录。若 DSH 要求插件有特定入口
|
|
8
|
+
名称,请在其插件入口中导入 ``DSHMemoryAdapter``,并保留下面的调用:
|
|
9
|
+
``adapter.process_message(user_id, message)``。
|
|
10
|
+
3. DSH 插件调用该方法后,会先将本轮消息写入对应用户的记忆空间,再检索
|
|
11
|
+
相关事实;返回的字符串可直接作为本轮对话的 System Prompt。
|
|
12
|
+
4. 默认数据库路径取 RecallWeaver 的 ``RECALLWEAVER_DB_PATH`` 配置;
|
|
13
|
+
未配置时使用当前工作目录下的 ``recallweaver.db``。如需指定其他路径,
|
|
14
|
+
可在创建适配器时传入 ``db_path``。
|
|
15
|
+
|
|
16
|
+
本文件是一个轻量的 Python 桥接示例,并不依赖或假设 DSH 的特定插件 SDK。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from recallweaver.api import RecallWeaverAPI
|
|
22
|
+
from recallweaver.config import settings
|
|
23
|
+
from recallweaver.models import MemoryOut
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DSHMemoryAdapter:
|
|
27
|
+
"""将 RecallWeaver 的记忆读写能力桥接到 DSH 的 System Prompt。"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
db_path: str | None = None,
|
|
32
|
+
api: RecallWeaverAPI | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""创建适配器;通常无需参数,测试或自定义部署可注入数据库/API。"""
|
|
35
|
+
configured_db_path = (
|
|
36
|
+
db_path
|
|
37
|
+
or os.environ.get("RECALLWEAVER_DB_PATH")
|
|
38
|
+
or settings.db_path
|
|
39
|
+
)
|
|
40
|
+
self.api = api or RecallWeaverAPI(configured_db_path)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _fact_text(memory: MemoryOut) -> str:
|
|
44
|
+
"""把文本记忆或图谱事实转换成可放入提示词的一行文字。"""
|
|
45
|
+
if memory.content:
|
|
46
|
+
return memory.content
|
|
47
|
+
|
|
48
|
+
# 图谱结果没有 content,使用关系的主语、关系和宾语组成自然语言片段。
|
|
49
|
+
parts = (
|
|
50
|
+
memory.subject or memory.start_name,
|
|
51
|
+
memory.predicate or memory.relation,
|
|
52
|
+
memory.object or memory.entity_name,
|
|
53
|
+
)
|
|
54
|
+
return " ".join(part for part in parts if part)
|
|
55
|
+
|
|
56
|
+
def process_message(self, user_id: str, message: str) -> str:
|
|
57
|
+
"""写入并召回该用户的记忆,返回 DSH 可用的 System Prompt。
|
|
58
|
+
|
|
59
|
+
DSH 侧只需提供用户标识和本轮消息;``user_id`` 同时作为 RecallWeaver
|
|
60
|
+
的租户标识,确保不同桌宠用户之间的记忆互相隔离。
|
|
61
|
+
"""
|
|
62
|
+
self.api.add_memory(message, tenant_id=user_id)
|
|
63
|
+
recalled = self.api.recall(message, limit=5, tenant_id=user_id)
|
|
64
|
+
|
|
65
|
+
# 保持事实顺序并去重,避免相同内容重复占用模型上下文。
|
|
66
|
+
facts = list(
|
|
67
|
+
dict.fromkeys(
|
|
68
|
+
fact
|
|
69
|
+
for memory in recalled
|
|
70
|
+
if (fact := self._fact_text(memory))
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
memory_context = (
|
|
74
|
+
"\n".join(f"- {fact}" for fact in facts)
|
|
75
|
+
if facts
|
|
76
|
+
else "(暂无相关记忆)"
|
|
77
|
+
)
|
|
78
|
+
return (
|
|
79
|
+
"你是一个拥有长期记忆的私人助手。\n"
|
|
80
|
+
"以下是你记得关于用户的事实:\n"
|
|
81
|
+
f"{memory_context}\n"
|
|
82
|
+
"请结合上述记忆回答用户;若无相关记忆,请直接回答。"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def on_message(self, user_id: str, message: str) -> str:
|
|
86
|
+
"""提供 DSH 常见事件入口名称,返回与 process_message 相同的提示词。"""
|
|
87
|
+
return self.process_message(user_id, message)
|
recallweaver/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""RecallWeaver 的公共包接口。
|
|
2
|
+
|
|
3
|
+
通过在包初始化时重新导出常用对象,外部代码可以直接从
|
|
4
|
+
``recallweaver`` 导入配置、异常类型和数据库管理器,而不必记住内部模块路径。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# 暴露全局配置实例,使调用方可以使用 ``recallweaver.settings`` 读取项目配置。
|
|
8
|
+
from .config import settings
|
|
9
|
+
# 暴露项目异常体系的基类,便于调用方统一捕获 RecallWeaver 相关错误。
|
|
10
|
+
from .errors import RecallWeaverError
|
|
11
|
+
from .errors import DatabaseError, ValidationError
|
|
12
|
+
# 暴露数据库管理类和默认管理器,分别用于创建自定义实例或直接操作默认数据库。
|
|
13
|
+
from .db import Database, db_manager
|
|
14
|
+
from .api import RecallWeaverAPI
|
|
15
|
+
|
|
16
|
+
# 显式声明包的公共接口,避免内部实现细节被误认为稳定的公开 API。
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Database",
|
|
19
|
+
"DatabaseError",
|
|
20
|
+
"RecallWeaverAPI",
|
|
21
|
+
"RecallWeaverError",
|
|
22
|
+
"ValidationError",
|
|
23
|
+
"db_manager",
|
|
24
|
+
"settings",
|
|
25
|
+
]
|
recallweaver/api.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""依赖注入式 RecallWeaver API 与 Pydantic 契约模型。"""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, NoReturn, TypeVar
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ValidationError as PydanticValidationError
|
|
7
|
+
|
|
8
|
+
from recallweaver.config import settings
|
|
9
|
+
from recallweaver.db import Database
|
|
10
|
+
from recallweaver.decay_engine import DecayEngine
|
|
11
|
+
from recallweaver.errors import DatabaseError, ValidationError
|
|
12
|
+
from recallweaver.graph import GraphManager
|
|
13
|
+
from recallweaver.memory_manager import MemoryManager
|
|
14
|
+
from recallweaver.models import (
|
|
15
|
+
DatabasePathIn,
|
|
16
|
+
HistoryIn,
|
|
17
|
+
HistoryOut,
|
|
18
|
+
MemoryIdOut,
|
|
19
|
+
MemoryOut,
|
|
20
|
+
RecallIn,
|
|
21
|
+
SuccessOut,
|
|
22
|
+
TextMemoryIn,
|
|
23
|
+
TripleIn,
|
|
24
|
+
)
|
|
25
|
+
from recallweaver.observability.logging import get_logger, trace_context
|
|
26
|
+
from recallweaver.observability.metrics import MetricsCollector
|
|
27
|
+
from recallweaver.retrieval import Retriever
|
|
28
|
+
|
|
29
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_input(model_type: type[ModelT], **values: Any) -> ModelT:
|
|
33
|
+
"""Validate an input model and translate Pydantic errors to API errors.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
model_type: Pydantic model class used to validate the input.
|
|
37
|
+
**values: Input fields passed to the model.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A validated instance of ``model_type``.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValidationError: If any input value violates the API contract.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
return model_type.model_validate(values)
|
|
47
|
+
except PydanticValidationError as exc:
|
|
48
|
+
raise ValidationError(str(exc)) from exc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _raise_database_error(operation: str, exc: Exception) -> NoReturn:
|
|
52
|
+
"""Translate lower-level storage exceptions at the public API boundary.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
operation: Human-readable operation name for the error message.
|
|
56
|
+
exc: The exception raised by an injected component.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
DatabaseError: Always, with the original exception chained.
|
|
60
|
+
"""
|
|
61
|
+
if isinstance(exc, DatabaseError):
|
|
62
|
+
raise exc
|
|
63
|
+
raise DatabaseError(f"{operation} failed: {exc}") from exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RecallWeaverAPI:
|
|
67
|
+
"""Expose validated memory, graph, hybrid-recall, and history operations.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
db_path: Filesystem path of the SQLite database to use.
|
|
71
|
+
|
|
72
|
+
All engines share the injected database manager so a single API instance
|
|
73
|
+
never reads from or writes to the process-global database by accident.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, db_path: str) -> None:
|
|
77
|
+
"""Create and initialize all engines for one SQLite database.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
db_path: Path to the database file.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
DatabaseError: If the database cannot be initialized.
|
|
84
|
+
ValidationError: If ``db_path`` is empty.
|
|
85
|
+
"""
|
|
86
|
+
database_path = _validate_input(DatabasePathIn, db_path=db_path)
|
|
87
|
+
self.database_manager = Database(database_path.db_path)
|
|
88
|
+
self.decay_engine = DecayEngine()
|
|
89
|
+
self.memory_manager = MemoryManager(database=self.database_manager)
|
|
90
|
+
self.graph_manager = GraphManager(database=self.database_manager)
|
|
91
|
+
self.retriever = Retriever(
|
|
92
|
+
database=self.database_manager,
|
|
93
|
+
decay_engine=self.decay_engine,
|
|
94
|
+
)
|
|
95
|
+
self.metrics = MetricsCollector()
|
|
96
|
+
self.logger = get_logger(__name__)
|
|
97
|
+
try:
|
|
98
|
+
self.database_manager.init_db(verbose=False)
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
_raise_database_error("database initialization", exc)
|
|
101
|
+
|
|
102
|
+
def remember_triple(
|
|
103
|
+
self,
|
|
104
|
+
subject: str,
|
|
105
|
+
predicate: str,
|
|
106
|
+
object_: str,
|
|
107
|
+
tenant_id: str = "default",
|
|
108
|
+
) -> bool:
|
|
109
|
+
"""Insert a fact and record request count plus safe error events."""
|
|
110
|
+
started_at = time.perf_counter()
|
|
111
|
+
self.logger.info(
|
|
112
|
+
"收到写入请求: %s-%s-%s",
|
|
113
|
+
subject,
|
|
114
|
+
predicate,
|
|
115
|
+
object_,
|
|
116
|
+
extra={
|
|
117
|
+
"action": "remember_triple",
|
|
118
|
+
"details": {
|
|
119
|
+
"event": "收到写入请求",
|
|
120
|
+
"subject": subject,
|
|
121
|
+
"predicate": predicate,
|
|
122
|
+
"object": object_,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
return self._remember_triple(
|
|
128
|
+
subject,
|
|
129
|
+
predicate,
|
|
130
|
+
object_,
|
|
131
|
+
tenant_id=tenant_id,
|
|
132
|
+
)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
self.logger.error(
|
|
135
|
+
"remember_triple failed: %s",
|
|
136
|
+
type(exc).__name__,
|
|
137
|
+
exc_info=True,
|
|
138
|
+
)
|
|
139
|
+
raise
|
|
140
|
+
finally:
|
|
141
|
+
duration_ms = (time.perf_counter() - started_at) * 1_000
|
|
142
|
+
self.metrics.record_remember(duration_ms)
|
|
143
|
+
|
|
144
|
+
def _remember_triple(
|
|
145
|
+
self,
|
|
146
|
+
subject: str,
|
|
147
|
+
predicate: str,
|
|
148
|
+
object_: str,
|
|
149
|
+
tenant_id: str = "default",
|
|
150
|
+
) -> bool:
|
|
151
|
+
"""Insert or resolve a time-versioned graph fact.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
subject: Entity that owns the fact.
|
|
155
|
+
predicate: Relationship name.
|
|
156
|
+
object_: Related entity or value.
|
|
157
|
+
tenant_id: Tenant namespace for all created records.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
``True`` when the fact is stored or is already current.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
ValidationError: If a fact field or tenant ID is empty or invalid.
|
|
164
|
+
DatabaseError: If persistence or conflict resolution fails.
|
|
165
|
+
"""
|
|
166
|
+
request = _validate_input(
|
|
167
|
+
TripleIn,
|
|
168
|
+
subject=subject,
|
|
169
|
+
predicate=predicate,
|
|
170
|
+
object_=object_,
|
|
171
|
+
tenant_id=tenant_id,
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
result = self.memory_manager.remember_with_conflict_resolution(
|
|
175
|
+
subject=request.subject,
|
|
176
|
+
predicate=request.predicate,
|
|
177
|
+
object_=request.object_,
|
|
178
|
+
tenant_id=request.tenant_id,
|
|
179
|
+
)
|
|
180
|
+
return SuccessOut(success=result).success
|
|
181
|
+
except PydanticValidationError as exc:
|
|
182
|
+
raise ValidationError(str(exc)) from exc
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
_raise_database_error("remember triple", exc)
|
|
185
|
+
|
|
186
|
+
def add_memory(
|
|
187
|
+
self,
|
|
188
|
+
content: str,
|
|
189
|
+
tenant_id: str = "default",
|
|
190
|
+
) -> str:
|
|
191
|
+
"""Persist validated plain text without creating graph edges.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
content: Text content to store.
|
|
195
|
+
tenant_id: Tenant namespace for the memory.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The unique ID assigned to the new memory.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
ValidationError: If content or tenant ID is empty or invalid.
|
|
202
|
+
DatabaseError: If the memory cannot be persisted.
|
|
203
|
+
"""
|
|
204
|
+
request = _validate_input(
|
|
205
|
+
TextMemoryIn,
|
|
206
|
+
content=content,
|
|
207
|
+
tenant_id=tenant_id,
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
memory_id = self.memory_manager.remember(
|
|
211
|
+
request.content,
|
|
212
|
+
tenant_id=request.tenant_id,
|
|
213
|
+
)
|
|
214
|
+
return MemoryIdOut(memory_id=memory_id).memory_id
|
|
215
|
+
except PydanticValidationError as exc:
|
|
216
|
+
raise ValidationError(str(exc)) from exc
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
_raise_database_error("add memory", exc)
|
|
219
|
+
|
|
220
|
+
def recall(
|
|
221
|
+
self,
|
|
222
|
+
query: str,
|
|
223
|
+
limit: int = 5,
|
|
224
|
+
tenant_id: str = "default",
|
|
225
|
+
) -> list[MemoryOut]:
|
|
226
|
+
"""Recall memories while recording duration and safe error events."""
|
|
227
|
+
with trace_context() as trace_id:
|
|
228
|
+
started_at = time.perf_counter()
|
|
229
|
+
self.logger.info(
|
|
230
|
+
'🚀 收到检索 | query="%s"',
|
|
231
|
+
query,
|
|
232
|
+
extra={
|
|
233
|
+
"action": "recall_start",
|
|
234
|
+
"details": {"query": query},
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
try:
|
|
238
|
+
results = self._recall(
|
|
239
|
+
query,
|
|
240
|
+
limit=limit,
|
|
241
|
+
tenant_id=tenant_id,
|
|
242
|
+
trace_id=trace_id,
|
|
243
|
+
)
|
|
244
|
+
duration_ms = (time.perf_counter() - started_at) * 1_000
|
|
245
|
+
self.logger.info(
|
|
246
|
+
"检索完成,耗时 %.3f ms,召回 %d 条记录",
|
|
247
|
+
duration_ms,
|
|
248
|
+
len(results),
|
|
249
|
+
extra={
|
|
250
|
+
"action": "recall_complete",
|
|
251
|
+
"details": {
|
|
252
|
+
"duration_ms": round(duration_ms, 3),
|
|
253
|
+
"result_count": len(results),
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
return results
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
self.logger.error(
|
|
260
|
+
"recall failed: %s",
|
|
261
|
+
type(exc).__name__,
|
|
262
|
+
exc_info=True,
|
|
263
|
+
extra={
|
|
264
|
+
"action": "recall_error",
|
|
265
|
+
"details": {"error_type": type(exc).__name__},
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
raise
|
|
269
|
+
finally:
|
|
270
|
+
duration_ms = (time.perf_counter() - started_at) * 1_000
|
|
271
|
+
self.metrics.record_recall(duration_ms)
|
|
272
|
+
|
|
273
|
+
def _recall(
|
|
274
|
+
self,
|
|
275
|
+
query: str,
|
|
276
|
+
limit: int = 5,
|
|
277
|
+
tenant_id: str = "default",
|
|
278
|
+
trace_id: str | None = None,
|
|
279
|
+
) -> list[MemoryOut]:
|
|
280
|
+
"""Merge text and graph recall, validate, deduplicate, and rank results.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
query: Text query or exact graph entity name.
|
|
284
|
+
limit: Maximum number of returned results; must be non-negative.
|
|
285
|
+
tenant_id: Tenant namespace to search.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Results as validated ``MemoryOut`` Pydantic models, sorted by
|
|
289
|
+
current decay score in descending order.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
ValidationError: If query, limit, or tenant ID is invalid.
|
|
293
|
+
DatabaseError: If any retrieval or scoring operation fails.
|
|
294
|
+
"""
|
|
295
|
+
request = _validate_input(
|
|
296
|
+
RecallIn,
|
|
297
|
+
query=query,
|
|
298
|
+
limit=limit,
|
|
299
|
+
tenant_id=tenant_id,
|
|
300
|
+
)
|
|
301
|
+
if request.limit == 0:
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
text_rows = self.retriever.search(
|
|
306
|
+
request.query,
|
|
307
|
+
request.limit,
|
|
308
|
+
tenant_id=request.tenant_id,
|
|
309
|
+
)
|
|
310
|
+
graph_rows = self.graph_manager.query_multi_hop(
|
|
311
|
+
request.query,
|
|
312
|
+
tenant_id=request.tenant_id,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
results: list[MemoryOut] = []
|
|
316
|
+
seen_memory_ids: set[str] = set()
|
|
317
|
+
seen_graph_paths: set[tuple[Any, ...]] = set()
|
|
318
|
+
|
|
319
|
+
for row in text_rows:
|
|
320
|
+
memory_id = str(row["id"])
|
|
321
|
+
if memory_id in seen_memory_ids:
|
|
322
|
+
continue
|
|
323
|
+
seen_memory_ids.add(memory_id)
|
|
324
|
+
score = self.decay_engine.calculate_score(
|
|
325
|
+
base_weight=float(row["base_weight"]),
|
|
326
|
+
decay_lambda=float(row["decay_lambda"]),
|
|
327
|
+
last_decay_at=int(row["last_decay_at"]),
|
|
328
|
+
retrieval_count=int(row["retrieval_count"]),
|
|
329
|
+
usefulness_weight=float(
|
|
330
|
+
row.get("usefulness_weight", row["base_weight"])
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
results.append(
|
|
334
|
+
MemoryOut(
|
|
335
|
+
memory_id=memory_id,
|
|
336
|
+
result_type="memory",
|
|
337
|
+
content=str(row["content"]),
|
|
338
|
+
tenant_id=request.tenant_id,
|
|
339
|
+
score=score,
|
|
340
|
+
trace_id=trace_id,
|
|
341
|
+
valid_from=row["valid_from"],
|
|
342
|
+
valid_to=row["valid_to"],
|
|
343
|
+
created_at=int(row["created_at"]),
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
for row in graph_rows:
|
|
348
|
+
memory_id_value = row.get("memory_id")
|
|
349
|
+
memory_id = str(memory_id_value) if memory_id_value else None
|
|
350
|
+
if memory_id is not None:
|
|
351
|
+
if memory_id in seen_memory_ids:
|
|
352
|
+
continue
|
|
353
|
+
seen_memory_ids.add(memory_id)
|
|
354
|
+
memory = self.memory_manager.get_memory(memory_id)
|
|
355
|
+
score = self.decay_engine.calculate_score(
|
|
356
|
+
base_weight=float(memory["base_weight"]),
|
|
357
|
+
decay_lambda=float(memory["decay_lambda"]),
|
|
358
|
+
last_decay_at=int(memory["last_decay_at"]),
|
|
359
|
+
retrieval_count=int(memory["retrieval_count"]),
|
|
360
|
+
usefulness_weight=float(
|
|
361
|
+
memory.get("usefulness_weight", memory["base_weight"])
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
created_at = int(memory["created_at"])
|
|
365
|
+
else:
|
|
366
|
+
graph_key = (
|
|
367
|
+
row.get("start_id"),
|
|
368
|
+
row.get("entity_id"),
|
|
369
|
+
row.get("relation"),
|
|
370
|
+
row.get("depth"),
|
|
371
|
+
)
|
|
372
|
+
if graph_key in seen_graph_paths:
|
|
373
|
+
continue
|
|
374
|
+
seen_graph_paths.add(graph_key)
|
|
375
|
+
score = 0.0
|
|
376
|
+
created_at = None
|
|
377
|
+
|
|
378
|
+
results.append(
|
|
379
|
+
MemoryOut(
|
|
380
|
+
memory_id=memory_id,
|
|
381
|
+
result_type="graph",
|
|
382
|
+
subject=row.get("start_name"),
|
|
383
|
+
predicate=row.get("relation"),
|
|
384
|
+
object=row.get("entity_name"),
|
|
385
|
+
entity_name=row.get("entity_name"),
|
|
386
|
+
relation=row.get("relation"),
|
|
387
|
+
depth=row.get("depth"),
|
|
388
|
+
tenant_id=request.tenant_id,
|
|
389
|
+
trace_id=trace_id,
|
|
390
|
+
score=score,
|
|
391
|
+
valid_from=row.get("valid_from"),
|
|
392
|
+
created_at=created_at,
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
results.sort(key=lambda result: result.score, reverse=True)
|
|
397
|
+
return results[: request.limit]
|
|
398
|
+
except PydanticValidationError as exc:
|
|
399
|
+
raise ValidationError(str(exc)) from exc
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
_raise_database_error("recall", exc)
|
|
402
|
+
|
|
403
|
+
def get_history(self, subject: str, predicate: str) -> list[dict[str, Any]]:
|
|
404
|
+
"""Return all current and expired versions of a subject-predicate fact.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
subject: Subject entity name.
|
|
408
|
+
predicate: Relationship name.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Dictionaries containing the object and both validity timestamps.
|
|
412
|
+
|
|
413
|
+
Raises:
|
|
414
|
+
ValidationError: If subject or predicate is empty.
|
|
415
|
+
DatabaseError: If the history query fails.
|
|
416
|
+
"""
|
|
417
|
+
request = _validate_input(
|
|
418
|
+
HistoryIn,
|
|
419
|
+
subject=subject,
|
|
420
|
+
predicate=predicate,
|
|
421
|
+
)
|
|
422
|
+
try:
|
|
423
|
+
with self.database_manager.get_connection() as conn:
|
|
424
|
+
rows = conn.execute(
|
|
425
|
+
"""
|
|
426
|
+
SELECT edge.id, edge.tenant_id, edge.subject_id,
|
|
427
|
+
subject.name AS subject, edge.predicate,
|
|
428
|
+
edge.object_id, object.name AS object,
|
|
429
|
+
edge.valid_from, edge.valid_to, edge.confidence,
|
|
430
|
+
edge.memory_id, edge.created_at
|
|
431
|
+
FROM edges AS edge
|
|
432
|
+
JOIN entities AS subject
|
|
433
|
+
ON subject.id = edge.subject_id
|
|
434
|
+
AND subject.tenant_id = edge.tenant_id
|
|
435
|
+
JOIN entities AS object
|
|
436
|
+
ON object.id = edge.object_id
|
|
437
|
+
AND object.tenant_id = edge.tenant_id
|
|
438
|
+
WHERE edge.tenant_id = ?
|
|
439
|
+
AND subject.name = ?
|
|
440
|
+
AND edge.predicate = ?
|
|
441
|
+
ORDER BY edge.valid_from, edge.created_at, edge.id
|
|
442
|
+
""",
|
|
443
|
+
("default", request.subject, request.predicate),
|
|
444
|
+
).fetchall()
|
|
445
|
+
history = [
|
|
446
|
+
HistoryOut.model_validate(dict(row)).model_dump()
|
|
447
|
+
for row in rows
|
|
448
|
+
]
|
|
449
|
+
return history
|
|
450
|
+
except PydanticValidationError as exc:
|
|
451
|
+
raise ValidationError(str(exc)) from exc
|
|
452
|
+
except Exception as exc:
|
|
453
|
+
_raise_database_error("get history", exc)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
RecallWeaver = RecallWeaverAPI
|
|
457
|
+
|
|
458
|
+
_default_instance: RecallWeaverAPI | None = None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _get_default_instance() -> RecallWeaverAPI:
|
|
462
|
+
"""Return the lazily initialized default API for legacy function callers.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
The singleton API connected to the configured default database.
|
|
466
|
+
"""
|
|
467
|
+
global _default_instance
|
|
468
|
+
if _default_instance is None:
|
|
469
|
+
_default_instance = RecallWeaverAPI(settings.db_path)
|
|
470
|
+
return _default_instance
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def remember(content: str, base_weight: float = 1.0) -> str:
|
|
474
|
+
"""Persist plain text through the legacy module-level function.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
content: Text memory to persist.
|
|
478
|
+
base_weight: Base importance weight.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
The generated memory identifier.
|
|
482
|
+
"""
|
|
483
|
+
try:
|
|
484
|
+
request = TextMemoryIn(content=content)
|
|
485
|
+
return _get_default_instance().memory_manager.remember(
|
|
486
|
+
request.content,
|
|
487
|
+
base_weight=base_weight,
|
|
488
|
+
)
|
|
489
|
+
except PydanticValidationError as exc:
|
|
490
|
+
raise ValidationError(str(exc)) from exc
|
|
491
|
+
except (ValidationError, DatabaseError):
|
|
492
|
+
raise
|
|
493
|
+
except Exception as exc:
|
|
494
|
+
_raise_database_error("remember", exc)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def recall(
|
|
498
|
+
query: str,
|
|
499
|
+
limit: int = 5,
|
|
500
|
+
) -> list[MemoryOut]:
|
|
501
|
+
"""Search the configured default database through the legacy function.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
query: Search term or entity name.
|
|
505
|
+
limit: Maximum number of results.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Validated, ranked memory and graph results.
|
|
509
|
+
"""
|
|
510
|
+
return _get_default_instance().recall(query, limit)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
__all__ = ["MemoryOut", "RecallWeaver", "RecallWeaverAPI", "recall", "remember"]
|