Spaces:
Running
Running
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) |