File size: 15,114 Bytes
cf8d91c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f941766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf8d91c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
"""

memory/vector_db.py

===================

Módulo de memoria a largo plazo para Aurora Core.



Utiliza ChromaDB en modo persistente (sin servidor externo) para almacenar

recuerdos del usuario como embeddings semánticos y recuperarlos por similitud.



Dependencias:

    pip install chromadb



Modelo de embeddings:

    Por defecto: all-MiniLM-L6-v2 vía ONNX (incluido en chromadb).

    Primera ejecución: el modelo (~45 MB) se descarga automáticamente

    en ~/.cache/chroma/onnx_models/. Las siguientes ejecuciones son

    completamente offline.



Estructura en disco generada:

    .aurora_memory_db/

    └── <colección SQLite + índice HNSW>

"""

from __future__ import annotations

import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

import chromadb


# ── Constantes ────────────────────────────────────────────────────────
_DB_PATH        = ".aurora_memory_db"      # directorio de persistencia local
_COLLECTION_NAME = "user_long_term_memory"  # una colección por usuario (prefijada con user_id)


class AuroraMemory:
    """

    Interfaz de memoria a largo plazo para Aurora Core.



    Cada instancia opera sobre una colección ChromaDB aislada por ``user_id``,

    lo que permite múltiples perfiles de usuario en la misma base de datos sin

    colisiones de datos.



    Example::



        mem = AuroraMemory(user_id="gabi_master")

        mem.save_memory("Aurora corre en Python 3.11 con FastAPI")

        contexto = mem.retrieve_relevant_context("¿qué stack tecnológico usamos?")

    """

    def __init__(self, user_id: str = "gabi_master") -> None:
        """

        Inicializa el cliente ChromaDB persistente y obtiene (o crea) la

        colección de memoria para el usuario indicado.



        La colección se nombra ``<user_id>__user_long_term_memory`` para

        garantizar aislamiento entre usuarios dentro de la misma carpeta de DB.



        Args:

            user_id: Identificador único del usuario. El nombre de la colección

                     se derivará de este valor para aislar recuerdos entre perfiles.



        Raises:

            Exception: Si ChromaDB no puede inicializar o acceder al directorio

                       de persistencia (permisos, disco lleno, etc.).

        """
        self.user_id = user_id

        # Asegurar que el directorio de persistencia exista
        Path(_DB_PATH).mkdir(parents=True, exist_ok=True)

        # Cliente persistente — sin servidor, todo local en disco
        self._client = chromadb.PersistentClient(path=_DB_PATH)

        # Nombre de colección con prefijo de usuario para aislamiento
        collection_name = f"{user_id}__{_COLLECTION_NAME}"

        # get_or_create_collection: idempotente — no falla si ya existe
        self._collection = self._client.get_or_create_collection(
            name=collection_name,
            metadata={
                "hnsw:space": "cosine",   # similitud coseno para texto semántico
                "description": f"Memoria a largo plazo de {user_id} — Aurora Core",
            },
        )

    # ── save_memory ───────────────────────────────────────────────────

    def save_memory(self, text: str, metadata: Optional[dict] = None) -> str:
        """

        Guarda un fragmento de texto como recuerdo persistente en la base de datos.



        El texto se convierte automáticamente en embedding semántico por ChromaDB

        (usando all-MiniLM-L6-v2) y se indexa para búsqueda por similitud futura.



        Args:

            text:     Fragmento de texto a recordar.

                      Ejemplos:

                        - "Gabi prefiere trabajar de noche en proyectos de IA"

                        - "El proyecto Aurora usa Hostinger para el deploy"

                        - "A Gabi le gusta el café con mucho azúcar"

            metadata: Diccionario opcional de metadatos adicionales (str/int/float/bool).

                      Se combina con los metadatos automáticos (timestamp, user_id, etc.).

                      Ejemplo: {"categoria": "tecnico", "proyecto": "aurora"}



        Returns:

            El ID único generado para este recuerdo (UUID v4 como string).



        Raises:

            ValueError: Si ``text`` es una cadena vacía.

            Exception:  Si ChromaDB falla al escribir (disco lleno, DB bloqueada, etc.).

        """
        if not text or not text.strip():
            raise ValueError("El texto del recuerdo no puede estar vacío.")

        memory_id = str(uuid.uuid4())

        # Metadatos automáticos siempre presentes
        base_metadata: dict = {
            "user_id":   self.user_id,
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "text_len":  len(text),
        }

        # Fusionar con metadatos adicionales del llamador (tienen prioridad)
        if metadata:
            # ChromaDB solo acepta str/int/float/bool en metadatos — filtrar otros tipos
            safe_extra = {
                k: v for k, v in metadata.items()
                if isinstance(v, (str, int, float, bool))
            }
            base_metadata.update(safe_extra)

        self._collection.add(
            ids=[memory_id],
            documents=[text.strip()],
            metadatas=[base_metadata],
        )

        return memory_id

    # ── retrieve_relevant_context ─────────────────────────────────────

    def retrieve_relevant_context(

        self,

        query: str,

        n_results: int = 3,

    ) -> list[str]:
        """

        Recupera los recuerdos más relevantes para el mensaje actual del usuario.



        Realiza una búsqueda semántica por similitud coseno en el espacio de

        embeddings. El resultado puede inyectarse directamente en el system prompt

        de la IA para darle contexto personalizado.



        Args:

            query:     Texto del mensaje actual del usuario (o una reformulación

                       temática del mismo para guiar la búsqueda).

            n_results: Número máximo de recuerdos a devolver. Se ajusta

                       automáticamente si la DB tiene menos documentos que este

                       valor (evita errores de ChromaDB).



        Returns:

            Lista de textos de los recuerdos más relevantes, ordenados de mayor

            a menor similitud semántica. Lista vacía si no hay recuerdos.



        Example::



            contexto = mem.retrieve_relevant_context("¿en qué proyecto trabajamos?")

            # → ["Aurora usa Hostinger para el deploy", "El backend corre en Python 3.11"]

        """
        if not query or not query.strip():
            return []

        total = self._collection.count()
        if total == 0:
            return []

        # Evitar que n_results > documentos existentes (error de ChromaDB)
        safe_n = min(n_results, total)

        try:
            results = self._collection.query(
                query_texts=[query.strip()],
                n_results=safe_n,
                include=["documents", "distances", "metadatas"],
            )
        except Exception as exc:
            # La DB puede estar temporalmente bloqueada en entornos multi-proceso
            print(f"[AuroraMemory] Error al consultar la memoria: {exc}")
            return []

        # results["documents"] → lista de listas (una por query_text)
        # Tomamos la primera (y única) consulta
        docs: list[str] = results["documents"][0] if results["documents"] else []
        return docs

    # ── borrar_todo ───────────────────────────────────────────────────

    def borrar_todo(self) -> dict:
        """

        Elimina **todos** los recuerdos de este usuario. Operación irreversible.



        La llama ``/delete-account`` al dar de baja una cuenta. Si esto no borra

        de verdad, queda texto literal de conversaciones en disco después de que

        alguien pidió expresamente que lo borren.



        Estrategia: ``delete_collection()`` y no ``collection.delete()``.



        La diferencia importa. ``delete()`` saca los documentos del índice, pero

        los archivos del segmento HNSW y las filas de SQLite siguen ocupando

        disco: los datos quedan recuperables con herramientas forenses básicas.

        ``delete_collection()`` es lo único que elimina la colección completa del

        almacenamiento. Y acá se puede usar porque cada usuario tiene su propia

        colección (``<user_id>__user_long_term_memory``), así que borrarla no

        toca los recuerdos de nadie más.



        Después del borrado la colección se **recrea vacía**, para que la

        instancia siga siendo usable: si otro hilo tuviera una referencia y

        llamara a ``save_memory`` o ``retrieve_relevant_context``, obtendría una

        memoria vacía en vez de una excepción.



        Returns:

            Diccionario con el resultado:



            - ``user_id`` (str): usuario afectado.

            - ``collection_name`` (str): colección eliminada.

            - ``borrados`` (int): recuerdos que había antes del borrado.

            - ``ok`` (bool): si el borrado se completó.

            - ``error`` (str, opcional): motivo si ``ok`` es False.



        Example::



            mem = AuroraMemory(user_id="gabi_master")

            informe = mem.borrar_todo()

            # → {"user_id": "gabi_master", "borrados": 12, "ok": True, ...}

        """
        nombre = self._collection.name

        try:
            previos = self._collection.count()
        except Exception:
            previos = -1

        informe: dict = {
            "user_id":         self.user_id,
            "collection_name": nombre,
            "borrados":        previos,
            "ok":              False,
        }

        try:
            self._client.delete_collection(name=nombre)

            # Recrear vacía con los MISMOS metadatos que en __init__, para que
            # una instancia reutilizada se comporte igual que una recién creada.
            self._collection = self._client.get_or_create_collection(
                name=nombre,
                metadata={
                    "hnsw:space": "cosine",
                    "description": f"Memoria a largo plazo de {self.user_id} — Aurora Core",
                },
            )

            informe["ok"] = True
            print(f"[AuroraMemory] Memoria de '{self.user_id}' borrada "
                  f"({previos} recuerdos eliminados).")

        except Exception as exc:
            # No se relanza: el borrado de cuenta tiene que poder seguir con los
            # demás pasos e informar qué falló, en vez de cortarse por la mitad
            # y dejar al usuario sin saber qué quedó borrado y qué no.
            informe["error"] = f"{type(exc).__name__}: {exc}"
            print(f"[AuroraMemory] FALLÓ el borrado de '{self.user_id}': {exc}")

        return informe

    # ── get_memory_stats ──────────────────────────────────────────────

    def get_memory_stats(self) -> dict:
        """

        Devuelve estadísticas básicas del estado actual de la memoria.



        Returns:

            Diccionario con las siguientes claves:



            - ``total_memories`` (int): Número de recuerdos almacenados.

            - ``user_id`` (str): Identificador del usuario de esta instancia.

            - ``collection_name`` (str): Nombre interno de la colección en ChromaDB.

            - ``db_path`` (str): Ruta absoluta del directorio de persistencia.



        Example::



            stats = mem.get_memory_stats()

            # → {"total_memories": 12, "user_id": "gabi_master", ...}

        """
        try:
            total = self._collection.count()
        except Exception:
            total = -1  # -1 indica que no se pudo acceder a la colección

        return {
            "total_memories":  total,
            "user_id":         self.user_id,
            "collection_name": self._collection.name,
            "db_path":         str(Path(_DB_PATH).resolve()),
        }


# ── Bloque de prueba ──────────────────────────────────────────────────

if __name__ == "__main__":
    print("=" * 58)
    print("  Aurora Core — Test de memoria vectorial a largo plazo")
    print("=" * 58)

    # 1. Inicializar
    print("\n[1] Inicializando AuroraMemory...")
    mem = AuroraMemory(user_id="gabi_master")
    stats_inicial = mem.get_memory_stats()
    print(f"    Recuerdos previos en DB: {stats_inicial['total_memories']}")
    print(f"    Colección: {stats_inicial['collection_name']}")
    print(f"    Ruta DB:   {stats_inicial['db_path']}")

    # 2. Guardar recuerdos de prueba
    print("\n[2] Guardando recuerdos de prueba...")

    id_tecnico = mem.save_memory(
        text="El proyecto Aurora tiene su backend en Python con FastAPI "
             "y está alojado en Hostinger con un VPS Ubuntu 22.04.",
        metadata={"categoria": "tecnico", "proyecto": "aurora"},
    )
    print(f"    ✓ Recuerdo técnico guardado  (id: {id_tecnico[:8]}...)")

    id_personal = mem.save_memory(
        text="Gabi prefiere programar de madrugada, entre las 2 y las 5 AM, "
             "cuando hay silencio total y puede concentrarse mejor en proyectos de IA.",
        metadata={"categoria": "personal", "habito": "horario_trabajo"},
    )
    print(f"    ✓ Recuerdo personal guardado (id: {id_personal[:8]}...)")

    # 3. Búsqueda semántica
    print("\n[3] Búsqueda semántica — query: '¿En qué proyecto estábamos trabajando?'")
    query = "¿En qué proyecto estábamos trabajando?"
    contexto = mem.retrieve_relevant_context(query, n_results=3)

    if contexto:
        print(f"    Recuerdos más relevantes recuperados ({len(contexto)}):")
        for i, doc in enumerate(contexto, 1):
            preview = doc[:90] + "..." if len(doc) > 90 else doc
            print(f"      {i}. {preview}")
    else:
        print("    (sin resultados)")

    # 4. Estadísticas finales
    print("\n[4] Estadísticas finales:")
    stats = mem.get_memory_stats()
    for k, v in stats.items():
        print(f"    {k}: {v}")

    print("\n" + "=" * 58)
    print("  Test completado exitosamente.")
    print("=" * 58)