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 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)
@@ -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"]