memory_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. # memory_service.py
  2. # Session-level long-term memory extraction + persistence for Lumi
  3. # 参考 lumi_memory.py,适配数据库存储
  4. #
  5. # Prompt 定义与《Backend-Prompt提示词逻辑 20260404.md》第 4 节「记忆提取和摘要 Prompt」保持一致:
  6. # §4.1 KEY_MEMORY_EXTRACTION_PROMPT
  7. # §4.2 INCREMENTAL_MEMORY_SUMMARY_PROMPT
  8. # 修改 Prompt 时请同步更新该文档。
  9. import json
  10. import os
  11. import sys
  12. from typing import Dict, Any, List, Optional
  13. from pathlib import Path
  14. # 添加 backend 目录到路径,以便导入数据库模块
  15. backend_dir = Path(__file__).parent.parent / "backend"
  16. sys.path.insert(0, str(backend_dir))
  17. DEFAULT_MEMORY_DIR = "memories"
  18. # --- §4.1 记忆提取 Prompt (`KEY_MEMORY_EXTRACTION_PROMPT`) ---
  19. KEY_MEMORY_EXTRACTION_PROMPT = r"""
  20. You are a memory extraction engine for Lumi.
  21. Your job: read the full session conversation and decide what (if anything) should be saved as long-term memory for personalization in future chats.
  22. Save only info that is:
  23. 1) Stable for weeks/months/years, AND
  24. 2) Useful for future personalization (tone, interests, preferences, identity, goals, recurring constraints), AND
  25. 3) Not overly private or risky.
  26. Good examples:
  27. - Name / preferred nickname (if explicitly stated)
  28. - Preferences (tone, topic likes/dislikes, boundaries like "don't ask X")
  29. - Long-term goals (e.g., "practice speaking confidence")
  30. - Persistent interests/hobbies (if clearly stable)
  31. Do NOT save:
  32. - One-off details (today's breakfast, today's mood)
  33. - Sensitive personal data (exact address, ID numbers, medical diagnoses, political opinions, etc.)
  34. - Private secrets the user didn't ask to store
  35. If user explicitly says "remember this / save this", you MAY store it unless it is sensitive.
  36. If sensitive, store a safer abstract version.
  37. OUTPUT FORMAT (JSON ONLY):
  38. {
  39. "should_write_memory": true/false,
  40. "memories_to_add": [
  41. {"key":"...", "value":"...", "confidence":0.0-1.0, "evidence":"..."}
  42. ],
  43. "memories_to_update": [
  44. {"key":"...", "old_value":"...", "new_value":"...", "confidence":0.0-1.0, "evidence":"..."}
  45. ],
  46. "memories_to_forget": [
  47. {"key":"...", "reason":"..."}
  48. ],
  49. "do_not_store": [
  50. {"candidate":"...", "reason":"one-off | sensitive | not useful"}
  51. ],
  52. "notes_for_personalization": [
  53. "..."
  54. ]
  55. }
  56. Rules:
  57. - Return JSON only. No extra text.
  58. - If nothing useful, should_write_memory=false and arrays empty.
  59. - Prefer fewer, higher-quality memories.
  60. - Keys should be namespaced, e.g. identity.name, preference.tone, preference.avoid, goal.long_term.
  61. """.strip()
  62. # --- §4.2 增量记忆摘要 Prompt (`INCREMENTAL_MEMORY_SUMMARY_PROMPT`) ---
  63. INCREMENTAL_MEMORY_SUMMARY_PROMPT = r"""
  64. You are Lumi's memory-summary updater.
  65. You will receive:
  66. 1) The previous memory summary (may be empty)
  67. 2) The latest long-term memory JSON (key-value pairs)
  68. Task:
  69. Update the previous summary into a single, smooth, assistant-facing paragraph
  70. that captures the user's stable preferences, interaction style, and recurring tendencies.
  71. Editing principles (important):
  72. - Prefer minimal edits to the existing summary when still valid.
  73. - Integrate new information naturally into the paragraph only if it provides clear long-term personalization value.
  74. - Remove or soften details that are no longer supported by memory.
  75. - Generalize or omit anything that could become sensitive.
  76. - Avoid rigid traits or absolute statements that would overly constrain future conversations.
  77. Output constraints:
  78. - ONE short paragraph only (no bullet points).
  79. - Around 60–100 words.
  80. - Neutral, warm, assistant-facing tone.
  81. - Describe tendencies and preferences, not fixed identities.
  82. - Avoid explicit instructions, rules, or triggers.
  83. - Do NOT mention internal memory keys or system concepts.
  84. - Plain text only. No headings. No JSON.
  85. The goal is to provide gentle background context that supports natural topic flow,
  86. not to force alignment or explicit recall.
  87. """.strip()
  88. def _kv_memory_for_incremental_prompt(memory: Dict[str, Any]) -> Dict[str, Any]:
  89. """
  90. §4.2 中第 2 项输入为「长期记忆 JSON(键值对)」。
  91. meta.*(如 meta.memory_summary)由第 1 项 [PREVIOUS SUMMARY] 单独提供,不参与 JSON 序列化,避免与摘要重复。
  92. """
  93. return {
  94. k: v
  95. for k, v in (memory or {}).items()
  96. if k and not str(k).startswith("meta.")
  97. }
  98. def _ensure_dir(path: str) -> None:
  99. """确保目录存在"""
  100. os.makedirs(path, exist_ok=True)
  101. def get_memory_path(user_id: str, memory_dir: str = DEFAULT_MEMORY_DIR) -> str:
  102. """获取用户记忆文件路径"""
  103. _ensure_dir(memory_dir)
  104. safe = "".join(c for c in (user_id or "default") if c.isalnum() or c in ("_", "-", ".")).strip()
  105. if not safe:
  106. safe = "default"
  107. return os.path.join(memory_dir, f"memory_{safe}.json")
  108. def load_memory(user_id: str, memory_dir: str = DEFAULT_MEMORY_DIR) -> Dict[str, Any]:
  109. """加载用户记忆"""
  110. path = get_memory_path(user_id, memory_dir)
  111. if os.path.exists(path):
  112. try:
  113. with open(path, "r", encoding="utf-8") as f:
  114. data = json.load(f)
  115. return data if isinstance(data, dict) else {}
  116. except Exception:
  117. return {}
  118. return {}
  119. def save_memory(user_id: str, memory: Dict[str, Any], memory_dir: str = DEFAULT_MEMORY_DIR) -> None:
  120. """保存用户记忆"""
  121. path = get_memory_path(user_id, memory_dir)
  122. _ensure_dir(memory_dir)
  123. mem = dict(memory or {})
  124. # ✅ Ensure meta.memory_summary is always the first key in the saved JSON
  125. if "meta.memory_summary" in mem:
  126. first = {"meta.memory_summary": mem.get("meta.memory_summary")}
  127. rest = {k: v for k, v in mem.items() if k != "meta.memory_summary"}
  128. mem = {**first, **rest}
  129. with open(path, "w", encoding="utf-8") as f:
  130. json.dump(mem, f, ensure_ascii=False, indent=2)
  131. def build_session_snippet(messages: List[dict], max_turns: int = 80) -> str:
  132. """
  133. Build a session transcript from messages.
  134. max_turns limits how many user/assistant messages we include (to avoid huge prompt).
  135. """
  136. turns = [m for m in messages if m.get("role") in ("user", "assistant")]
  137. turns = turns[-max_turns:] # keep tail if too long
  138. lines = []
  139. for m in turns:
  140. role = "User" if m["role"] == "user" else "Lumi"
  141. lines.append(f"{role}: {m.get('content', '')}")
  142. return "\n".join(lines).strip()
  143. def safe_json_load(s: str) -> Dict[str, Any]:
  144. """
  145. Robust JSON loader for imperfect model outputs.
  146. """
  147. s = (s or "").strip()
  148. if not s:
  149. return {}
  150. try:
  151. return json.loads(s)
  152. except Exception:
  153. start = s.find("{")
  154. end = s.rfind("}")
  155. if start != -1 and end != -1 and end > start:
  156. try:
  157. return json.loads(s[start: end + 1])
  158. except Exception:
  159. return {}
  160. return {}
  161. def _to_clean_text(v: Any) -> str:
  162. """清理文本值"""
  163. if v is None:
  164. return ""
  165. if isinstance(v, str):
  166. return v.strip()
  167. if isinstance(v, list):
  168. parts = []
  169. for x in v:
  170. if x is None:
  171. continue
  172. s = x.strip() if isinstance(x, str) else str(x).strip()
  173. if s:
  174. parts.append(s)
  175. return "; ".join(parts).strip()
  176. if isinstance(v, dict):
  177. return json.dumps(v, ensure_ascii=False).strip()
  178. return str(v).strip()
  179. def apply_memory_patch(
  180. memory: Dict[str, Any],
  181. patch: Dict[str, Any],
  182. min_confidence: float = 0.55,
  183. ) -> Dict[str, Any]:
  184. """
  185. Apply add/update/forget operations returned by the extractor model.
  186. """
  187. mem = dict(memory or {})
  188. for item in (patch.get("memories_to_add") or []):
  189. k = (item.get("key") or "").strip()
  190. v = _to_clean_text(item.get("value"))
  191. try:
  192. conf = float(item.get("confidence") or 0.0)
  193. except Exception:
  194. conf = 0.0
  195. if k and v and conf >= min_confidence:
  196. if not mem.get(k):
  197. mem[k] = v
  198. for item in (patch.get("memories_to_update") or []):
  199. k = (item.get("key") or "").strip()
  200. new_v = _to_clean_text(item.get("new_value"))
  201. try:
  202. conf = float(item.get("confidence") or 0.0)
  203. except Exception:
  204. conf = 0.0
  205. if k and new_v and conf >= min_confidence:
  206. mem[k] = new_v
  207. for item in (patch.get("memories_to_forget") or []):
  208. k = (item.get("key") or "").strip()
  209. if k in mem:
  210. mem.pop(k, None)
  211. return mem
  212. def summarize_existing_memory_incremental(
  213. client,
  214. model: str,
  215. memory: Dict[str, Any],
  216. previous_summary: str = "",
  217. ) -> str:
  218. """
  219. Incrementally update a human-readable summary using the previous summary + latest memory.
  220. """
  221. if not isinstance(memory, dict):
  222. return previous_summary.strip() or "(no long-term memory yet)"
  223. if not memory:
  224. return previous_summary.strip() or "(no long-term memory yet)"
  225. prev = (previous_summary or "").strip()
  226. kv_only = _kv_memory_for_incremental_prompt(memory)
  227. payload = json.dumps(kv_only, ensure_ascii=False)
  228. user_content = (
  229. f"[PREVIOUS SUMMARY]\n{prev if prev else '(none)'}\n\n"
  230. f"[LATEST LONG-TERM MEMORY JSON]\n{payload}"
  231. )
  232. try:
  233. resp = client.chat.completions.create(
  234. model=model,
  235. messages=[
  236. {"role": "system", "content": INCREMENTAL_MEMORY_SUMMARY_PROMPT},
  237. {"role": "user", "content": user_content},
  238. ],
  239. temperature=0.2,
  240. )
  241. text = (resp.choices[0].message.content or "").strip()
  242. return text or (prev or "(no summary generated)")
  243. except Exception:
  244. return prev or "(memory summary unavailable)"
  245. def update_memory_summary_field(
  246. client,
  247. model: str,
  248. user_id: str,
  249. memory: Dict[str, Any],
  250. memory_dir: str = DEFAULT_MEMORY_DIR,
  251. persist: bool = True,
  252. ) -> Dict[str, Any]:
  253. """
  254. Store an incremental summary into memory under: meta.memory_summary
  255. """
  256. mem = dict(memory or {})
  257. previous_summary = ""
  258. if isinstance(mem.get("meta.memory_summary"), str):
  259. previous_summary = mem.get("meta.memory_summary", "").strip()
  260. summary = summarize_existing_memory_incremental(
  261. client=client,
  262. model=model,
  263. memory=mem,
  264. previous_summary=previous_summary,
  265. )
  266. mem["meta.memory_summary"] = summary
  267. if persist:
  268. save_memory(user_id, mem, memory_dir=memory_dir)
  269. return mem
  270. def extract_and_update_memory_session_level(
  271. client,
  272. model: str,
  273. user_id: str,
  274. messages: List[dict],
  275. current_memory: Optional[Dict[str, Any]] = None,
  276. max_turns: int = 80,
  277. ) -> Dict[str, Any]:
  278. """
  279. Session-level memory update:
  280. - Build session snippet
  281. - Call LLM once at end of session to extract patch
  282. - Apply patch and persist
  283. - Update incremental memory summary and persist
  284. """
  285. memory = dict(current_memory or {})
  286. snippet = build_session_snippet(messages, max_turns=max_turns)
  287. if not snippet:
  288. return memory
  289. resp = client.chat.completions.create(
  290. model=model,
  291. messages=[
  292. {"role": "system", "content": KEY_MEMORY_EXTRACTION_PROMPT},
  293. {"role": "user", "content": snippet},
  294. ],
  295. temperature=0.2,
  296. )
  297. raw = (resp.choices[0].message.content or "").strip()
  298. patch = safe_json_load(raw)
  299. if not patch or not patch.get("should_write_memory"):
  300. # Even if no new memory, we keep whatever exists (no summary refresh)
  301. return memory
  302. new_memory = apply_memory_patch(memory, patch)
  303. save_memory(user_id, new_memory)
  304. # Incremental summary (kept inside memory as meta.memory_summary)
  305. new_memory = update_memory_summary_field(
  306. client=client,
  307. model=model,
  308. user_id=user_id,
  309. memory=new_memory,
  310. persist=True,
  311. )
  312. return new_memory