"""Mémoire long-terme sémantique (Qdrant). Stocke les tours utilisateur comme vecteurs dans la collection `stt-memory` et retrouve les souvenirs pertinents pour les injecter dans le prompt. Embeddings via une instance llama-server dédiée (`nomic-embed-text`, gpu-01 `:1238`, dim 768). **Tout dégrade proprement** : si Qdrant ou l'endpoint d'embedding est injoignable, `recall` renvoie [] et `remember` ne fait rien (la mémoire court-terme de session continue de fonctionner). > Le modèle d'embedding est configurable (`STT_EMBED_URL`/`STT_EMBED_MODEL`). Si la dimension > change, `_ensure_collection` recrée automatiquement la collection. Voir admin/ia/rag.md. """ from __future__ import annotations import time import uuid import httpx from stt_server.config import settings class LongTermMemory: def __init__(self) -> None: self.qdrant = settings.qdrant_url.rstrip("/") self.collection = settings.qdrant_collection self.embed_url = settings.embed_url self.embed_model = settings.embed_model self.top_k = settings.memory_top_k self._ready = False async def _embed(self, client: httpx.AsyncClient, text: str) -> list[float]: r = await client.post( self.embed_url, json={"model": self.embed_model, "input": text}, timeout=30, ) r.raise_for_status() return r.json()["data"][0]["embedding"] async def _ensure_collection(self, client: httpx.AsyncClient, dim: int) -> None: if self._ready: return r = await client.get(f"{self.qdrant}/collections/{self.collection}") if r.status_code == 200: # Collection existante : si la dimension a changé (modèle d'embedding # différent, ex. Qwen3 4096 → nomic 768), on recrée — les anciens vecteurs # sont incomparables dans le nouvel espace. vectors = ( r.json().get("result", {}).get("config", {}).get("params", {}).get("vectors", {}) ) size = vectors.get("size") if isinstance(vectors, dict) else None if size == dim: self._ready = True return await client.delete(f"{self.qdrant}/collections/{self.collection}") await client.put( f"{self.qdrant}/collections/{self.collection}", json={"vectors": {"size": dim, "distance": "Cosine"}}, ) self._ready = True async def recall(self, text: str) -> list[str]: """Souvenirs pertinents (texte) ou [] si indisponible.""" try: async with httpx.AsyncClient(timeout=20) as client: vec = await self._embed(client, text) r = await client.post( f"{self.qdrant}/collections/{self.collection}/points/search", json={"vector": vec, "limit": self.top_k, "with_payload": True}, ) if r.status_code == 404: # collection pas encore créée return [] r.raise_for_status() pts = r.json().get("result", []) return [p["payload"]["text"] for p in pts if p.get("payload", {}).get("text")] except Exception: # noqa: BLE001 — dégrade silencieusement return [] async def health(self) -> dict: """Diagnostic actif : sonde embeddings + Qdrant + collection, sans rien avaler. Contrairement à recall/remember (qui dégradent en silence), expose les erreurs pour pouvoir déboguer la mémoire long-terme sans `kubectl exec`. """ out: dict = { "enabled": True, "qdrant_url": self.qdrant, "embed_url": self.embed_url, "collection": self.collection, "embed": {"ok": False}, "qdrant": {"ok": False}, } async with httpx.AsyncClient(timeout=20) as client: # 1) embeddings try: vec = await self._embed(client, "ping mémoire") out["embed"] = {"ok": True, "dim": len(vec)} except Exception as e: # noqa: BLE001 — on veut l'erreur out["embed"] = {"ok": False, "error": f"{type(e).__name__}: {e}"} # 2) Qdrant + collection try: r = await client.get(f"{self.qdrant}/collections") r.raise_for_status() names = [c["name"] for c in r.json().get("result", {}).get("collections", [])] exists = self.collection in names qdrant: dict = {"ok": True, "collections": names, "collection_exists": exists} if exists: cr = await client.get(f"{self.qdrant}/collections/{self.collection}") if cr.status_code == 200: qdrant["points_count"] = cr.json().get("result", {}).get("points_count") out["qdrant"] = qdrant except Exception as e: # noqa: BLE001 out["qdrant"] = {"ok": False, "error": f"{type(e).__name__}: {e}"} return out async def remember(self, session_id: str, text: str) -> None: try: async with httpx.AsyncClient(timeout=20) as client: vec = await self._embed(client, text) await self._ensure_collection(client, len(vec)) await client.put( f"{self.qdrant}/collections/{self.collection}/points?wait=true", json={ "points": [ { "id": str(uuid.uuid4()), "vector": vec, "payload": { "text": text, "session_id": session_id, "ts": time.time(), }, } ] }, ) except Exception: # noqa: BLE001 — dégrade silencieusement pass