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)