File size: 5,153 Bytes
8a2dcce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
mem0 client wrapper.

Responsibility boundary (do not blur this):
    - mem0       -> user preferences + long-term conversational memory
                    (e.g. "prefers beginner explanations", "likes step-by-step examples")
    - ChromaDB   -> DSA knowledge embeddings (rag/retriever.py) β€” NEVER touched here
    - SQLite     -> raw message/session transcripts (history/) β€” NEVER touched here

mem0 runs in local/open-source mode: a local Chroma vector store (separate
collection from the knowledge base) + a local sentence-transformers
embedder (same model as the KB for consistency, configurable independently
if needed).

Fact extraction (infer=True) would use an LLM to summarize what to
remember. To avoid depending on the (expensive, 4-bit-quantized) local
Mistral model just to log a preference, this wrapper uses infer=False by
default β€” messages are stored as-is and semantic search over them still
works for retrieval. Set infer=True if you want mem0 to run LLM-based
fact extraction using the same provider configured in llm/generate.py.
"""

import os

import config
from logs.logger import get_logger

logger = get_logger(__name__)

_MEM0_COLLECTION_NAME = "user_preferences_memory"


def _build_config_dict() -> dict:
    os.makedirs(config.MEM0_LOCAL_STORAGE_DIR, exist_ok=True)
    return {
        "vector_store": {
            "provider": "chroma",
            "config": {
                "collection_name": _MEM0_COLLECTION_NAME,
                "path": os.path.join(config.MEM0_LOCAL_STORAGE_DIR, "chroma_db"),
            },
        },
        "embedder": {
            "provider": "huggingface",
            "config": {
                "model": f"sentence-transformers/{config.EMBEDDING_MODEL_NAME}",
            },
        },
        "history_db_path": os.path.join(config.MEM0_LOCAL_STORAGE_DIR, "mem0_history.db"),
    }


class Mem0Client:
    """
    Thin wrapper around mem0.Memory scoped to a single responsibility:
    user preferences and long-term conversational memory.
    """

    def __init__(self, infer: bool = False):
        self.infer = infer
        self._memory = None
        self._enabled = config.USE_MEM0

        if not self._enabled:
            logger.info("USE_MEM0 is False β€” Mem0Client will be a no-op.")

    def _get_memory(self):
        if self._memory is None:
            from mem0 import Memory

            logger.info("Initializing mem0 (local chroma + huggingface embedder)...")
            self._memory = Memory.from_config(_build_config_dict())
        return self._memory

    def add_interaction(self, user_id: str, user_message: str, assistant_message: str = None) -> None:
        """
        Store a turn of conversation for long-term memory purposes (NOT
        transcript storage β€” that's history/service.py + SQLite).

        Only call this for things worth remembering long-term (preferences,
        recurring topics of interest, stated skill level) β€” not every raw
        message. The router/chat layer decides when this is worth calling;
        this wrapper doesn't filter content itself.
        """
        if not self._enabled:
            return

        messages = [{"role": "user", "content": user_message}]
        if assistant_message:
            messages.append({"role": "assistant", "content": assistant_message})

        try:
            self._get_memory().add(messages, user_id=user_id, infer=self.infer)
        except Exception:
            logger.exception("mem0 add_interaction failed for user_id=%s", user_id)

    def get_relevant_context(self, user_id: str, query: str, limit: int = 5) -> list:
        """
        Returns a list of plain-text memory strings relevant to `query`,
        ready to hand to llm/prompts.py's mem0_context parameter.

        Returns [] if mem0 is disabled, the user has no memories yet, or a
        lookup error occurs β€” callers should treat that as "no memory
        context available", not as an error condition.
        """
        if not self._enabled:
            return []

        try:
            results = self._get_memory().search(query, user_id=user_id, limit=limit)
        except Exception:
            logger.exception("mem0 get_relevant_context failed for user_id=%s", user_id)
            return []

        # mem0's search() returns {"results": [{"memory": "...", "score": ...}, ...]}
        # in v2, or a plain list in some versions β€” handle both.
        items = results.get("results", results) if isinstance(results, dict) else results
        return [item.get("memory", "") for item in items if item.get("memory")]

    def delete_all_for_user(self, user_id: str) -> None:
        """Useful for account deletion / a 'forget me' feature."""
        if not self._enabled:
            return
        try:
            self._get_memory().delete_all(user_id=user_id)
        except Exception:
            logger.exception("mem0 delete_all_for_user failed for user_id=%s", user_id)


_CLIENT_SINGLETON = None


def get_mem0_client() -> Mem0Client:
    global _CLIENT_SINGLETON
    if _CLIENT_SINGLETON is None:
        _CLIENT_SINGLETON = Mem0Client()
    return _CLIENT_SINGLETON