memcore-sdk 0.3.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.
- memcore/__init__.py +15 -0
- memcore/client.py +338 -0
- memcore/integrations/__init__.py +36 -0
- memcore/integrations/langchain.py +180 -0
- memcore/integrations/llama_index.py +198 -0
- memcore/models.py +131 -0
- memcore_sdk-0.3.0.dist-info/METADATA +162 -0
- memcore_sdk-0.3.0.dist-info/RECORD +9 -0
- memcore_sdk-0.3.0.dist-info/WHEEL +4 -0
memcore/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""memcore — 通用记忆内核的 Python SDK。
|
|
2
|
+
|
|
3
|
+
from memcore import Memcore
|
|
4
|
+
mc = Memcore(api_key="mk_live_...")
|
|
5
|
+
user = mc.subject("user-42")
|
|
6
|
+
user.remember("我住在上海")
|
|
7
|
+
print(user.recall("我住在哪").as_prompt())
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from memcore.client import AsyncMemcore, AsyncSubject, Memcore, Subject
|
|
11
|
+
from memcore.models import MemcoreError, Memory, Profile, ProfileSlot, RecallResult, Why
|
|
12
|
+
|
|
13
|
+
__version__ = "0.3.0"
|
|
14
|
+
__all__ = ["Memcore", "AsyncMemcore", "Subject", "AsyncSubject", "Memory", "Profile",
|
|
15
|
+
"ProfileSlot", "RecallResult", "Why", "MemcoreError"]
|
memcore/client.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""memcore Python SDK。
|
|
2
|
+
|
|
3
|
+
from memcore import Memcore
|
|
4
|
+
|
|
5
|
+
mc = Memcore(api_key="mk_live_...")
|
|
6
|
+
user = mc.subject("user-42") # 绑定主体,省得每次都传
|
|
7
|
+
|
|
8
|
+
user.remember("我住在上海,在做数据库产品")
|
|
9
|
+
hits = user.recall("他住在哪", explain=True)
|
|
10
|
+
print(hits.as_prompt()) # 直接塞进你的 system prompt
|
|
11
|
+
|
|
12
|
+
设计取舍:
|
|
13
|
+
- 同步 `Memcore` 与异步 `AsyncMemcore` 对等,方法名完全一致
|
|
14
|
+
- `subject()` 返回绑定主体的句柄——绝大多数调用都是"针对某个用户",不该重复传参
|
|
15
|
+
- 只依赖 httpx;返回轻量 dataclass,不给调用方塞 pydantic
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from memcore.models import MemcoreError, Memory, Profile, RecallResult
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = os.environ.get("MEMCORE_BASE_URL", "https://api.reglos.ai")
|
|
30
|
+
_TIMEOUT = 30.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _params(subject: str, **kw) -> dict:
|
|
34
|
+
out: dict[str, Any] = {"subject": subject}
|
|
35
|
+
for k, v in kw.items():
|
|
36
|
+
if v is None or v is False:
|
|
37
|
+
continue
|
|
38
|
+
out[k] = v.isoformat() if isinstance(v, datetime) else v
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check(r: httpx.Response) -> dict:
|
|
43
|
+
if r.status_code >= 400:
|
|
44
|
+
try:
|
|
45
|
+
msg = r.json().get("detail", r.text)
|
|
46
|
+
except Exception:
|
|
47
|
+
msg = r.text
|
|
48
|
+
raise MemcoreError(r.status_code, str(msg))
|
|
49
|
+
return r.json()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _BaseSubject:
|
|
53
|
+
"""绑定了某个主体的句柄。所有方法都不必再传 subject。"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, client, subject_id: str, subject_type: str = "user"):
|
|
56
|
+
self._c = client
|
|
57
|
+
self.subject_id = subject_id
|
|
58
|
+
self.subject_type = subject_type
|
|
59
|
+
|
|
60
|
+
def _p(self, **kw) -> dict:
|
|
61
|
+
return _params(self.subject_id, subject_type=self.subject_type, **kw)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Subject(_BaseSubject):
|
|
65
|
+
"""同步句柄。"""
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------- 写入
|
|
68
|
+
def remember(
|
|
69
|
+
self,
|
|
70
|
+
text: str | None = None,
|
|
71
|
+
*,
|
|
72
|
+
messages: list[dict] | None = None,
|
|
73
|
+
facts: list[dict] | None = None,
|
|
74
|
+
session_id: str | None = None,
|
|
75
|
+
sync: bool = False,
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""写入记忆。
|
|
78
|
+
|
|
79
|
+
text 最常用:一段自然语言
|
|
80
|
+
messages 对话形式 [{"role","content","at"}];`at` 用于回灌历史(相对时间才解析得对)
|
|
81
|
+
facts 直接写结构化事实(客户已有自己的抽取时用)
|
|
82
|
+
sync True = 同步抽取完再返回(写完立刻可查,代价是延迟)
|
|
83
|
+
"""
|
|
84
|
+
body: dict[str, Any] = {}
|
|
85
|
+
if text is not None:
|
|
86
|
+
body["text"] = text
|
|
87
|
+
if messages:
|
|
88
|
+
body["messages"] = messages
|
|
89
|
+
if facts:
|
|
90
|
+
body["facts"] = facts
|
|
91
|
+
if session_id:
|
|
92
|
+
body["session_id"] = session_id
|
|
93
|
+
return _check(
|
|
94
|
+
self._c._http.post(
|
|
95
|
+
"/v1/remember", params=self._p(mode="sync" if sync else "async"), json=body
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def remember_file(self, path: str | Path) -> dict:
|
|
100
|
+
"""文档/图片入记忆。图片走 VLM 双路(描述 + OCR);
|
|
101
|
+
通用内容只进可检索索引,只有关于该主体的少量事实进画像。"""
|
|
102
|
+
p = Path(path)
|
|
103
|
+
with p.open("rb") as fh:
|
|
104
|
+
return _check(
|
|
105
|
+
self._c._http.post(
|
|
106
|
+
"/v1/documents", params=self._p(), files={"file": (p.name, fh)}
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------- 检索
|
|
111
|
+
def recall(
|
|
112
|
+
self,
|
|
113
|
+
query: str,
|
|
114
|
+
*,
|
|
115
|
+
limit: int | None = None,
|
|
116
|
+
explain: bool = False,
|
|
117
|
+
topic: str | None = None,
|
|
118
|
+
since: datetime | None = None,
|
|
119
|
+
until: datetime | None = None,
|
|
120
|
+
source: str | None = None,
|
|
121
|
+
min_confidence: float | None = None,
|
|
122
|
+
layers: str = "facts,summaries,documents",
|
|
123
|
+
) -> RecallResult:
|
|
124
|
+
"""检索记忆。`explain=True` 会为每条附上"为什么被选中"(命中路径、排名、
|
|
125
|
+
RRF 贡献、有效期状态)与预算统计——调试与合规都用得上。"""
|
|
126
|
+
return RecallResult._from(
|
|
127
|
+
_check(
|
|
128
|
+
self._c._http.get(
|
|
129
|
+
"/v1/recall",
|
|
130
|
+
params=self._p(
|
|
131
|
+
q=query, limit=limit, explain=explain, topic=topic, since=since,
|
|
132
|
+
until=until, source=source, min_confidence=min_confidence,
|
|
133
|
+
include_layers=layers,
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def messages(self, *, limit: int = 50, session_id: str | None = None) -> list[dict]:
|
|
140
|
+
"""按**时间顺序**读回最近的消息(旧 → 新)。
|
|
141
|
+
|
|
142
|
+
与 `recall` 的分野:`recall` 回答「关于这个问题,我们记得什么」
|
|
143
|
+
(抽取后的事实,按相关性排序);这里回答「这个人说过什么」
|
|
144
|
+
(原始消息,按时间排序)。**对话历史不能用检索排序冒充。**
|
|
145
|
+
"""
|
|
146
|
+
return _check(
|
|
147
|
+
self._c._http.get(
|
|
148
|
+
"/v1/messages", params=self._p(limit=limit, session_id=session_id)
|
|
149
|
+
)
|
|
150
|
+
).get("messages", [])
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------- 画像
|
|
153
|
+
def profile(self) -> Profile:
|
|
154
|
+
return Profile._from(_check(self._c._http.get("/v1/profile", params=self._p())))
|
|
155
|
+
|
|
156
|
+
def ask(self, question: str) -> dict:
|
|
157
|
+
"""自然语言问画像:不必理解我们的槽位结构。返回 {answer, based_on}。"""
|
|
158
|
+
return _check(
|
|
159
|
+
self._c._http.post("/v1/profile/ask", params=self._p(), json={"question": question})
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------- 遗忘
|
|
163
|
+
def forget(
|
|
164
|
+
self,
|
|
165
|
+
memory_id: str | None = None,
|
|
166
|
+
*,
|
|
167
|
+
session_id: str | None = None,
|
|
168
|
+
everything: bool = False,
|
|
169
|
+
reason: str | None = None,
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""三种语义严格区分:
|
|
172
|
+
memory_id 抽错了 → 作废,查询不再命中,但保留痕迹可回溯
|
|
173
|
+
session_id 删一整段会话 → 级联清掉派生事实与向量,不留幽灵
|
|
174
|
+
everything 全清(删除权)→ 返回可存档的删除回执
|
|
175
|
+
"""
|
|
176
|
+
body: dict[str, Any] = {"reason": reason}
|
|
177
|
+
if memory_id:
|
|
178
|
+
body["memory_id"] = memory_id
|
|
179
|
+
elif session_id:
|
|
180
|
+
body["session_id"] = session_id
|
|
181
|
+
elif everything:
|
|
182
|
+
body.update({"all": True, "confirm": self.subject_id})
|
|
183
|
+
else:
|
|
184
|
+
raise ValueError("memory_id / session_id / everything 三选一")
|
|
185
|
+
return _check(self._c._http.post("/v1/forget", params=self._p(), json=body))
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------- 溯源与审计
|
|
188
|
+
def explain(self, memory_id: str) -> dict:
|
|
189
|
+
"""这条记忆怎么来的:原始出处、生成记录(模型 + prompt 哈希)、被谁取代。"""
|
|
190
|
+
return _check(
|
|
191
|
+
self._c._http.get(f"/v1/memories/{memory_id}/explain", params=self._p())
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def audit(self, *, since: datetime | None = None, action: str | None = None,
|
|
195
|
+
limit: int = 200) -> list[dict]:
|
|
196
|
+
"""审计账本:谁在何时改了哪条、为什么。"""
|
|
197
|
+
return _check(
|
|
198
|
+
self._c._http.get("/v1/audit", params=self._p(since=since, action=action, limit=limit))
|
|
199
|
+
)["entries"]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Memcore:
|
|
203
|
+
"""同步客户端。线程安全:内部 httpx.Client 可复用。"""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
api_key: str | None = None,
|
|
208
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
209
|
+
timeout: float = _TIMEOUT,
|
|
210
|
+
):
|
|
211
|
+
self._http = httpx.Client(
|
|
212
|
+
base_url=base_url.rstrip("/"),
|
|
213
|
+
timeout=timeout,
|
|
214
|
+
headers={"X-API-Key": api_key} if api_key else {},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def subject(self, subject_id: str, subject_type: str = "user") -> Subject:
|
|
218
|
+
"""绑定一个记忆主体(终端用户 / Agent / 团队)。"""
|
|
219
|
+
return Subject(self, subject_id, subject_type)
|
|
220
|
+
|
|
221
|
+
def health(self) -> dict:
|
|
222
|
+
return _check(self._http.get("/healthz"))
|
|
223
|
+
|
|
224
|
+
def close(self) -> None:
|
|
225
|
+
self._http.close()
|
|
226
|
+
|
|
227
|
+
def __enter__(self) -> Memcore:
|
|
228
|
+
return self
|
|
229
|
+
|
|
230
|
+
def __exit__(self, *exc) -> None:
|
|
231
|
+
self.close()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class AsyncSubject(_BaseSubject):
|
|
235
|
+
"""异步句柄,方法与 Subject 一一对应。"""
|
|
236
|
+
|
|
237
|
+
async def remember(self, text=None, *, messages=None, facts=None,
|
|
238
|
+
session_id=None, sync=False) -> dict:
|
|
239
|
+
body: dict[str, Any] = {}
|
|
240
|
+
if text is not None:
|
|
241
|
+
body["text"] = text
|
|
242
|
+
if messages:
|
|
243
|
+
body["messages"] = messages
|
|
244
|
+
if facts:
|
|
245
|
+
body["facts"] = facts
|
|
246
|
+
if session_id:
|
|
247
|
+
body["session_id"] = session_id
|
|
248
|
+
return _check(
|
|
249
|
+
await self._c._http.post(
|
|
250
|
+
"/v1/remember", params=self._p(mode="sync" if sync else "async"), json=body
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
async def remember_file(self, path: str | Path) -> dict:
|
|
255
|
+
p = Path(path)
|
|
256
|
+
with p.open("rb") as fh:
|
|
257
|
+
return _check(
|
|
258
|
+
await self._c._http.post(
|
|
259
|
+
"/v1/documents", params=self._p(), files={"file": (p.name, fh)}
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
async def recall(self, query: str, *, limit=None, explain=False, topic=None, since=None,
|
|
264
|
+
until=None, source=None, min_confidence=None,
|
|
265
|
+
layers="facts,summaries,documents") -> RecallResult:
|
|
266
|
+
return RecallResult._from(
|
|
267
|
+
_check(
|
|
268
|
+
await self._c._http.get(
|
|
269
|
+
"/v1/recall",
|
|
270
|
+
params=self._p(q=query, limit=limit, explain=explain, topic=topic,
|
|
271
|
+
since=since, until=until, source=source,
|
|
272
|
+
min_confidence=min_confidence, include_layers=layers),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def profile(self) -> Profile:
|
|
278
|
+
return Profile._from(_check(await self._c._http.get("/v1/profile", params=self._p())))
|
|
279
|
+
|
|
280
|
+
async def ask(self, question: str) -> dict:
|
|
281
|
+
return _check(
|
|
282
|
+
await self._c._http.post(
|
|
283
|
+
"/v1/profile/ask", params=self._p(), json={"question": question}
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
async def forget(self, memory_id=None, *, session_id=None, everything=False,
|
|
288
|
+
reason=None) -> dict:
|
|
289
|
+
body: dict[str, Any] = {"reason": reason}
|
|
290
|
+
if memory_id:
|
|
291
|
+
body["memory_id"] = memory_id
|
|
292
|
+
elif session_id:
|
|
293
|
+
body["session_id"] = session_id
|
|
294
|
+
elif everything:
|
|
295
|
+
body.update({"all": True, "confirm": self.subject_id})
|
|
296
|
+
else:
|
|
297
|
+
raise ValueError("memory_id / session_id / everything 三选一")
|
|
298
|
+
return _check(await self._c._http.post("/v1/forget", params=self._p(), json=body))
|
|
299
|
+
|
|
300
|
+
async def explain(self, memory_id: str) -> dict:
|
|
301
|
+
return _check(
|
|
302
|
+
await self._c._http.get(f"/v1/memories/{memory_id}/explain", params=self._p())
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
async def audit(self, *, since=None, action=None, limit=200) -> list[dict]:
|
|
306
|
+
return _check(
|
|
307
|
+
await self._c._http.get(
|
|
308
|
+
"/v1/audit", params=self._p(since=since, action=action, limit=limit)
|
|
309
|
+
)
|
|
310
|
+
)["entries"]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class AsyncMemcore:
|
|
314
|
+
def __init__(self, api_key: str | None = None, base_url: str = DEFAULT_BASE_URL,
|
|
315
|
+
timeout: float = _TIMEOUT):
|
|
316
|
+
self._http = httpx.AsyncClient(
|
|
317
|
+
base_url=base_url.rstrip("/"),
|
|
318
|
+
timeout=timeout,
|
|
319
|
+
headers={"X-API-Key": api_key} if api_key else {},
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def subject(self, subject_id: str, subject_type: str = "user") -> AsyncSubject:
|
|
323
|
+
return AsyncSubject(self, subject_id, subject_type)
|
|
324
|
+
|
|
325
|
+
async def health(self) -> dict:
|
|
326
|
+
return _check(await self._http.get("/healthz"))
|
|
327
|
+
|
|
328
|
+
async def aclose(self) -> None:
|
|
329
|
+
await self._http.aclose()
|
|
330
|
+
|
|
331
|
+
async def __aenter__(self) -> AsyncMemcore:
|
|
332
|
+
return self
|
|
333
|
+
|
|
334
|
+
async def __aexit__(self, *exc) -> None:
|
|
335
|
+
await self.aclose()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
__all__ = ["Memcore", "AsyncMemcore", "Memory", "Profile", "RecallResult", "MemcoreError"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""框架适配:LangChain / LlamaIndex。
|
|
2
|
+
|
|
3
|
+
## 为什么单独放一个子包
|
|
4
|
+
|
|
5
|
+
这些适配**不能**成为 SDK 的必需依赖 —— 装个记忆 SDK 顺带拖进整个 LangChain,
|
|
6
|
+
对不用它的人是纯粹的负担。所以:
|
|
7
|
+
|
|
8
|
+
- `pyproject.toml` 里做成 optional extras(`memcore-sdk[langchain]`)
|
|
9
|
+
- 每个模块在**导入时**才 import 框架,没装就抛一句能读懂的话,
|
|
10
|
+
而不是 `ModuleNotFoundError: No module named 'langchain_core'` 让人自己猜
|
|
11
|
+
|
|
12
|
+
## 两个框架各自的接入点
|
|
13
|
+
|
|
14
|
+
| | LangChain | LlamaIndex |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| 拿记忆当检索源 | `MemcoreRetriever`(`BaseRetriever`) | `MemcoreRetriever`(`BaseRetriever`) |
|
|
17
|
+
| 拿记忆当对话历史 | `MemcoreChatMessageHistory` | `MemcoreMemory`(`BaseMemory`) |
|
|
18
|
+
|
|
19
|
+
**两者的语义差别要清楚**:检索器回答"关于这个问题,我们记得什么";
|
|
20
|
+
对话历史回答"这个人跟我说过什么"。前者是抽取后的事实,后者是原始消息。
|
|
21
|
+
混用会让上下文里出现同一件事的两个版本。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__all__ = ["require"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def require(module: str, extra: str) -> None:
|
|
28
|
+
"""检查框架是否可用,不可用时抛一句能直接照做的话。"""
|
|
29
|
+
import importlib.util
|
|
30
|
+
|
|
31
|
+
if importlib.util.find_spec(module) is None:
|
|
32
|
+
raise ImportError(
|
|
33
|
+
f"需要 {module},但它没有安装。\n"
|
|
34
|
+
f" pip install 'memcore-sdk[{extra}]'\n"
|
|
35
|
+
f" 或直接:pip install {module}"
|
|
36
|
+
)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""LangChain 适配。
|
|
2
|
+
|
|
3
|
+
pip install 'memcore-sdk[langchain]'
|
|
4
|
+
|
|
5
|
+
两个接入点,语义不同,**别混用**:
|
|
6
|
+
|
|
7
|
+
MemcoreRetriever 「关于这个问题,我们记得什么」→ 抽取后的事实
|
|
8
|
+
MemcoreChatMessageHistory 「这个人跟我说过什么」 → 原始消息
|
|
9
|
+
|
|
10
|
+
同时塞进上下文会让同一件事出现两个版本(一条事实 + 它的原始出处),
|
|
11
|
+
既浪费预算又容易让模型自相矛盾。要长期记忆用前者,要多轮上下文用后者。
|
|
12
|
+
|
|
13
|
+
## 用法
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from memcore import Memcore
|
|
17
|
+
from memcore.integrations.langchain import MemcoreRetriever, MemcoreChatMessageHistory
|
|
18
|
+
|
|
19
|
+
mc = Memcore(api_key="mk_live_…", base_url="https://your-host")
|
|
20
|
+
|
|
21
|
+
# ① 当检索器用
|
|
22
|
+
retriever = MemcoreRetriever(subject=mc.subject("alice"), k=8)
|
|
23
|
+
chain = {"context": retriever, "question": RunnablePassthrough()} | prompt | llm
|
|
24
|
+
|
|
25
|
+
# ② 当对话历史用
|
|
26
|
+
chain_with_history = RunnableWithMessageHistory(
|
|
27
|
+
chain,
|
|
28
|
+
lambda sid: MemcoreChatMessageHistory(subject=mc.subject(sid)),
|
|
29
|
+
input_messages_key="question",
|
|
30
|
+
history_messages_key="history",
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from memcore.integrations import require
|
|
40
|
+
|
|
41
|
+
require("langchain_core", "langchain")
|
|
42
|
+
|
|
43
|
+
from langchain_core.callbacks import CallbackManagerForRetrieverRun # noqa: E402
|
|
44
|
+
from langchain_core.chat_history import BaseChatMessageHistory # noqa: E402
|
|
45
|
+
from langchain_core.documents import Document # noqa: E402
|
|
46
|
+
from langchain_core.messages import ( # noqa: E402
|
|
47
|
+
AIMessage,
|
|
48
|
+
BaseMessage,
|
|
49
|
+
HumanMessage,
|
|
50
|
+
SystemMessage,
|
|
51
|
+
)
|
|
52
|
+
from langchain_core.retrievers import BaseRetriever # noqa: E402
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _to_document(m: Any) -> Document:
|
|
56
|
+
"""一条记忆 → 一个 Document。
|
|
57
|
+
|
|
58
|
+
**`page_content` 只放陈述本身**,时间、来源、为什么被选中全进 metadata ——
|
|
59
|
+
把它们拼进正文会污染下游的相似度计算与去重(很多链会对 page_content 做二次处理)。
|
|
60
|
+
"""
|
|
61
|
+
meta: dict[str, Any] = {
|
|
62
|
+
"id": m.id,
|
|
63
|
+
"source": "memcore",
|
|
64
|
+
"topic": m.topic,
|
|
65
|
+
"valid_from": m.valid_from,
|
|
66
|
+
"valid_to": m.valid_to,
|
|
67
|
+
# 这条是不是仍然有效。False 表示已被更新的事实取代 ——
|
|
68
|
+
# 仍是历史真相,但下游若要"当前状态"就该过滤掉
|
|
69
|
+
"is_current": m.is_current,
|
|
70
|
+
"provenance": m.provenance,
|
|
71
|
+
}
|
|
72
|
+
if m.score is not None:
|
|
73
|
+
meta["score"] = m.score
|
|
74
|
+
if m.explain_url:
|
|
75
|
+
# 可解释性是我们的差异化 —— 把入口一路带到 Document 上,
|
|
76
|
+
# 下游要追"这条为什么被选中"时不用回头查
|
|
77
|
+
meta["explain_url"] = m.explain_url
|
|
78
|
+
return Document(page_content=m.statement, metadata=meta)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MemcoreRetriever(BaseRetriever):
|
|
82
|
+
"""把 memcore 当 LangChain 检索器用。
|
|
83
|
+
|
|
84
|
+
与向量库检索器的区别:返回的是**抽取后的事实**,不是原文分块。
|
|
85
|
+
所以条数少、密度高,`k` 通常比向量库小一个量级。
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
subject: Any
|
|
89
|
+
"""`memcore.Memcore(...).subject("alice")` 拿到的对象。"""
|
|
90
|
+
|
|
91
|
+
k: int = 8
|
|
92
|
+
"""取多少条。"""
|
|
93
|
+
|
|
94
|
+
topic: str | None = None
|
|
95
|
+
"""只取某个主题(可选)。"""
|
|
96
|
+
|
|
97
|
+
min_confidence: float | None = None
|
|
98
|
+
|
|
99
|
+
current_only: bool = True
|
|
100
|
+
"""是否只要仍然有效的事实。**默认 True** —— 多数链要的是"现在是什么",
|
|
101
|
+
而账本里保留着被取代的历史版本;不过滤会让模型同时看到新旧两个说法。"""
|
|
102
|
+
|
|
103
|
+
# 允许 Subject 这种任意对象作为字段值
|
|
104
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
105
|
+
|
|
106
|
+
def _get_relevant_documents(
|
|
107
|
+
self, query: str, *, run_manager: CallbackManagerForRetrieverRun
|
|
108
|
+
) -> list[Document]:
|
|
109
|
+
res = self.subject.recall(
|
|
110
|
+
query, limit=self.k, topic=self.topic, min_confidence=self.min_confidence
|
|
111
|
+
)
|
|
112
|
+
facts = list(res.facts)
|
|
113
|
+
if self.current_only:
|
|
114
|
+
facts = [f for f in facts if f.is_current]
|
|
115
|
+
return [_to_document(f) for f in facts[: self.k]]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class MemcoreChatMessageHistory(BaseChatMessageHistory):
|
|
119
|
+
"""把 memcore 当 LangChain 的对话历史用(`RunnableWithMessageHistory`)。
|
|
120
|
+
|
|
121
|
+
## 与普通历史存储的两点不同
|
|
122
|
+
|
|
123
|
+
**一、写入是异步抽取的。** `add_messages` 落库即返回,事实抽取在后台队列进行。
|
|
124
|
+
所以刚写完立刻 `recall` 未必查得到那条事实 —— 但 `messages` 读的是原始消息,
|
|
125
|
+
不受影响。要写完立刻可查,用 `sync=True`(代价是延迟)。
|
|
126
|
+
|
|
127
|
+
**二、`clear()` 是真的删。** 它调 `/v1/forget` 清掉该主体的全部记忆,
|
|
128
|
+
**不可撤销**。LangChain 的语义里 clear 只是清一个会话的历史,
|
|
129
|
+
而我们这里没有"只清历史不清记忆"的中间态 —— 所以默认**拒绝执行**,
|
|
130
|
+
必须显式 `allow_destructive_clear=True` 才动手。
|
|
131
|
+
宁可让人多写一个参数,也不能让一次误调用清空某个人的全部记忆。
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
subject: Any,
|
|
137
|
+
*,
|
|
138
|
+
limit: int = 50,
|
|
139
|
+
sync: bool = False,
|
|
140
|
+
allow_destructive_clear: bool = False,
|
|
141
|
+
) -> None:
|
|
142
|
+
self.subject = subject
|
|
143
|
+
self.limit = limit
|
|
144
|
+
self.sync = sync
|
|
145
|
+
self.allow_destructive_clear = allow_destructive_clear
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def messages(self) -> list[BaseMessage]:
|
|
149
|
+
"""读回原始消息。取不到就返回空 —— 历史缺失不该让整条链挂掉。"""
|
|
150
|
+
try:
|
|
151
|
+
raw = self.subject.messages(limit=self.limit)
|
|
152
|
+
except Exception: # noqa: BLE001
|
|
153
|
+
# 历史读不回来不该让整条链挂掉 —— 退化成"没有历史"
|
|
154
|
+
return []
|
|
155
|
+
out: list[BaseMessage] = []
|
|
156
|
+
for m in raw:
|
|
157
|
+
role, content = m.get("role"), m.get("content") or ""
|
|
158
|
+
if role == "assistant":
|
|
159
|
+
out.append(AIMessage(content=content))
|
|
160
|
+
elif role == "system":
|
|
161
|
+
out.append(SystemMessage(content=content))
|
|
162
|
+
else:
|
|
163
|
+
out.append(HumanMessage(content=content))
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
def add_messages(self, messages: list[BaseMessage]) -> None:
|
|
167
|
+
payload = []
|
|
168
|
+
for m in messages:
|
|
169
|
+
role = {"ai": "assistant", "human": "user", "system": "system"}.get(m.type, "user")
|
|
170
|
+
payload.append({"role": role, "content": str(m.content)})
|
|
171
|
+
if payload:
|
|
172
|
+
self.subject.remember(messages=payload, sync=self.sync)
|
|
173
|
+
|
|
174
|
+
def clear(self) -> None:
|
|
175
|
+
if not self.allow_destructive_clear:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
"clear() 会清空该主体的**全部记忆**(不只是本次会话的历史),且不可撤销。\n"
|
|
178
|
+
"确实要这么做,请构造时传 allow_destructive_clear=True。"
|
|
179
|
+
)
|
|
180
|
+
self.subject.forget(everything=True)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""LlamaIndex 适配。
|
|
2
|
+
|
|
3
|
+
pip install 'memcore-sdk[llamaindex]'
|
|
4
|
+
|
|
5
|
+
两个接入点,语义不同,**别混用**(同 LangChain 那边):
|
|
6
|
+
|
|
7
|
+
MemcoreRetriever 「关于这个问题,我们记得什么」→ 抽取后的事实,按相关性
|
|
8
|
+
MemcoreMemory 「这个人说过什么」 → 原始消息,按时间
|
|
9
|
+
|
|
10
|
+
## 用法
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from memcore import Memcore
|
|
14
|
+
from memcore.integrations.llama_index import MemcoreRetriever, MemcoreMemory
|
|
15
|
+
|
|
16
|
+
mc = Memcore(api_key="mk_live_…", base_url="https://your-host")
|
|
17
|
+
subject = mc.subject("alice")
|
|
18
|
+
|
|
19
|
+
# ① 当检索器用(可直接进 RetrieverQueryEngine)
|
|
20
|
+
retriever = MemcoreRetriever(subject=subject, k=8)
|
|
21
|
+
nodes = retriever.retrieve("我最喜欢什么乐器?")
|
|
22
|
+
|
|
23
|
+
# ② 当 ChatEngine 的记忆用
|
|
24
|
+
memory = MemcoreMemory.from_defaults(subject=subject)
|
|
25
|
+
```
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from memcore.integrations import require
|
|
33
|
+
|
|
34
|
+
require("llama_index.core", "llamaindex")
|
|
35
|
+
|
|
36
|
+
from llama_index.core.base.llms.types import ChatMessage, MessageRole # noqa: E402
|
|
37
|
+
from llama_index.core.memory import BaseMemory # noqa: E402
|
|
38
|
+
from llama_index.core.retrievers import BaseRetriever # noqa: E402
|
|
39
|
+
from llama_index.core.schema import NodeWithScore, QueryBundle, TextNode # noqa: E402
|
|
40
|
+
|
|
41
|
+
_ROLE_IN = {"assistant": MessageRole.ASSISTANT, "system": MessageRole.SYSTEM,
|
|
42
|
+
"user": MessageRole.USER}
|
|
43
|
+
_ROLE_OUT = {MessageRole.ASSISTANT: "assistant", MessageRole.SYSTEM: "system",
|
|
44
|
+
MessageRole.USER: "user"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _to_node(m: Any) -> NodeWithScore:
|
|
48
|
+
"""一条记忆 → 一个 NodeWithScore。
|
|
49
|
+
|
|
50
|
+
`text` 只放陈述本身,其余进 metadata —— 与 LangChain 那边同样的理由:
|
|
51
|
+
把时间、来源拼进正文会污染下游的相似度与去重。
|
|
52
|
+
|
|
53
|
+
**`excluded_llm_metadata_keys` 要设。** LlamaIndex 默认会把 metadata
|
|
54
|
+
一并渲染进给 LLM 的文本里,`id` / `explain_url` 这些对回答毫无帮助,
|
|
55
|
+
白占上下文预算;但它们要留给调用方做溯源,所以是"排除渲染"而不是不写。
|
|
56
|
+
"""
|
|
57
|
+
meta = {
|
|
58
|
+
"id": m.id,
|
|
59
|
+
"source": "memcore",
|
|
60
|
+
"topic": m.topic,
|
|
61
|
+
"valid_from": m.valid_from,
|
|
62
|
+
"valid_to": m.valid_to,
|
|
63
|
+
"is_current": m.is_current,
|
|
64
|
+
"explain_url": m.explain_url,
|
|
65
|
+
}
|
|
66
|
+
node = TextNode(
|
|
67
|
+
id_=m.id or None,
|
|
68
|
+
text=m.statement,
|
|
69
|
+
metadata={k: v for k, v in meta.items() if v is not None},
|
|
70
|
+
excluded_llm_metadata_keys=["id", "explain_url", "source"],
|
|
71
|
+
excluded_embed_metadata_keys=["id", "explain_url", "source"],
|
|
72
|
+
)
|
|
73
|
+
return NodeWithScore(node=node, score=m.score)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MemcoreRetriever(BaseRetriever):
|
|
77
|
+
"""把 memcore 当 LlamaIndex 检索器用。
|
|
78
|
+
|
|
79
|
+
与向量索引检索器的区别:返回的是**抽取后的事实**,不是原文分块 ——
|
|
80
|
+
条数少、密度高,`k` 通常比向量库小一个量级。
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
subject: Any,
|
|
86
|
+
*,
|
|
87
|
+
k: int = 8,
|
|
88
|
+
topic: str | None = None,
|
|
89
|
+
min_confidence: float | None = None,
|
|
90
|
+
current_only: bool = True,
|
|
91
|
+
**kwargs: Any,
|
|
92
|
+
) -> None:
|
|
93
|
+
self._subject = subject
|
|
94
|
+
self._k = k
|
|
95
|
+
self._topic = topic
|
|
96
|
+
self._min_confidence = min_confidence
|
|
97
|
+
# 只要仍然有效的事实。**默认 True** —— 账本里保留着被取代的历史版本,
|
|
98
|
+
# 不过滤会让模型同时看到新旧两个说法。
|
|
99
|
+
self._current_only = current_only
|
|
100
|
+
super().__init__(**kwargs)
|
|
101
|
+
|
|
102
|
+
def _retrieve(self, query_bundle: QueryBundle) -> list[NodeWithScore]:
|
|
103
|
+
res = self._subject.recall(
|
|
104
|
+
query_bundle.query_str, limit=self._k,
|
|
105
|
+
topic=self._topic, min_confidence=self._min_confidence,
|
|
106
|
+
)
|
|
107
|
+
facts = list(res.facts)
|
|
108
|
+
if self._current_only:
|
|
109
|
+
facts = [f for f in facts if f.is_current]
|
|
110
|
+
return [_to_node(f) for f in facts[: self._k]]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class MemcoreMemory(BaseMemory):
|
|
114
|
+
"""把 memcore 当 LlamaIndex 的对话记忆用。
|
|
115
|
+
|
|
116
|
+
## 与 `ChatMemoryBuffer` 的三点不同
|
|
117
|
+
|
|
118
|
+
**一、跨进程、跨设备。** 记忆在服务端,换台机器、换个端都还在 ——
|
|
119
|
+
`ChatMemoryBuffer` 是进程内的。
|
|
120
|
+
|
|
121
|
+
**二、`put` 是异步抽取的。** 落库即返回,事实抽取在后台队列进行。
|
|
122
|
+
`get()` 读的是原始消息,不受影响。
|
|
123
|
+
|
|
124
|
+
**三、`reset()` 默认拒绝执行。** LlamaIndex 的语义里 reset 只是清一段会话,
|
|
125
|
+
而我们这里它会清掉该主体的**全部记忆**且不可撤销。所以必须显式
|
|
126
|
+
`allow_destructive_reset=True`。宁可让人多写一个参数,
|
|
127
|
+
也不能让一次误调用清空某个人的全部记忆。
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
# BaseMemory 是 pydantic 模型;这些字段要声明才存得住
|
|
131
|
+
subject: Any = None
|
|
132
|
+
limit: int = 50
|
|
133
|
+
sync: bool = False
|
|
134
|
+
allow_destructive_reset: bool = False
|
|
135
|
+
|
|
136
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def class_name(cls) -> str:
|
|
140
|
+
return "MemcoreMemory"
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_defaults(cls, **kwargs: Any) -> MemcoreMemory: # type: ignore[override]
|
|
144
|
+
subject = kwargs.pop("subject", None)
|
|
145
|
+
if subject is None:
|
|
146
|
+
raise ValueError("MemcoreMemory 需要 subject=Memcore(...).subject('<id>')")
|
|
147
|
+
return cls(subject=subject, **kwargs)
|
|
148
|
+
|
|
149
|
+
def get(self, input: str | None = None, **kwargs: Any) -> list[ChatMessage]:
|
|
150
|
+
"""LlamaIndex 会用它取"要喂给模型的历史"。这里按时间顺序返回原始消息。
|
|
151
|
+
|
|
152
|
+
`input` 参数没用上是**有意的**:它是给"按当前输入裁剪历史"这类实现用的,
|
|
153
|
+
而我们这里若按 input 去检索,返回的就不是历史而是检索结果 ——
|
|
154
|
+
那正是 `MemcoreRetriever` 的活。两者混在一个对象里会让上下文出现
|
|
155
|
+
同一件事的两个版本。
|
|
156
|
+
"""
|
|
157
|
+
return self.get_all()
|
|
158
|
+
|
|
159
|
+
def get_all(self) -> list[ChatMessage]:
|
|
160
|
+
try:
|
|
161
|
+
raw = self.subject.messages(limit=self.limit)
|
|
162
|
+
except Exception: # noqa: BLE001
|
|
163
|
+
# 历史读不回来不该让整个 ChatEngine 挂掉 —— 退化成"没有历史"
|
|
164
|
+
return []
|
|
165
|
+
return [
|
|
166
|
+
ChatMessage(
|
|
167
|
+
role=_ROLE_IN.get(m.get("role") or "user", MessageRole.USER),
|
|
168
|
+
content=m.get("content") or "",
|
|
169
|
+
)
|
|
170
|
+
for m in raw
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
def put(self, message: ChatMessage) -> None:
|
|
174
|
+
role = _ROLE_OUT.get(message.role, "user")
|
|
175
|
+
content = message.content or ""
|
|
176
|
+
if not content:
|
|
177
|
+
return
|
|
178
|
+
self.subject.remember(messages=[{"role": role, "content": content}], sync=self.sync)
|
|
179
|
+
|
|
180
|
+
def set(self, messages: list[ChatMessage]) -> None:
|
|
181
|
+
"""LlamaIndex 用它整体覆盖历史。
|
|
182
|
+
|
|
183
|
+
**我们做不到"覆盖"** —— 账本是 ADD-only,事实永不改写。
|
|
184
|
+
所以这里退化成"把这些消息追加进去",并且**只追加库里还没有的那些**
|
|
185
|
+
(按内容比对最近一段),避免每轮把整段历史重复写一遍。
|
|
186
|
+
"""
|
|
187
|
+
existing = {(m.role, m.content) for m in self.get_all()}
|
|
188
|
+
for m in messages:
|
|
189
|
+
if (m.role, m.content or "") not in existing:
|
|
190
|
+
self.put(m)
|
|
191
|
+
|
|
192
|
+
def reset(self) -> None:
|
|
193
|
+
if not self.allow_destructive_reset:
|
|
194
|
+
raise RuntimeError(
|
|
195
|
+
"reset() 会清空该主体的**全部记忆**(不只是本次会话),且不可撤销。\n"
|
|
196
|
+
"确实要这么做,请构造时传 allow_destructive_reset=True。"
|
|
197
|
+
)
|
|
198
|
+
self.subject.forget(everything=True)
|
memcore/models.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""SDK 返回类型。用轻量 dataclass 而非 pydantic——SDK 不该给调用方塞依赖。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Why:
|
|
11
|
+
"""一条记忆"为什么被检索到"。只有 explain=True 时才有。"""
|
|
12
|
+
|
|
13
|
+
matched_paths: dict[str, Any] = field(default_factory=dict) # 命中的检索路及排名
|
|
14
|
+
rrf_contribution: dict[str, float] = field(default_factory=dict) # 各路对最终分的贡献
|
|
15
|
+
validity: dict[str, Any] = field(default_factory=dict) # current / superseded + 有效期
|
|
16
|
+
confidence: float | None = None
|
|
17
|
+
confirmed_times: int | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Memory:
|
|
22
|
+
id: str
|
|
23
|
+
statement: str
|
|
24
|
+
score: float | None = None
|
|
25
|
+
topic: str | None = None
|
|
26
|
+
valid_from: str | None = None
|
|
27
|
+
valid_to: str | None = None
|
|
28
|
+
provenance: str | None = None
|
|
29
|
+
why: Why | None = None
|
|
30
|
+
explain_url: str | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_current(self) -> bool:
|
|
34
|
+
"""False 表示这条已被更新的事实取代(但仍是历史真相,可用于回溯查询)。"""
|
|
35
|
+
return self.valid_to is None
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def _from(cls, d: dict) -> Memory:
|
|
39
|
+
why = d.get("why")
|
|
40
|
+
return cls(
|
|
41
|
+
id=d.get("id", ""),
|
|
42
|
+
statement=d.get("statement", ""),
|
|
43
|
+
score=d.get("score"),
|
|
44
|
+
topic=d.get("topic"),
|
|
45
|
+
valid_from=d.get("valid_from"),
|
|
46
|
+
valid_to=d.get("valid_to"),
|
|
47
|
+
provenance=d.get("provenance"),
|
|
48
|
+
why=Why(**{k: v for k, v in why.items() if k in Why.__annotations__}) if why else None,
|
|
49
|
+
explain_url=d.get("explain_url"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str:
|
|
53
|
+
mark = "" if self.is_current else " (已被取代)"
|
|
54
|
+
return f"{self.statement}{mark}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class RecallResult:
|
|
59
|
+
facts: list[Memory] = field(default_factory=list)
|
|
60
|
+
summaries: list[dict] = field(default_factory=list) # L1 会话摘要
|
|
61
|
+
documents: list[dict] = field(default_factory=list) # 文档分块
|
|
62
|
+
messages: list[dict] = field(default_factory=list) # L0 原文片段
|
|
63
|
+
budget: dict | None = None # explain=True 时:用了多少预算、裁掉多少
|
|
64
|
+
timed_out_paths: list[str] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
def __iter__(self):
|
|
67
|
+
return iter(self.facts)
|
|
68
|
+
|
|
69
|
+
def __len__(self) -> int:
|
|
70
|
+
return len(self.facts)
|
|
71
|
+
|
|
72
|
+
def as_prompt(self, header: str = "关于该用户你已知的:") -> str:
|
|
73
|
+
"""拼成可直接塞进 system prompt 的文本——最常见的用法,省得每家自己写一遍。"""
|
|
74
|
+
if not self.facts:
|
|
75
|
+
return ""
|
|
76
|
+
lines = [f"- {m.statement}" for m in self.facts]
|
|
77
|
+
return f"{header}\n" + "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def _from(cls, d: dict) -> RecallResult:
|
|
81
|
+
return cls(
|
|
82
|
+
facts=[Memory._from(x) for x in d.get("facts", [])],
|
|
83
|
+
summaries=d.get("summaries", []) or [],
|
|
84
|
+
documents=d.get("documents", []) or [],
|
|
85
|
+
messages=d.get("messages", []) or [],
|
|
86
|
+
budget=d.get("budget"),
|
|
87
|
+
timed_out_paths=d.get("timed_out_paths", []) or [],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class ProfileSlot:
|
|
93
|
+
topic: str
|
|
94
|
+
sub_topic: str
|
|
95
|
+
projection: str
|
|
96
|
+
update_hits: int = 1
|
|
97
|
+
source_fact_ids: list[str] = field(default_factory=list) # 可下钻:这条画像由哪些事实支撑
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Profile:
|
|
102
|
+
slots: list[ProfileSlot] = field(default_factory=list)
|
|
103
|
+
markdown: str | None = None # 叙事版画像(Dream 全量重写)
|
|
104
|
+
|
|
105
|
+
def get(self, topic: str) -> list[ProfileSlot]:
|
|
106
|
+
return [s for s in self.slots if s.topic == topic]
|
|
107
|
+
|
|
108
|
+
def as_prompt(self) -> str:
|
|
109
|
+
return "\n".join(f"- [{s.topic}] {s.projection}" for s in self.slots)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def _from(cls, d: dict) -> Profile:
|
|
113
|
+
return cls(
|
|
114
|
+
slots=[
|
|
115
|
+
ProfileSlot(
|
|
116
|
+
topic=s.get("topic", ""),
|
|
117
|
+
sub_topic=s.get("sub_topic", ""),
|
|
118
|
+
projection=s.get("projection", ""),
|
|
119
|
+
update_hits=s.get("update_hits", 1),
|
|
120
|
+
source_fact_ids=s.get("source_fact_ids", []) or [],
|
|
121
|
+
)
|
|
122
|
+
for s in d.get("slots", [])
|
|
123
|
+
],
|
|
124
|
+
markdown=d.get("markdown"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class MemcoreError(Exception):
|
|
129
|
+
def __init__(self, status: int, message: str):
|
|
130
|
+
self.status, self.message = status, message
|
|
131
|
+
super().__init__(f"[{status}] {message}")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: memcore-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Python SDK for memcore — a universal memory kernel with explainable, auditable retrieval
|
|
5
|
+
Project-URL: Homepage, https://reglos.ai
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Keywords: agents,llm,memory,rag
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Provides-Extra: all
|
|
11
|
+
Requires-Dist: langchain-core>=0.3; extra == 'all'
|
|
12
|
+
Requires-Dist: llama-index-core>=0.12; extra == 'all'
|
|
13
|
+
Provides-Extra: langchain
|
|
14
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
15
|
+
Provides-Extra: llamaindex
|
|
16
|
+
Requires-Dist: llama-index-core>=0.12; extra == 'llamaindex'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# memcore Python SDK
|
|
20
|
+
|
|
21
|
+
给你的 AI 应用加一层**可解释、可审计**的长期记忆。
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install memcore-sdk
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 10 分钟上手
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from memcore import Memcore
|
|
31
|
+
|
|
32
|
+
mc = Memcore(api_key="mk_live_...") # tenant/project 由 key 决定,你不用管
|
|
33
|
+
user = mc.subject("your-user-42") # 绑定记忆主体,后续调用不必再传
|
|
34
|
+
|
|
35
|
+
# 1. 记住
|
|
36
|
+
user.remember("我叫 Richard,住在上海,在做数据库产品,周末喜欢爬山。")
|
|
37
|
+
|
|
38
|
+
# 2. 想起来 —— 直接拼进你的 prompt
|
|
39
|
+
hits = user.recall("他住在哪里")
|
|
40
|
+
system_prompt = "你是一个助手。\n" + hits.as_prompt()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
就这样。抽取、去重、冲突消解、时效管理都在服务端完成。
|
|
44
|
+
|
|
45
|
+
## 五个动词
|
|
46
|
+
|
|
47
|
+
| 动词 | 用途 |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `remember` | 写入:自然语言 / 对话消息 / 直接写事实 / 文档与图片 |
|
|
50
|
+
| `recall` | 检索:多路融合 + 过滤 + **解释** |
|
|
51
|
+
| `profile` / `ask` | 画像:结构化槽位 + 自然语言问答 |
|
|
52
|
+
| `forget` | 遗忘:否决单条 / 删会话 / 全清(带删除回执) |
|
|
53
|
+
| `explain` / `audit` | 溯源:这条记忆怎么来的 + 审计账本 |
|
|
54
|
+
|
|
55
|
+
## 写入的三种形态
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
# 自然语言(最常用)
|
|
59
|
+
user.remember("我下个月要去日本出差")
|
|
60
|
+
|
|
61
|
+
# 对话消息 —— `at` 用于回灌历史,相对时间("三年前")才能被正确解析
|
|
62
|
+
user.remember(messages=[
|
|
63
|
+
{"role": "user", "content": "我三年前搬到杭州", "at": "2023-05-01T10:00:00Z"},
|
|
64
|
+
{"role": "assistant", "content": "杭州挺好的"},
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
# 你已有自己的抽取,直接写结构化事实
|
|
68
|
+
user.remember(facts=[{"statement": "用户的租约 2027 年 8 月到期",
|
|
69
|
+
"topic": "lifestyle", "confidence": 0.95}])
|
|
70
|
+
|
|
71
|
+
# 文档与图片:PDF / Word / 文本直接解析;图片走 VLM 双路(描述 + OCR)
|
|
72
|
+
user.remember_file("contract.pdf")
|
|
73
|
+
user.remember_file("whiteboard.png")
|
|
74
|
+
|
|
75
|
+
# 需要"写完立刻可查"?
|
|
76
|
+
user.remember("...", sync=True) # 默认异步:落库即返回,抽取在后台
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 检索与解释
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
hits = user.recall("他住在哪里", explain=True)
|
|
83
|
+
|
|
84
|
+
for m in hits:
|
|
85
|
+
print(m.statement, m.is_current) # is_current=False 表示已被更新的事实取代
|
|
86
|
+
print(m.why.matched_paths) # {'vector': {'rank': 1}, 'keyword': {'rank': 3}}
|
|
87
|
+
print(m.why.rrf_contribution) # 各路对最终排名的贡献
|
|
88
|
+
print(m.why.validity) # current / superseded + 有效期
|
|
89
|
+
|
|
90
|
+
print(hits.budget) # 考虑了多少条、返回多少、裁掉多少、用了多少 token
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**为什么要 explain**:调试召回质量时你能看到"为什么是这条";合规审查时你能证明答案有据可依。
|
|
94
|
+
|
|
95
|
+
过滤:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
user.recall("最近的工作", topic="work", since=datetime(2026, 1, 1),
|
|
99
|
+
source="document", min_confidence=0.8, limit=20)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 记忆是有时间的
|
|
103
|
+
|
|
104
|
+
同一件事变了,旧记忆**不会被覆盖**,而是标记失效并保留:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
user.remember("我搬到上海了")
|
|
108
|
+
user.recall("住在哪里") # → 上海(当前有效)
|
|
109
|
+
# 历史仍在:m.is_current == False 的那条记录着"曾经住在杭州"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
这让"我 2023 年住哪"这类问题也答得出来,也让画像的每次变化都有据可查。
|
|
113
|
+
|
|
114
|
+
## 遗忘的三种语义
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
user.forget(memory_id, reason="抽错了") # 作废:查询不再命中,留痕可回溯
|
|
118
|
+
user.forget(session_id="...") # 级联:消息 + 派生事实 + 向量一起清
|
|
119
|
+
receipt = user.forget(everything=True) # 全清:返回可存档的删除回执(合规举证)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## 溯源与审计
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
lineage = user.explain(memory_id)
|
|
126
|
+
# → 原始消息原文、生成记录(模型 + prompt 哈希)、被哪条新事实取代
|
|
127
|
+
|
|
128
|
+
for e in user.audit(since=last_week):
|
|
129
|
+
print(e["at"], e["action"], e["record_id"])
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## 异步
|
|
133
|
+
|
|
134
|
+
方法名完全一致:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from memcore import AsyncMemcore
|
|
138
|
+
|
|
139
|
+
async with AsyncMemcore(api_key="...") as mc:
|
|
140
|
+
user = mc.subject("user-42")
|
|
141
|
+
await user.remember("...")
|
|
142
|
+
hits = await user.recall("...")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## 记忆主体不只是"用户"
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
mc.subject("user-42") # 终端用户
|
|
149
|
+
mc.subject("agent-researcher", "agent") # 给 Agent 的记忆
|
|
150
|
+
mc.subject("team-growth", "team") # 团队共享记忆
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## 错误处理
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from memcore import MemcoreError
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
user.remember("...")
|
|
160
|
+
except MemcoreError as e:
|
|
161
|
+
print(e.status, e.message) # 401 鉴权 / 400 参数 / 404 不存在
|
|
162
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
memcore/__init__.py,sha256=IHh2Ky2ie_DtCPLt8YZZdS5c2eYEMt_kypSk3kesFy0,585
|
|
2
|
+
memcore/client.py,sha256=c_7kiAvkw_0Cv_x_nqfXtBwvkFbs6_nq2gRNqImqcJE,12175
|
|
3
|
+
memcore/models.py,sha256=p7e98XvKV2Z5EfUTfKbX-J6OM9Jx3t-4bd5VvFUBy0c,4471
|
|
4
|
+
memcore/integrations/__init__.py,sha256=DjOvTXbvPhkuzcm65tA_Al-U65NiVHtMuX4dj5aXf8c,1429
|
|
5
|
+
memcore/integrations/langchain.py,sha256=kS51KJioF_h2ZJ2uDBXMrMz95nxeNFy5IwO2PvNcVsY,6788
|
|
6
|
+
memcore/integrations/llama_index.py,sha256=9evACVho1KcS0pm-0ZAGK68gHNB1J63m3-n8iA5Is5c,7554
|
|
7
|
+
memcore_sdk-0.3.0.dist-info/METADATA,sha256=WOSZv-AHudYoT5BVWsFZfRuJ8a4rF8CZWDfFXGpOe5k,5086
|
|
8
|
+
memcore_sdk-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
memcore_sdk-0.3.0.dist-info/RECORD,,
|