dsh-mem0-memory 0.1.0
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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.en.md +270 -0
- package/README.md +272 -0
- package/bin/dsh-mem0-import.mjs +211 -0
- package/bin/dsh-mem0-search.mjs +124 -0
- package/cordis.patch.yml +4 -0
- package/excluded-workspaces.example.json +4 -0
- package/lib/index.js +299 -0
- package/package.json +38 -0
- package/scripts/.env.example +22 -0
- package/scripts/excluded-workspaces.json +4 -0
- package/scripts/g1-session-parser.mjs +816 -0
- package/scripts/mem0-server.py +743 -0
- package/scripts/setup.py +188 -0
- package/scripts/start-server.bat +47 -0
- package/scripts/start-server.sh +44 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
mem0-server.py — Mem0 常驻 HTTP 服务(dsh-mem0-memory 三件套之一)
|
|
5
|
+
|
|
6
|
+
启动时初始化一次 Memory(),后续通过 HTTP 请求调用。
|
|
7
|
+
(避免每次 spawn Python 都要重新加载向量库。)
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
python mem0-server.py [--port 18200] [--host 127.0.0.1] [--data-dir ./mem0_store] [--env-file ./.env]
|
|
11
|
+
|
|
12
|
+
端点:
|
|
13
|
+
POST /search {"query": "...", "limit": 20, "date_from": null, "date_to": null, "source": null}
|
|
14
|
+
POST /add {"content": "...", "timestamp": null, "infer": true}
|
|
15
|
+
POST /import {"messages": [{role, content}, ...], "source": null, "timestamp": null, "infer": true}
|
|
16
|
+
GET /health
|
|
17
|
+
|
|
18
|
+
配置优先级:环境变量 > .env 文件(--env-file,默认 ./.env)> 内置默认值。
|
|
19
|
+
|
|
20
|
+
环境变量:
|
|
21
|
+
MEM0_DATA_DIR 向量库数据目录(默认 --data-dir,再默认 ./mem0_store)
|
|
22
|
+
MEM0_USER_ID 记忆归属 user_id(默认 "default")
|
|
23
|
+
MEM0_LLM_PROVIDER LLM provider(默认 deepseek;可换 openai 等 mem0 支持值)
|
|
24
|
+
MEM0_LLM_MODEL LLM 模型(默认 deepseek-v4-flash)
|
|
25
|
+
MEM0_LLM_BASE_URL LLM base URL(默认 https://api.deepseek.com/v1)
|
|
26
|
+
MEM0_LLM_API_KEY LLM API key(兼容旧名 DEEPSEEK_API_KEY)
|
|
27
|
+
MEM0_EMBEDDING_MODEL embedding 模型(默认 Qwen/Qwen3-Embedding-8B,中文推荐)
|
|
28
|
+
MEM0_EMBEDDING_BASE_URL embedding API base URL(默认 https://api.siliconflow.cn/v1)
|
|
29
|
+
MEM0_EMBEDDING_API_KEY embedding API key(兼容旧名 SILICONFLOW_API_KEY)
|
|
30
|
+
MEM0_EMBEDDING_DIMS embedding 维度(默认 4096;首次建库后不可更改)
|
|
31
|
+
|
|
32
|
+
内置定制(随服务启动自动应用,不修改 mem0 安装源码,升级不丢):
|
|
33
|
+
- jieba 中文分词 BM25 补丁(monkey-patch lemmatize_for_bm25 ×3 引用点;失败仅 WARN)
|
|
34
|
+
- 提取语言补丁(use_input_language=True——中文内容不被翻译成英文事实;失败仅 WARN)
|
|
35
|
+
- DeepSeek 补丁(thinking=disabled 防 0 事实入库 + 去 response_format 防 v4 flash 空内容;失败仅 WARN)
|
|
36
|
+
- API client 超时补丁(embedding/deepseek 120s,对齐生产实测;失败仅 WARN)
|
|
37
|
+
- 检索增强:hybrid + 关键词补捞 topup + embedding 故障降级(keyword fallback)
|
|
38
|
+
- G4 source 后置过滤(Qdrant local payload filter 不可靠,Python 层精确筛)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import argparse
|
|
42
|
+
import io
|
|
43
|
+
import os
|
|
44
|
+
import sys
|
|
45
|
+
import time
|
|
46
|
+
import warnings
|
|
47
|
+
import threading
|
|
48
|
+
|
|
49
|
+
os.environ.setdefault("MEM0_TELEMETRY", "False")
|
|
50
|
+
os.environ.setdefault("POSTHOG_DISABLED", "1")
|
|
51
|
+
warnings.filterwarnings("ignore")
|
|
52
|
+
|
|
53
|
+
# Fix encoding on Windows
|
|
54
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
55
|
+
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
|
56
|
+
|
|
57
|
+
from flask import Flask, request, jsonify
|
|
58
|
+
from mem0 import Memory
|
|
59
|
+
from mem0.configs.base import MemoryConfig
|
|
60
|
+
|
|
61
|
+
# --- Config(env > .env > 默认值)---
|
|
62
|
+
ENV_FILE = os.environ.get("MEM0_ENV_FILE", os.path.join(os.getcwd(), ".env"))
|
|
63
|
+
DATA_DIR = os.environ.get("MEM0_DATA_DIR", os.path.join(os.getcwd(), "mem0_store"))
|
|
64
|
+
_USER_ID = None # 延迟求值:必须在 load_dotenv() 之后读取,否则 .env 的 MEM0_USER_ID 不生效
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_user_id():
|
|
68
|
+
"""记忆归属 user_id(env/.env 的 MEM0_USER_ID,默认 "default")。"""
|
|
69
|
+
global _USER_ID
|
|
70
|
+
if _USER_ID is None:
|
|
71
|
+
_USER_ID = os.environ.get("MEM0_USER_ID", "default")
|
|
72
|
+
return _USER_ID
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- Globals ---
|
|
76
|
+
memory_instance = None
|
|
77
|
+
init_error = None
|
|
78
|
+
app = Flask(__name__)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_dotenv(path):
|
|
82
|
+
"""极简 .env 读取(不引入 python-dotenv 依赖):KEY=VALUE 行,忽略注释/空行。
|
|
83
|
+
已存在的环境变量优先(不覆盖)。文件不存在时静默跳过。"""
|
|
84
|
+
try:
|
|
85
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
86
|
+
for line in f:
|
|
87
|
+
line = line.strip()
|
|
88
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
89
|
+
continue
|
|
90
|
+
k, _, v = line.partition("=")
|
|
91
|
+
k = k.strip()
|
|
92
|
+
v = v.strip().strip('"').strip("'")
|
|
93
|
+
if k and k not in os.environ:
|
|
94
|
+
os.environ[k] = v
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
pass
|
|
97
|
+
except Exception as e:
|
|
98
|
+
print(f"[mem0-server] WARN: .env 读取失败({path}): {e}", flush=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def read_key(*names):
|
|
102
|
+
"""按序取第一个存在的环境变量值(.env 已并入 os.environ)。"""
|
|
103
|
+
for n in names:
|
|
104
|
+
v = os.environ.get(n, "")
|
|
105
|
+
if v:
|
|
106
|
+
return v
|
|
107
|
+
return ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_llm_config():
|
|
111
|
+
provider = os.environ.get("MEM0_LLM_PROVIDER", "deepseek")
|
|
112
|
+
cfg = {
|
|
113
|
+
"model": os.environ.get("MEM0_LLM_MODEL", "deepseek-v4-flash"),
|
|
114
|
+
"api_key": read_key("MEM0_LLM_API_KEY", "DEEPSEEK_API_KEY"),
|
|
115
|
+
"temperature": 0.1,
|
|
116
|
+
"top_p": 1.0,
|
|
117
|
+
"max_tokens": 8000,
|
|
118
|
+
}
|
|
119
|
+
base_url = os.environ.get("MEM0_LLM_BASE_URL")
|
|
120
|
+
if base_url:
|
|
121
|
+
cfg["deepseek_base_url" if provider == "deepseek" else "openai_base_url"] = base_url
|
|
122
|
+
elif provider == "deepseek":
|
|
123
|
+
cfg["deepseek_base_url"] = "https://api.deepseek.com/v1"
|
|
124
|
+
return provider, cfg
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_embedder_config():
|
|
128
|
+
return {
|
|
129
|
+
"model": os.environ.get("MEM0_EMBEDDING_MODEL", "Qwen/Qwen3-Embedding-8B"),
|
|
130
|
+
"openai_base_url": os.environ.get("MEM0_EMBEDDING_BASE_URL", "https://api.siliconflow.cn/v1"),
|
|
131
|
+
"api_key": read_key("MEM0_EMBEDDING_API_KEY", "SILICONFLOW_API_KEY"),
|
|
132
|
+
"embedding_dims": int(os.environ.get("MEM0_EMBEDDING_DIMS", "4096")),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def apply_jieba_patch():
|
|
137
|
+
"""中文 BM25 分词补丁(运行时 monkey-patch,不修改 mem0 安装源码)。
|
|
138
|
+
|
|
139
|
+
把 mem0 默认的 spaCy 英文 lemmatizer 替换为 jieba 中文分词——中文关键词
|
|
140
|
+
(专有名词/ID/路径)才能在 BM25 通道精确命中(mem0 混合检索每次搜索都会
|
|
141
|
+
调用 lemmatize_for_bm25)。
|
|
142
|
+
注意:必须替换**全部引用点**——mem0 的 memory/main.py 与 utils/scoring.py
|
|
143
|
+
都在模块顶部 `from ... import lemmatize_for_bm25` 绑定,只 patch 定义处
|
|
144
|
+
无法覆盖写入侧 text_lemmatized 与 topup 归一化(实测:写入侧未分词 →
|
|
145
|
+
SPLADE 对连续中文编码失效 → BM25 0 命中)。jieba 未安装或 patch 失败 →
|
|
146
|
+
仅 WARN 降级,绝不中断服务启动。
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
import jieba
|
|
150
|
+
import mem0.utils.lemmatization as lemm
|
|
151
|
+
import mem0.memory.main as main_mod
|
|
152
|
+
import mem0.utils.scoring as scoring_mod
|
|
153
|
+
|
|
154
|
+
_PUNCT = set(" \t\n\r,。!?;:、\"''()【】《》…—·~")
|
|
155
|
+
|
|
156
|
+
def _zh_lemmatize(text):
|
|
157
|
+
try:
|
|
158
|
+
tokens = [t for t in jieba.cut(text) if t.strip() and not all(ch in _PUNCT for ch in t)]
|
|
159
|
+
return " ".join(tokens)
|
|
160
|
+
except Exception:
|
|
161
|
+
return text
|
|
162
|
+
|
|
163
|
+
lemm.lemmatize_for_bm25 = _zh_lemmatize
|
|
164
|
+
if getattr(main_mod, "lemmatize_for_bm25", None) is not _zh_lemmatize:
|
|
165
|
+
main_mod.lemmatize_for_bm25 = _zh_lemmatize
|
|
166
|
+
if getattr(scoring_mod, "lemmatize_for_bm25", None) is not _zh_lemmatize:
|
|
167
|
+
scoring_mod.lemmatize_for_bm25 = _zh_lemmatize
|
|
168
|
+
print("[mem0-server] jieba 中文分词补丁已启用(monkey-patch lemmatize_for_bm25 ×3 引用点)", flush=True)
|
|
169
|
+
return True
|
|
170
|
+
except ImportError:
|
|
171
|
+
print("[mem0-server] WARN: jieba 未安装,BM25 中文分词降级为默认行为(pip install jieba)", flush=True)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
print(f"[mem0-server] WARN: jieba 补丁应用失败({e}),继续使用默认行为", flush=True)
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def apply_language_patch():
|
|
178
|
+
"""提取语言补丁(monkey-patch,不修改 mem0 安装源码)。
|
|
179
|
+
|
|
180
|
+
问题:mem0 默认把输入内容提取成**英文**事实——中文对话会被 LLM 翻译成
|
|
181
|
+
英文落库(实测),中文关键词检索(BM25 通道)随之失效。
|
|
182
|
+
修复:mem0 2.x 的 generate_additive_extraction_prompt 原生支持
|
|
183
|
+
use_input_language=True(prompt 追加 Language Requirement 段),只是
|
|
184
|
+
memory/main.py 调用处默认不传。这里包装该函数强制开启。
|
|
185
|
+
注:main.py 在模块顶部 from ... import 绑定,需同步替换模块内名字。
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
import mem0.configs.prompts as prompts_mod
|
|
189
|
+
import mem0.memory.main as main_mod
|
|
190
|
+
|
|
191
|
+
_orig = prompts_mod.generate_additive_extraction_prompt
|
|
192
|
+
|
|
193
|
+
def _patched(*args, **kwargs):
|
|
194
|
+
kwargs["use_input_language"] = True
|
|
195
|
+
return _orig(*args, **kwargs)
|
|
196
|
+
|
|
197
|
+
prompts_mod.generate_additive_extraction_prompt = _patched
|
|
198
|
+
if getattr(main_mod, "generate_additive_extraction_prompt", None) is _orig:
|
|
199
|
+
main_mod.generate_additive_extraction_prompt = _patched
|
|
200
|
+
print("[mem0-server] 提取语言补丁已启用(use_input_language=True,事实按输入语言提取)", flush=True)
|
|
201
|
+
return True
|
|
202
|
+
except Exception as e:
|
|
203
|
+
print(f"[mem0-server] WARN: 提取语言补丁应用失败({e}),事实可能被提取为英文", flush=True)
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def apply_deepseek_patch():
|
|
208
|
+
"""DeepSeek 补丁(monkey-patch,不修改 mem0 安装源码)。
|
|
209
|
+
|
|
210
|
+
两个问题(均取自生产环境实测排雷):
|
|
211
|
+
① deepseek-v4-flash 默认 thinking=enabled,会把 max_tokens 预算全部烧在
|
|
212
|
+
reasoning 上(finish_reason=length, content=""),mem0 静默视为
|
|
213
|
+
「0 事实提取」(add=0)→ 请求带 extra_body={"thinking": {"type": "disabled"}}。
|
|
214
|
+
② deepseek-v4-flash 在 response_format=json_object + 长 prompt 时会返回
|
|
215
|
+
空内容(生产实测注释)→ **不传递 response_format**(依赖 prompt 内的
|
|
216
|
+
JSON 示例,main.py 侧用 json.loads 解析)。
|
|
217
|
+
方法体复制自 mem0 2.0.7 llms/deepseek.py 的 generate_response,
|
|
218
|
+
按生产验证过的行为调整;mem0 升级后需同步核对。
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
import mem0.llms.deepseek as ds
|
|
222
|
+
|
|
223
|
+
def _patched(self, messages, response_format=None, tools=None, tool_choice="auto", **kwargs):
|
|
224
|
+
params = self._get_supported_params(messages=messages, **kwargs)
|
|
225
|
+
params.update(
|
|
226
|
+
{
|
|
227
|
+
"model": self.config.model,
|
|
228
|
+
"messages": messages,
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
# [mem0-server patch] 不传 response_format:deepseek-v4-flash 在
|
|
232
|
+
# json_object + 长 prompt 下返回空内容(生产实测),依赖 prompt 内 JSON 示例
|
|
233
|
+
if tools:
|
|
234
|
+
params["tools"] = tools
|
|
235
|
+
params["tool_choice"] = tool_choice
|
|
236
|
+
# [mem0-server patch] 强制关闭 thinking:机械提取任务不需要链式思考,
|
|
237
|
+
# 且 deepseek-v4-flash 默认 thinking 会烧光 max_tokens 导致 0 事实入库
|
|
238
|
+
params["extra_body"] = {"thinking": {"type": "disabled"}}
|
|
239
|
+
response = self.client.chat.completions.create(**params)
|
|
240
|
+
return self._parse_response(response, tools)
|
|
241
|
+
|
|
242
|
+
ds.DeepSeekLLM.generate_response = _patched
|
|
243
|
+
print("[mem0-server] DeepSeek 补丁已启用(thinking=disabled + 去 response_format)", flush=True)
|
|
244
|
+
return True
|
|
245
|
+
except Exception as e:
|
|
246
|
+
print(f"[mem0-server] WARN: DeepSeek 补丁应用失败({e}),DeepSeek 提取可能异常", flush=True)
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def apply_timeout_patch():
|
|
251
|
+
"""API client 超时补丁(monkey-patch,不修改 mem0 安装源码)。
|
|
252
|
+
|
|
253
|
+
mem0 官方构造 OpenAI client 时不设 timeout(默认 600s),embedding/LLM
|
|
254
|
+
API 长时间无响应会拖住 /search 与 /add 线程。生产环境实测将其收紧为
|
|
255
|
+
120s(对齐生产行为)。仅包装 __init__ 后设置 client.timeout,失败静默。
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
import httpx
|
|
259
|
+
|
|
260
|
+
import mem0.embeddings.openai as emb
|
|
261
|
+
import mem0.llms.deepseek as ds
|
|
262
|
+
|
|
263
|
+
def _wrap_init(cls):
|
|
264
|
+
_orig = cls.__init__
|
|
265
|
+
|
|
266
|
+
def _patched(self, *args, **kwargs):
|
|
267
|
+
_orig(self, *args, **kwargs)
|
|
268
|
+
try:
|
|
269
|
+
self.client.timeout = httpx.Timeout(120.0)
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
cls.__init__ = _patched
|
|
274
|
+
|
|
275
|
+
_wrap_init(emb.OpenAIEmbedding)
|
|
276
|
+
_wrap_init(ds.DeepSeekLLM)
|
|
277
|
+
print("[mem0-server] client 超时补丁已启用(embedding/deepseek 120s)", flush=True)
|
|
278
|
+
return True
|
|
279
|
+
except Exception as e:
|
|
280
|
+
print(f"[mem0-server] WARN: client 超时补丁应用失败({e})", flush=True)
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def init_memory():
|
|
285
|
+
"""Initialize Memory instance (called once at startup)."""
|
|
286
|
+
global memory_instance, init_error
|
|
287
|
+
if memory_instance is not None:
|
|
288
|
+
return memory_instance
|
|
289
|
+
try:
|
|
290
|
+
print(f"[mem0-server] Initializing Memory()...", flush=True)
|
|
291
|
+
t0 = time.time()
|
|
292
|
+
|
|
293
|
+
llm_provider, llm_config = build_llm_config()
|
|
294
|
+
embedder_config = build_embedder_config()
|
|
295
|
+
embedding_dims = embedder_config["embedding_dims"]
|
|
296
|
+
|
|
297
|
+
config = MemoryConfig(
|
|
298
|
+
llm={
|
|
299
|
+
"provider": llm_provider,
|
|
300
|
+
"config": llm_config,
|
|
301
|
+
},
|
|
302
|
+
embedder={
|
|
303
|
+
"provider": "openai",
|
|
304
|
+
"config": embedder_config,
|
|
305
|
+
},
|
|
306
|
+
vector_store={
|
|
307
|
+
"provider": "qdrant",
|
|
308
|
+
"config": {
|
|
309
|
+
"path": DATA_DIR,
|
|
310
|
+
"embedding_model_dims": embedding_dims,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
history_db_path=os.path.join(DATA_DIR, "history.db"),
|
|
314
|
+
version="v1.1",
|
|
315
|
+
)
|
|
316
|
+
memory_instance = Memory(config)
|
|
317
|
+
elapsed = time.time() - t0
|
|
318
|
+
print(f"[mem0-server] Memory() initialized in {elapsed:.1f}s", flush=True)
|
|
319
|
+
return memory_instance
|
|
320
|
+
except Exception as e:
|
|
321
|
+
init_error = str(e)
|
|
322
|
+
print(f"[mem0-server] Memory() init failed: {e}", flush=True)
|
|
323
|
+
raise
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --- Lock cleanup ---
|
|
327
|
+
def clean_lock():
|
|
328
|
+
try:
|
|
329
|
+
lock_file = os.path.join(DATA_DIR, ".lock")
|
|
330
|
+
if os.path.exists(lock_file):
|
|
331
|
+
os.remove(lock_file)
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# --- Routes ---
|
|
337
|
+
|
|
338
|
+
@app.route("/health", methods=["GET"])
|
|
339
|
+
def health():
|
|
340
|
+
if memory_instance is None:
|
|
341
|
+
if init_error:
|
|
342
|
+
return jsonify({"ok": False, "status": "init_failed", "error": init_error[:300]})
|
|
343
|
+
return jsonify({"ok": True, "status": "initializing"})
|
|
344
|
+
# Lightweight: only verify the Memory instance exists. Do not run a real search
|
|
345
|
+
# here; concurrent long searches can block the embedding model / Qdrant client and
|
|
346
|
+
# cause the watchdog health check to time out, which kills the server.
|
|
347
|
+
return jsonify({"ok": True, "status": "healthy", "sample_results": 0})
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.route("/search", methods=["POST"])
|
|
351
|
+
def search():
|
|
352
|
+
if memory_instance is None:
|
|
353
|
+
return jsonify({"ok": False, "error": "Memory not initialized"}), 503
|
|
354
|
+
|
|
355
|
+
data = request.get_json(force=True)
|
|
356
|
+
query = data.get("query", "")
|
|
357
|
+
limit = data.get("limit", 20)
|
|
358
|
+
date_from = data.get("date_from")
|
|
359
|
+
date_to = data.get("date_to")
|
|
360
|
+
fact_type = data.get("fact_type") # optional: "identity"|"preference"|"decision"|"relationship"|"emotional_landmark"
|
|
361
|
+
source = data.get("source") # 2026-08-19 G4: optional "DSH"(G1 导入)/ "supplemental-extraction" 等
|
|
362
|
+
|
|
363
|
+
if not query:
|
|
364
|
+
return jsonify({"ok": False, "error": "query is required"}), 400
|
|
365
|
+
|
|
366
|
+
# build base filters
|
|
367
|
+
filters = {"user_id": get_user_id()}
|
|
368
|
+
if fact_type:
|
|
369
|
+
filters["fact_type"] = fact_type
|
|
370
|
+
# 2026-08-19 G4 修复:source 不放进 mem0 filters!
|
|
371
|
+
# Qdrant local 的 payload filter 有限(payload 索引无效 + 缺失字段不被正确排除,
|
|
372
|
+
# 详见 mem0 库内 2026-07-06 记录的三大限制),source 过滤改为 Python 层精确筛
|
|
373
|
+
# (复用 date-filter 的成熟模式)。若放进 filters,空 source 记录会混入结果。
|
|
374
|
+
|
|
375
|
+
start = time.time()
|
|
376
|
+
|
|
377
|
+
degraded = False
|
|
378
|
+
mode = "hybrid"
|
|
379
|
+
topup = 0
|
|
380
|
+
try:
|
|
381
|
+
try:
|
|
382
|
+
if not date_from and not date_to:
|
|
383
|
+
if source:
|
|
384
|
+
# 先放大 top_k 查询(source 筛完可能不足 limit),再 Python 层精确过滤
|
|
385
|
+
# 注意:mem0 Memory.search 参数名是 top_k(不是 limit,limit 会被
|
|
386
|
+
# **kwargs 吞掉——2026-08-19 实测发现的历史 bug,此前放大从未生效)
|
|
387
|
+
res = memory_instance.search(query, filters=filters, top_k=max(limit * 5, 100))
|
|
388
|
+
res = _post_filter_by_source(res, source, limit)
|
|
389
|
+
res, topup = _topup_with_keyword(res, query, filters, limit, source=source)
|
|
390
|
+
else:
|
|
391
|
+
res = memory_instance.search(query, filters=filters, top_k=limit)
|
|
392
|
+
res, topup = _topup_with_keyword(res, query, filters, limit)
|
|
393
|
+
else:
|
|
394
|
+
# Date-filtered search: scroll + re-rank (from bridge.py logic)
|
|
395
|
+
res = _search_with_date_filter(query, limit, date_from, date_to, fact_type=fact_type, source=source)
|
|
396
|
+
except Exception as e:
|
|
397
|
+
# 2026-08-19:dense embedding 故障降级 → 本地 BM25 关键词通道(离线可用)。
|
|
398
|
+
# 触发条件:embedding API 不可用/超时(主路径抛异常)。降级模式不做日期过滤。
|
|
399
|
+
try:
|
|
400
|
+
res = _search_keyword_fallback(query, filters, limit, source=source)
|
|
401
|
+
degraded = True
|
|
402
|
+
mode = "keyword"
|
|
403
|
+
print(f"[mem0-server] /search degraded → keyword fallback (primary: {str(e)[:150]})", flush=True)
|
|
404
|
+
except Exception:
|
|
405
|
+
raise e
|
|
406
|
+
if topup:
|
|
407
|
+
print(f"[mem0-server] /search keyword top-up: +{topup} (hybrid {mode} path)", flush=True)
|
|
408
|
+
|
|
409
|
+
elapsed = (time.time() - start) * 1000
|
|
410
|
+
results = [
|
|
411
|
+
{
|
|
412
|
+
"id": r.get("id"),
|
|
413
|
+
"memory": r.get("memory"),
|
|
414
|
+
"score": r.get("score"),
|
|
415
|
+
"metadata": r.get("metadata", {}),
|
|
416
|
+
}
|
|
417
|
+
for r in res.get("results", [])
|
|
418
|
+
]
|
|
419
|
+
return jsonify({
|
|
420
|
+
"ok": True,
|
|
421
|
+
"query": query,
|
|
422
|
+
"count": len(results),
|
|
423
|
+
"elapsed_ms": round(elapsed, 2),
|
|
424
|
+
"degraded": degraded,
|
|
425
|
+
"mode": mode,
|
|
426
|
+
"topup": topup,
|
|
427
|
+
"results": results,
|
|
428
|
+
})
|
|
429
|
+
except Exception as e:
|
|
430
|
+
elapsed = (time.time() - start) * 1000
|
|
431
|
+
return jsonify({"ok": False, "error": str(e)[:300], "elapsed_ms": round(elapsed, 2)})
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@app.route("/add", methods=["POST"])
|
|
435
|
+
def add():
|
|
436
|
+
if memory_instance is None:
|
|
437
|
+
return jsonify({"ok": False, "error": "Memory not initialized"}), 503
|
|
438
|
+
|
|
439
|
+
data = request.get_json(force=True)
|
|
440
|
+
content = data.get("content", "")
|
|
441
|
+
timestamp = data.get("timestamp")
|
|
442
|
+
infer = data.get("infer", True)
|
|
443
|
+
|
|
444
|
+
if not content:
|
|
445
|
+
return jsonify({"ok": False, "error": "content is required"}), 400
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
metadata = {"user_id": get_user_id()}
|
|
449
|
+
if timestamp:
|
|
450
|
+
metadata["timestamp"] = timestamp
|
|
451
|
+
|
|
452
|
+
# mem0 v2 API: Memory.add(messages=[...], user_id=..., metadata=..., infer=...)
|
|
453
|
+
# (v1 的 data= 已移除;user_id 是独立关键字参数,不在 metadata 里)
|
|
454
|
+
kwargs = {"messages": [{"role": "user", "content": content}], "user_id": get_user_id(), "metadata": metadata}
|
|
455
|
+
if not infer:
|
|
456
|
+
kwargs["infer"] = False
|
|
457
|
+
|
|
458
|
+
result = memory_instance.add(**kwargs)
|
|
459
|
+
return jsonify({"ok": True, "result": result})
|
|
460
|
+
except Exception as e:
|
|
461
|
+
return jsonify({"ok": False, "error": str(e)[:300]})
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@app.route("/import", methods=["POST"])
|
|
465
|
+
def import_batch():
|
|
466
|
+
"""导入对话批次(P2.2,通用导入链的入库端)。
|
|
467
|
+
|
|
468
|
+
body: {"messages": [{role, content}, ...], "source": "DSH"|null,
|
|
469
|
+
"timestamp": null, "infer": true}
|
|
470
|
+
一次调用 = 一个对话批次 → memory.add(messages)(mem0 v2 原生多轮语义)。
|
|
471
|
+
结构校验复用生产 daemon 的 structure guard 模式(防外来 JSON 文件把
|
|
472
|
+
memory.add 打炸);source 由调用方显式传入(发布版只有 DSH 来源)。
|
|
473
|
+
"""
|
|
474
|
+
if memory_instance is None:
|
|
475
|
+
return jsonify({"ok": False, "error": "Memory not initialized"}), 503
|
|
476
|
+
|
|
477
|
+
data = request.get_json(force=True)
|
|
478
|
+
messages = data.get("messages")
|
|
479
|
+
source = data.get("source")
|
|
480
|
+
timestamp = data.get("timestamp")
|
|
481
|
+
infer = data.get("infer", True)
|
|
482
|
+
|
|
483
|
+
if not isinstance(messages, list) or len(messages) == 0:
|
|
484
|
+
return jsonify({"ok": False, "error": "messages (non-empty array) is required"}), 400
|
|
485
|
+
# structure guard(生产 daemon 模式):每个元素必须是含 role/content 的消息 dict
|
|
486
|
+
for item in messages:
|
|
487
|
+
if not isinstance(item, dict) or not item.get("role") or not item.get("content"):
|
|
488
|
+
return jsonify({"ok": False, "error": "invalid message structure (need {role, content} dicts)"}), 400
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
metadata = {"user_id": get_user_id()}
|
|
492
|
+
if source:
|
|
493
|
+
metadata["source"] = source
|
|
494
|
+
if timestamp:
|
|
495
|
+
metadata["timestamp"] = timestamp
|
|
496
|
+
|
|
497
|
+
kwargs = {"messages": messages, "user_id": get_user_id(), "metadata": metadata}
|
|
498
|
+
if not infer:
|
|
499
|
+
kwargs["infer"] = False
|
|
500
|
+
|
|
501
|
+
result = memory_instance.add(**kwargs)
|
|
502
|
+
results = (result or {}).get("results", [])
|
|
503
|
+
add_count = sum(1 for r in results if isinstance(r, dict) and r.get("event") == "ADD")
|
|
504
|
+
return jsonify({"ok": True, "result": result, "add_count": add_count, "total": len(results)})
|
|
505
|
+
except Exception as e:
|
|
506
|
+
return jsonify({"ok": False, "error": str(e)[:300]})
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _dense_vector(point):
|
|
510
|
+
"""Extract the dense vector from a Qdrant point, ignoring sparse vectors like 'bm25'."""
|
|
511
|
+
import numpy as np
|
|
512
|
+
v = point.vector
|
|
513
|
+
if v is None:
|
|
514
|
+
return None
|
|
515
|
+
# Single default (unnamed) vector
|
|
516
|
+
if isinstance(v, (list, tuple)):
|
|
517
|
+
return np.asarray(v, dtype=np.float32)
|
|
518
|
+
# Named vectors: prefer non-sparse vector names
|
|
519
|
+
if isinstance(v, dict):
|
|
520
|
+
for name, vec in v.items():
|
|
521
|
+
if name != "bm25" and isinstance(vec, (list, tuple)):
|
|
522
|
+
return np.asarray(vec, dtype=np.float32)
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _post_filter_by_source(res, source, limit):
|
|
527
|
+
"""Python 层按 metadata.source 精确过滤(2026-08-19 G4 修复)。
|
|
528
|
+
|
|
529
|
+
Qdrant local 的 payload filter 不可靠(缺失字段不被正确排除、payload 索引无效),
|
|
530
|
+
source 过滤必须走 Python 层——与 date-filter 的 scroll+重排模式同构。
|
|
531
|
+
从已查询的候选(res.results)中筛出 source 精确匹配的,取前 limit 条。
|
|
532
|
+
"""
|
|
533
|
+
if not source:
|
|
534
|
+
return res
|
|
535
|
+
filtered = [r for r in res.get("results", []) if (r.get("metadata") or {}).get("source") == source]
|
|
536
|
+
res["results"] = filtered[:limit]
|
|
537
|
+
res["count"] = len(res["results"])
|
|
538
|
+
return res
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _search_keyword_fallback(query, filters, limit, source=None):
|
|
542
|
+
"""Embedding API 故障降级:本地 BM25 关键词通道(2026-08-19)。
|
|
543
|
+
|
|
544
|
+
dense embedding 依赖外部 API(SiliconFlow),故障时主路径抛异常。
|
|
545
|
+
本函数用 mem0 内置 keyword_search(fastembed Qdrant/bm25 本地模型 +
|
|
546
|
+
jieba 中文分词补丁,均离线可用)做 sparse-only 检索——关键词硬匹配兜底。
|
|
547
|
+
结果在 /search 响应中带 degraded/mode 标记,调用方(DSH bridge /
|
|
548
|
+
OpenClaw mem0_search)可据此提示当前为降级模式。
|
|
549
|
+
注意:降级模式不支持日期过滤(Qdrant local 无 range filter,且无 dense
|
|
550
|
+
可重排);date_from/date_to 在降级时被忽略,由响应标记明示。
|
|
551
|
+
"""
|
|
552
|
+
from mem0.utils.lemmatization import lemmatize_for_bm25
|
|
553
|
+
|
|
554
|
+
vs = memory_instance.vector_store
|
|
555
|
+
topk = max(limit * 5, 100) if source else max(limit, 10)
|
|
556
|
+
hits = vs.keyword_search(lemmatize_for_bm25(query), top_k=topk, filters=filters)
|
|
557
|
+
if hits is None:
|
|
558
|
+
raise RuntimeError("BM25 keyword search unavailable (collection 无 bm25 槽位或 fastembed 缺失)")
|
|
559
|
+
|
|
560
|
+
results = []
|
|
561
|
+
for p in hits:
|
|
562
|
+
payload = p.payload or {}
|
|
563
|
+
# mem0 2.x payload 为扁平结构:事实文本在 "data",source/timestamp/fact_type
|
|
564
|
+
# 等平铺在顶层(不在 "metadata" 子对象)——与 hybrid 路径返回的
|
|
565
|
+
# {id, memory, score, metadata} 形状对齐,供 _post_filter_by_source 与调用方使用
|
|
566
|
+
results.append({
|
|
567
|
+
"id": str(p.id),
|
|
568
|
+
"memory": payload.get("data", ""),
|
|
569
|
+
"score": float(p.score),
|
|
570
|
+
"metadata": dict(payload),
|
|
571
|
+
})
|
|
572
|
+
res = {"results": results, "count": len(results)}
|
|
573
|
+
if source:
|
|
574
|
+
res = _post_filter_by_source(res, source, limit)
|
|
575
|
+
else:
|
|
576
|
+
res["results"] = res["results"][:limit]
|
|
577
|
+
res["count"] = len(res["results"])
|
|
578
|
+
return res
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _topup_with_keyword(res, query, filters, limit, source=None):
|
|
582
|
+
"""混合检索补捞(2026-08-19):mem0 标准 hybrid 的候选集只来自语义侧
|
|
583
|
+
(BM25 只能给语义候选加分,纯关键词命中的记忆会被丢弃)。当结果不足
|
|
584
|
+
limit 时,用本地 BM25 关键词检索补捞,按 id 去重合并。
|
|
585
|
+
关键词分用 mem0 自己的 sigmoid 归一化(normalize_bm25)到 [0,1],
|
|
586
|
+
与 hybrid 分数同尺度排序。返回 (res, 补捞条数)。
|
|
587
|
+
"""
|
|
588
|
+
cur = res.get("results", [])
|
|
589
|
+
if len(cur) >= limit:
|
|
590
|
+
return res, 0
|
|
591
|
+
try:
|
|
592
|
+
# 检索深度取大:短查询(如「装配壳」)的 BM25 命中可能排在较后,
|
|
593
|
+
# 且 source 后置过滤会再砍一层——topk 太小会补捞失败(2026-08-19 实测)
|
|
594
|
+
kw = _search_keyword_fallback(query, filters, max(limit * 10, 50), source=source)
|
|
595
|
+
from mem0.utils.scoring import get_bm25_params, normalize_bm25
|
|
596
|
+
midpoint, steepness = get_bm25_params(query)
|
|
597
|
+
seen = {str(r.get("id")) for r in cur}
|
|
598
|
+
added = 0
|
|
599
|
+
for r in kw.get("results", []):
|
|
600
|
+
if str(r.get("id")) in seen:
|
|
601
|
+
continue
|
|
602
|
+
raw = r.get("score") or 0.0
|
|
603
|
+
r["score"] = normalize_bm25(raw, midpoint, steepness)
|
|
604
|
+
cur.append(r)
|
|
605
|
+
added += 1
|
|
606
|
+
except Exception:
|
|
607
|
+
return res, 0
|
|
608
|
+
if added == 0:
|
|
609
|
+
return res, 0
|
|
610
|
+
cur.sort(key=lambda x: x.get("score") or 0.0, reverse=True)
|
|
611
|
+
res["results"] = cur[:limit]
|
|
612
|
+
res["count"] = len(res["results"])
|
|
613
|
+
return res, added
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _search_with_date_filter(query, limit, date_from=None, date_to=None, fact_type=None, source=None):
|
|
617
|
+
"""Date-filtered search using stored vectors + Python re-rank.
|
|
618
|
+
|
|
619
|
+
Optimization: instead of re-embedding every candidate in Python (which
|
|
620
|
+
called the embedding model N times and dominated query latency), we scroll
|
|
621
|
+
with the vectors already stored in Qdrant and compute cosine similarities
|
|
622
|
+
in one batched numpy operation. This keeps the workaround for Qdrant local's
|
|
623
|
+
lack of string range filters while removing the embedding bottleneck.
|
|
624
|
+
"""
|
|
625
|
+
import numpy as np
|
|
626
|
+
from qdrant_client.models import Filter, FieldCondition, MatchValue
|
|
627
|
+
|
|
628
|
+
collection_name = "mem0"
|
|
629
|
+
must_conditions = [FieldCondition(key="user_id", match=MatchValue(value=get_user_id()))]
|
|
630
|
+
if fact_type:
|
|
631
|
+
must_conditions.append(FieldCondition(key="metadata.fact_type", match=MatchValue(value=fact_type)))
|
|
632
|
+
# 2026-08-19 G4 修复:source 不放进 Qdrant FieldCondition(local 模式不可靠),
|
|
633
|
+
# 改为下方 Python 循环内精确匹配(与 date 过滤同层)。
|
|
634
|
+
timestamp_filter = Filter(must=must_conditions)
|
|
635
|
+
|
|
636
|
+
# Embed the query once
|
|
637
|
+
query_embedding = np.asarray(
|
|
638
|
+
memory_instance.embedding_model.embed(query),
|
|
639
|
+
dtype=np.float32,
|
|
640
|
+
)
|
|
641
|
+
query_norm = np.linalg.norm(query_embedding)
|
|
642
|
+
|
|
643
|
+
results = []
|
|
644
|
+
vectors = []
|
|
645
|
+
offset = None
|
|
646
|
+
while True:
|
|
647
|
+
batch = memory_instance.vector_store.client.scroll(
|
|
648
|
+
collection_name=collection_name,
|
|
649
|
+
scroll_filter=timestamp_filter,
|
|
650
|
+
limit=1000,
|
|
651
|
+
offset=offset,
|
|
652
|
+
with_payload=True,
|
|
653
|
+
with_vectors=True, # use stored vectors, avoid re-embedding
|
|
654
|
+
)
|
|
655
|
+
points = batch[0]
|
|
656
|
+
offset = batch[1]
|
|
657
|
+
for p in points:
|
|
658
|
+
meta = p.payload or {}
|
|
659
|
+
if source and meta.get("source") != source:
|
|
660
|
+
continue
|
|
661
|
+
ts = meta.get("timestamp")
|
|
662
|
+
if not ts:
|
|
663
|
+
continue
|
|
664
|
+
ts_day = ts.split("T")[0] if "T" in ts else ts
|
|
665
|
+
if date_from and ts_day < date_from:
|
|
666
|
+
continue
|
|
667
|
+
if date_to and ts_day > date_to:
|
|
668
|
+
continue
|
|
669
|
+
memory_text = meta.get("data", "")
|
|
670
|
+
if not memory_text:
|
|
671
|
+
continue
|
|
672
|
+
|
|
673
|
+
mem_vec = _dense_vector(p)
|
|
674
|
+
if mem_vec is None:
|
|
675
|
+
continue
|
|
676
|
+
|
|
677
|
+
vectors.append(mem_vec)
|
|
678
|
+
results.append({
|
|
679
|
+
"id": p.id,
|
|
680
|
+
"memory": memory_text,
|
|
681
|
+
"metadata": meta,
|
|
682
|
+
})
|
|
683
|
+
if offset is None or len(points) == 0:
|
|
684
|
+
break
|
|
685
|
+
|
|
686
|
+
if vectors:
|
|
687
|
+
# Batched cosine similarity: (N, D) x (D,) -> (N,)
|
|
688
|
+
vectors = np.stack(vectors, axis=0)
|
|
689
|
+
scores = np.dot(vectors, query_embedding) / (
|
|
690
|
+
np.linalg.norm(vectors, axis=1) * query_norm + 1e-10
|
|
691
|
+
)
|
|
692
|
+
for i, score in enumerate(scores.tolist()):
|
|
693
|
+
results[i]["score"] = score
|
|
694
|
+
|
|
695
|
+
results.sort(key=lambda x: x["score"], reverse=True)
|
|
696
|
+
return {"results": results[:limit]}
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# --- Startup ---
|
|
700
|
+
|
|
701
|
+
def main():
|
|
702
|
+
parser = argparse.ArgumentParser(description="Mem0 persistent HTTP server (dsh-mem0-memory)")
|
|
703
|
+
parser.add_argument("--port", type=int, default=18200, help="HTTP port (default: 18200)")
|
|
704
|
+
parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
|
|
705
|
+
parser.add_argument("--data-dir", default=None, help="Vector store data dir (default: $MEM0_DATA_DIR or ./mem0_store)")
|
|
706
|
+
parser.add_argument("--env-file", default=None, help=".env file to load (default: ./.env)")
|
|
707
|
+
args = parser.parse_args()
|
|
708
|
+
|
|
709
|
+
global DATA_DIR
|
|
710
|
+
env_file = args.env_file or ENV_FILE
|
|
711
|
+
load_dotenv(env_file)
|
|
712
|
+
if args.data_dir:
|
|
713
|
+
DATA_DIR = args.data_dir
|
|
714
|
+
elif os.environ.get("MEM0_DATA_DIR"):
|
|
715
|
+
DATA_DIR = os.environ.get("MEM0_DATA_DIR")
|
|
716
|
+
DATA_DIR = os.path.abspath(DATA_DIR)
|
|
717
|
+
os.makedirs(DATA_DIR, exist_ok=True)
|
|
718
|
+
|
|
719
|
+
print(f"[mem0-server] Starting on {args.host}:{args.port}", flush=True)
|
|
720
|
+
print(f"[mem0-server] data-dir={DATA_DIR} user_id={get_user_id()} env-file={env_file}", flush=True)
|
|
721
|
+
|
|
722
|
+
# Pre-init Memory in a background thread so Flask can still respond to /health
|
|
723
|
+
def _init():
|
|
724
|
+
try:
|
|
725
|
+
clean_lock()
|
|
726
|
+
apply_jieba_patch()
|
|
727
|
+
apply_language_patch()
|
|
728
|
+
apply_deepseek_patch()
|
|
729
|
+
apply_timeout_patch()
|
|
730
|
+
init_memory()
|
|
731
|
+
except Exception as e:
|
|
732
|
+
print(f"[mem0-server] Init error: {e}", flush=True)
|
|
733
|
+
|
|
734
|
+
init_thread = threading.Thread(target=_init, daemon=True)
|
|
735
|
+
init_thread.start()
|
|
736
|
+
|
|
737
|
+
# Use threaded=True to handle concurrent requests (health check + search)
|
|
738
|
+
# Without this, Flask dev server blocks and auto-recall health checks time out
|
|
739
|
+
app.run(host=args.host, port=args.port, threaded=True)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
if __name__ == "__main__":
|
|
743
|
+
main()
|