diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 20a2eee..1b27db0 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -86,12 +86,13 @@ switch global type `hermes-switch` (pas de restart, chaque client choisit le sie - **Court-terme (5a, fait)** : le serveur garde l'historique par `session_id` (en mémoire, borné `max_turns` + TTL) et l'injecte dans l'appel LLM. `/v1/ask {session_id}` + `/v1/reset`. Le client génère un `session_id` par run. Deployment en **1 worker** (cohérence mémoire process). -- **Long-terme (5b, à venir)** : collection Qdrant dédiée `stt-memory` (indépendante du - `funk-docs` du RAG), embeddings via Qwen3 `:1234` (dim 4096), injection des souvenirs - pertinents. Joint Qdrant en IP directe `192.168.10.1:6333`. Dégrade proprement si Qdrant down. - **Prérequis** : réparer Qdrant (5c). -- **Réparation Qdrant (5c)** : crash-loop depuis 05/06 (segment `funk-docs` corrompu). - Sur s01 : `systemctl stop qdrant && rm -rf /srv/data/qdrant/storage/collections/funk-docs && systemctl start qdrant` (voir `admin/incidents.md`). +- **Long-terme (5b, fait)** : collection Qdrant dédiée `stt-memory` (indépendante du + `funk-docs` du RAG), embeddings via Qwen3 `:1234` (dim 4096, Cosine), souvenirs pertinents + (top-k) injectés au prompt. Qdrant en IP directe `192.168.10.1:6333`. **Dégrade proprement** + si Qdrant/embeddings injoignables (la mémoire court-terme tient). Caveat qualité : embeddings + Qwen3 médiocres → recherche approximative (piste `nomic-embed-text`, voir `admin/ia/rag.md`). +- **Réparation Qdrant (5c, fait)** : crash-loop depuis 05/06 (segment `funk-docs` corrompu). + Réparé le 17/06 sur s01 : `systemctl stop qdrant && rm -rf /srv/data/qdrant/storage/collections/funk-docs && systemctl start qdrant`. > ⚠️ **Vie privée** : seule la mémoire distillée serait committée. Repo privé impératif. @@ -140,8 +141,8 @@ Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du | **3 — Déploiement cluster** | image ghcr + manifests k8s + ArgoCD (LiteLLM en IP directe) | ✅ déployé | | **4 — HUD avancé** | visualiseur arc-reactor + thèmes + écran réglages | ⏳ | | **5a — Mémoire court-terme** | historique de session côté serveur | ✅ | -| **5b — Mémoire long-terme** | Qdrant `stt-memory` + embeddings (après 5c) | ⏳ | -| **5c — Réparation Qdrant** | drop `funk-docs` corrompu + restart (s01) | ⏳ (action infra) | +| **5b — Mémoire long-terme** | Qdrant `stt-memory` + embeddings Qwen3 (dégrade si down) | ✅ | +| **5c — Réparation Qdrant** | drop `funk-docs` corrompu + restart (s01) | ✅ (17/06) | | **6 — Auto-start client** | `install-service.sh` (systemd --user) + kiosk | ⏳ | | **7 — Outils Hermes** | « agir sur Funk » via gateway `:8080` (API à spécifier) | ⏳ | diff --git a/k8s/apps/stt/deployment.yaml b/k8s/apps/stt/deployment.yaml index 2dae854..1b8575b 100644 --- a/k8s/apps/stt/deployment.yaml +++ b/k8s/apps/stt/deployment.yaml @@ -41,6 +41,13 @@ spec: value: "lm-studio" - name: STT_MODEL value: "hermes-default" + # Mémoire long-terme (Qdrant s01 + embeddings Qwen3 gpu-01) — dégrade si injoignable + - name: STT_MEMORY_LONGTERM + value: "true" + - name: STT_QDRANT_URL + value: "http://192.168.10.1:6333" + - name: STT_EMBED_URL + value: "http://192.168.10.20:1234/v1/embeddings" readinessProbe: httpGet: path: /healthz diff --git a/stt/server/README.md b/stt/server/README.md index 68752b9..6dd82c2 100644 --- a/stt/server/README.md +++ b/stt/server/README.md @@ -30,6 +30,10 @@ la cohérence de la mémoire process. Mémoire long-terme Qdrant = phase 5b.) | `STT_ALLOWED_MODELS` | `hermes-default,qwen3-8b,claude-sonnet-4-6,claude-opus-4-7` | alias autorisés (demandables par le client) | | `STT_SYSTEM_PROMPT` | prompt vocal FR concis | persona | | `STT_MAX_TOKENS` / `STT_TEMPERATURE` | `200` / `0.7` | génération | +| `STT_MEMORY_LONGTERM` | `true` | mémoire long-terme Qdrant (dégrade si injoignable) | +| `STT_QDRANT_URL` | `http://192.168.10.1:6333` | Qdrant (s01) | +| `STT_EMBED_URL` | `http://192.168.10.20:1234/v1/embeddings` | embeddings Qwen3 (gpu-01) | +| `STT_MEMORY_TOPK` | `3` | nombre de souvenirs injectés | ## Dev local @@ -48,9 +52,15 @@ curl -s localhost:8000/v1/ask -H 'content-type: application/json' -d '{"text":"b - Manifests : `k8s/apps/stt/` ; Application ArgoCD : `k8s/apps-of-apps/apps/stt.yaml` (depuis `main`). - Accès LiteLLM (hors cluster) : IP directe `192.168.10.1:4000` (même pattern qu'open-webui). +## Mémoire + +- **Court-terme** (`memory.py`) : historique par `session_id` (en mémoire, borné + TTL). +- **Long-terme** (`longterm.py`) : collection Qdrant `stt-memory` (vecteurs des tours user, + embeddings Qwen3), souvenirs pertinents injectés au prompt. Dégrade si Qdrant/embeddings down. + ## À venir - Intégration des **outils Hermes** (« agir sur Funk ») via le gateway `:8080` — nécessite de spécifier son API. Aujourd'hui : inférence/chat seulement (via LiteLLM). -- **Mémoire** centralisée : Qdrant (s01) + distillation versionnée (`server/memory/`). -- Sessions multi-clients + historique. +- Embedding dédié (`nomic-embed-text`) pour une recherche sémantique de meilleure qualité. +- Mémoire **distillée** versionnée (faits/préférences) dans `server/memory/`. diff --git a/stt/server/stt_server/app.py b/stt/server/stt_server/app.py index fca5e94..9cd936c 100644 --- a/stt/server/stt_server/app.py +++ b/stt/server/stt_server/app.py @@ -14,10 +14,12 @@ from pydantic import BaseModel from stt_server import __version__ from stt_server.brain import ask as brain_ask from stt_server.config import settings +from stt_server.longterm import LongTermMemory from stt_server.memory import SessionStore app = FastAPI(title="STT-server", version=__version__) sessions = SessionStore() +longterm = LongTermMemory() if settings.memory_longterm else None class AskRequest(BaseModel): @@ -60,13 +62,16 @@ async def v1_ask(req: AskRequest) -> AskReply: detail=f"modèle '{model}' non autorisé ; dispo : {settings.allowed_models}", ) history = sessions.history(req.session_id) if req.session_id else None + memories = await longterm.recall(text) if longterm else None try: - reply = await brain_ask(text, model, history) + reply = await brain_ask(text, model, history, memories) except httpx.HTTPError as e: raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e if req.session_id: sessions.add(req.session_id, "user", text) sessions.add(req.session_id, "assistant", reply) + if longterm: + await longterm.remember(req.session_id or "anon", text) return AskReply(reply=reply, model=model) diff --git a/stt/server/stt_server/brain.py b/stt/server/stt_server/brain.py index 967fd1f..7cfd3b9 100644 --- a/stt/server/stt_server/brain.py +++ b/stt/server/stt_server/brain.py @@ -12,8 +12,20 @@ import httpx from stt_server.config import settings -async def ask(text: str, model: str | None = None, history: list[dict] | None = None) -> str: - messages = [{"role": "system", "content": settings.system_prompt}] +async def ask( + text: str, + model: str | None = None, + history: list[dict] | None = None, + memories: list[str] | None = None, +) -> str: + system = settings.system_prompt + if memories: + souvenirs = "\n".join(f"- {m}" for m in memories) + system += ( + "\n\nÉléments de mémoire long-terme (peuvent aider, ignore si hors-sujet) :\n" + + souvenirs + ) + messages = [{"role": "system", "content": system}] if history: messages += history messages.append({"role": "user", "content": text}) diff --git a/stt/server/stt_server/config.py b/stt/server/stt_server/config.py index a33ddff..dfebb0a 100644 --- a/stt/server/stt_server/config.py +++ b/stt/server/stt_server/config.py @@ -32,5 +32,14 @@ class Settings: temperature: float = float(os.getenv("STT_TEMPERATURE", "0.7")) request_timeout: float = float(os.getenv("STT_REQUEST_TIMEOUT", "60")) + # Mémoire long-terme (Qdrant) — dégrade proprement si Qdrant/embeddings injoignables + memory_longterm: bool = os.getenv("STT_MEMORY_LONGTERM", "true").lower() == "true" + qdrant_url: str = os.getenv("STT_QDRANT_URL", "http://192.168.10.1:6333") + qdrant_collection: str = os.getenv("STT_QDRANT_COLLECTION", "stt-memory") + # Embeddings : Qwen3 sur llama-server gpu-01 (comme le RAG). dim 4096. + embed_url: str = os.getenv("STT_EMBED_URL", "http://192.168.10.20:1234/v1/embeddings") + embed_model: str = os.getenv("STT_EMBED_MODEL", "qwen3-8b") + memory_top_k: int = int(os.getenv("STT_MEMORY_TOPK", "3")) + settings = Settings() diff --git a/stt/server/stt_server/longterm.py b/stt/server/stt_server/longterm.py new file mode 100644 index 0000000..fd5f580 --- /dev/null +++ b/stt/server/stt_server/longterm.py @@ -0,0 +1,91 @@ +"""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 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", + 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