memory_service.py 11 KB

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