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.
@@ -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()