Funk-lab/stt/server/stt_server/longterm.py
Claude 34a8502e7f
feat(stt): endpoint /v1/memory/health + upsert Qdrant synchrone
- /v1/memory/health sonde activement embeddings + Qdrant + collection et
  expose les erreurs (recall/remember dégradent en silence → indébogables).
  Permet de diagnostiquer la mémoire long-terme sans kubectl exec.
- remember() : upsert avec ?wait=true → le souvenir est immédiatement
  cherchable (sans wait, Qdrant met l'écriture en file → un recall
  cross-session immédiat pouvait le rater).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013FmcxGsyXZXogiAHQLjnZT
2026-06-17 18:27:29 +00:00

128 lines
5.4 KiB
Python

"""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 Qwen3 (llama-server
gpu-01), comme le RAG. **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).
> Caveat qualité : Qwen3 n'est pas un modèle d'embedding dédié (cosinus uniformément hauts) ;
> la recherche est approximative. Voir admin/ia/rag.md pour la piste nomic-embed-text.
"""
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 == 404:
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