File size: 5,979 Bytes
f3aa68e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | """
RUBRA — memory_engine.py
Persistent, cross-session user memory — durable facts about a person that
carry across every NEW conversation, not just the current one.
The gap this closes: session history already makes RUBRA remember
everything within ONE chat. The moment someone clicks "New Chat", that
session_id is gone and so is everything RUBRA knew. This module is keyed
on a stable user identity instead (see main.py's `mem_key` resolution), so
starting a new chat doesn't mean starting from zero — the same way a
person doesn't forget a friend just because they're now talking on a new
phone call instead of the last one.
Design mirrors how memory works for Claude itself: silent application
(never "according to my memory of you..."), durable facts only (not a
transcript of every message), bounded size (oldest facts age out), and
never forced into a reply that isn't about that topic.
"""
import re
import json
import logging
from typing import Optional, List
from database import user_memory_load, user_memory_save
log = logging.getLogger("rubra.memory")
MAX_FACTS = 40 # bounded growth — oldest facts drop off past this
_NAME_PATTERNS = [
r"\b(?:my name is|i am|i'm|call me)\s+([A-Z][a-zA-Z]{1,20})\b",
r"\b(?:amar naam|আমার নাম)\s+([A-Za-z\u0980-\u09FF]{2,20})\b",
]
# Common false positives for the "i'm X" pattern — moods/states, not names.
_NAME_FALSE_POSITIVES = {
"fine", "good", "ok", "okay", "not", "sorry", "happy", "sad", "tired",
"done", "here", "back", "working", "trying", "going", "sure", "glad",
"stuck", "confused", "busy", "free", "ready", "new", "still", "also",
}
def _extract_name_heuristic(message: str) -> Optional[str]:
"""Cheap regex pass — runs on every message, no LLM call needed."""
for pattern in _NAME_PATTERNS:
m = re.search(pattern, message, re.IGNORECASE)
if m:
name = m.group(1).strip()
if name.lower() not in _NAME_FALSE_POSITIVES:
return name
return None
async def extract_and_update_memory(user_id: str, user_msg: str, assistant_msg: str, llm_func) -> None:
"""
Fire-and-forget after a turn completes — call via asyncio.create_task,
never awaited inline, so memory extraction can't slow down the actual
reply. Never raises: a memory-extraction failure must never surface as
a broken chat response.
Cheap heuristic name extraction always runs. A short LLM call extracts
0-3 durable facts, but only for exchanges substantial enough to likely
contain one (skips "ok thanks", "hi", etc. — not worth a call).
"""
if not user_id:
return
try:
mem = user_memory_load(user_id)
name = mem.get("name")
facts = list(mem.get("facts", []))
heuristic_name = _extract_name_heuristic(user_msg)
if heuristic_name and not name:
name = heuristic_name
if len(user_msg.split()) >= 6:
prompt = (
"Extract 0 to 3 SHORT durable facts about the user from this single "
"exchange that would help in FUTURE, otherwise-unrelated conversations — "
"things like their name, profession, ongoing projects, preferences, or "
"communication style. Do NOT extract one-off task details (e.g. \"asked to "
"fix a typo\" is not durable; \"is building an e-commerce site called Foo\" "
"IS durable). If nothing durable came up, return an empty list.\n\n"
f"User: {user_msg[:500]}\nAssistant: {assistant_msg[:500]}\n\n"
"Respond with ONLY a JSON array of short strings, e.g. "
'["works as a graphic designer", "prefers concise answers"]. '
"No explanation, no markdown fences, no extra text."
)
raw = ""
try:
async for tok in llm_func([{"role": "user", "content": prompt}], mode="fast"):
raw += tok
except Exception as e:
log.warning(f"[MEMORY] extraction call failed: {e}")
raw = ""
raw = raw.strip()
if raw.startswith("```"):
raw = re.sub(r'^```\w*\n?', '', raw)
raw = re.sub(r'\n?```$', '', raw)
try:
new_facts = json.loads(raw) if raw else []
if isinstance(new_facts, list):
for f in new_facts:
f = str(f).strip()
if f and f not in facts:
facts.append(f)
except Exception:
pass # model didn't return clean JSON — skip silently, not fatal
facts = facts[-MAX_FACTS:]
if name or facts:
user_memory_save(user_id, name, facts)
except Exception as e:
log.warning(f"[MEMORY] update failed for {user_id}: {e}")
def format_memory_for_prompt(user_id: str) -> str:
"""
Builds the system_note injected into EVERY conversation (new or
ongoing). Empty string if nothing is known yet — never inject a hollow
"I don't know anything about you" block; absence of memory should be
invisible, not announced.
"""
if not user_id:
return ""
mem = user_memory_load(user_id)
name, facts = mem.get("name"), mem.get("facts", [])
if not name and not facts:
return ""
lines = ["[WHAT YOU KNOW ABOUT THIS PERSON — from earlier conversations]"]
if name:
lines.append(f"Their name is {name}. Use it naturally when it fits — don't overuse it.")
for f in facts[-MAX_FACTS:]:
lines.append(f"- {f}")
lines.append(
"Use this the way a person remembers a friend across conversations — never say "
"\"according to my memory\" or list these facts back at them, and don't force an "
"irrelevant fact into a reply that isn't about that topic."
)
return "\n".join(lines)
|