lumi_memory.py 11 KB

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