From 815beaf37c6a5269eb540325685737a36eb633fe Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Wed, 17 Jun 2026 16:15:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt):=20m=C3=A9moire=20court-terme=20de=20?= =?UTF-8?q?session=20c=C3=B4t=C3=A9=20serveur=20(5a)=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serveur : SessionStore (historique borné + TTL, en mémoire), /v1/ask accepte session_id et injecte l'historique dans l'appel LLM, /v1/reset l'efface. Dockerfile en 1 worker (cohérence mémoire process). Client : session_id généré par run (uuid), envoyé à chaque requête ; commande /reset en mode texte. Testé en process (TestClient) : historique croît 0→2→4, reset→0, sessions isolées, sans session_id = sans état. Mémoire long-terme Qdrant (5b) + réparation Qdrant (5c) à suivre. Claude-Session: https://claude.ai/code/session_013FmcxGsyXZXogiAHQLjnZT Co-authored-by: Claude --- admin/ia/stt.md | 18 +++++++++++----- stt/client/stt/api.py | 21 +++++++++++++++++- stt/client/stt/cli.py | 6 +++++- stt/server/Dockerfile | 4 ++-- stt/server/README.md | 8 ++++++- stt/server/stt_server/app.py | 18 ++++++++++++++-- stt/server/stt_server/brain.py | 11 +++++----- stt/server/stt_server/memory.py | 38 +++++++++++++++++++++++++++++++++ 8 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 stt/server/stt_server/memory.py diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 52e6eb8..20a2eee 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -81,11 +81,17 @@ switch global type `hermes-switch` (pas de restart, chaque client choisit le sie `GET /v1/models` liste le défaut + les alias autorisés. Noms courts client : `hermes` (=hermes-default), `qwen`, `claude` (=claude-sonnet-4-6), `opus`. -### La mémoire — côté serveur (futur) +### La mémoire — côté serveur -- **Client** : cache local SQLite (`~/.local/share/stt/`, gitignoré) — historique brut. -- **Serveur** (à venir) : Qdrant s01 (sémantique, partagé RAG) + mémoire **distillée** - versionnée dans `stt/server/memory/distilled/`. Sessions multi-clients. +- **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`). > ⚠️ **Vie privée** : seule la mémoire distillée serait committée. Repo privé impératif. @@ -133,7 +139,9 @@ Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du | **2 — STT-server** | FastAPI `/v1/ask` → LiteLLM | ✅ | | **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 | ⏳ | -| **5 — Mémoire serveur** | Qdrant s01 + distillée GitHub + sessions | ⏳ | +| **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) | | **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/stt/client/stt/api.py b/stt/client/stt/api.py index 176cf25..08c839d 100644 --- a/stt/client/stt/api.py +++ b/stt/client/stt/api.py @@ -7,6 +7,7 @@ choisit le modèle (alias LiteLLM) et le passe au serveur par requête. from __future__ import annotations +import uuid from typing import Any @@ -15,13 +16,19 @@ class ServerClient: self.url = cfg["url"].rstrip("/") self.timeout = cfg.get("timeout_sec", 90) self.model = cfg.get("model", "hermes-default") + # une session par run → mémoire court-terme côté serveur + self.session_id = uuid.uuid4().hex def ask(self, text: str, model: str | None = None) -> str: import requests r = requests.post( f"{self.url}/v1/ask", - json={"text": text, "model": model or self.model}, + json={ + "text": text, + "model": model or self.model, + "session_id": self.session_id, + }, timeout=self.timeout, ) if r.status_code == 400: @@ -29,6 +36,18 @@ class ServerClient: r.raise_for_status() return r.json()["reply"].strip() + def reset(self) -> None: + import requests + + try: + requests.post( + f"{self.url}/v1/reset", + json={"text": "", "session_id": self.session_id}, + timeout=10, + ) + except requests.RequestException: + pass + def models(self) -> dict: """{'default': ..., 'available': [...]} depuis le serveur.""" import requests diff --git a/stt/client/stt/cli.py b/stt/client/stt/cli.py index dd97831..75b342e 100644 --- a/stt/client/stt/cli.py +++ b/stt/client/stt/cli.py @@ -41,7 +41,7 @@ def _cmd_text(args) -> int: print(f"STT (texte) — serveur {client.url} — modèle '{client.model}'") if not client.healthy(): print(" ⚠️ serveur injoignable — vérifie [server] url et le réseau") - print(" commandes : /model , /models, /quit\n") + print(" commandes : /model , /models, /reset, /quit\n") from stt.config import resolve_model @@ -55,6 +55,10 @@ def _cmd_text(args) -> int: continue if line in ("/quit", "/exit", "/q"): return 0 + if line == "/reset": + client.reset() + print(" → mémoire de session effacée\n") + continue if line == "/models": try: m = client.models() diff --git a/stt/server/Dockerfile b/stt/server/Dockerfile index b3d02bc..6b1ac31 100644 --- a/stt/server/Dockerfile +++ b/stt/server/Dockerfile @@ -6,5 +6,5 @@ COPY stt_server ./stt_server RUN pip install --no-cache-dir . EXPOSE 8000 -# Service interne au cluster (exposé via Traefik). uvicorn multi-workers. -CMD ["uvicorn", "stt_server.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] +# 1 worker : la mémoire de session est en mémoire process (cohérence mono-worker). +CMD ["uvicorn", "stt_server.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/stt/server/README.md b/stt/server/README.md index c662aa6..5bb2054 100644 --- a/stt/server/README.md +++ b/stt/server/README.md @@ -9,11 +9,17 @@ STT et route l'inférence vers LiteLLM (s01) → Qwen3 (g01) / Claude. |---|---|---|---| | `GET` | `/healthz` | — | `{status, version}` | | `GET` | `/v1/models` | — | `{default, available[]}` | -| `POST` | `/v1/ask` | `{text, model?}` | `{reply, model}` | +| `POST` | `/v1/ask` | `{text, model?, session_id?}` | `{reply, model}` | +| `POST` | `/v1/reset` | `{session_id}` | `{status}` | `model` (optionnel) = alias LiteLLM ; défaut serveur si absent ; rejeté (400) si absent de `STT_ALLOWED_MODELS`. Le client choisit le modèle par requête (pas de switch global). +`session_id` (optionnel) active la **mémoire court-terme** : le serveur garde l'historique +de la conversation (en mémoire, borné + TTL) et l'injecte dans l'appel LLM. `/v1/reset` +l'efface. Sans `session_id`, chaque requête est sans état. (Deployment en 1 worker pour +la cohérence de la mémoire process. Mémoire long-terme Qdrant = phase 5b.) + ## Configuration (variables d'env) | Var | Défaut | Rôle | diff --git a/stt/server/stt_server/app.py b/stt/server/stt_server/app.py index 5a0067f..fca5e94 100644 --- a/stt/server/stt_server/app.py +++ b/stt/server/stt_server/app.py @@ -14,13 +14,16 @@ 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.memory import SessionStore app = FastAPI(title="STT-server", version=__version__) +sessions = SessionStore() class AskRequest(BaseModel): text: str - model: str | None = None # alias LiteLLM ; défaut serveur si absent + model: str | None = None # alias LiteLLM ; défaut serveur si absent + session_id: str | None = None # mémoire court-terme : fil de conversation class AskReply(BaseModel): @@ -38,6 +41,13 @@ async def v1_models() -> dict: return {"default": settings.model, "available": settings.allowed_models} +@app.post("/v1/reset") +async def v1_reset(req: AskRequest) -> dict: + if req.session_id: + sessions.reset(req.session_id) + return {"status": "reset"} + + @app.post("/v1/ask", response_model=AskReply) async def v1_ask(req: AskRequest) -> AskReply: text = req.text.strip() @@ -49,10 +59,14 @@ async def v1_ask(req: AskRequest) -> AskReply: status_code=400, detail=f"modèle '{model}' non autorisé ; dispo : {settings.allowed_models}", ) + history = sessions.history(req.session_id) if req.session_id else None try: - reply = await brain_ask(text, model) + reply = await brain_ask(text, model, history) 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) return AskReply(reply=reply, model=model) diff --git a/stt/server/stt_server/brain.py b/stt/server/stt_server/brain.py index e8ea395..967fd1f 100644 --- a/stt/server/stt_server/brain.py +++ b/stt/server/stt_server/brain.py @@ -12,13 +12,14 @@ import httpx from stt_server.config import settings -async def ask(text: str, model: str | None = None) -> str: +async def ask(text: str, model: str | None = None, history: list[dict] | None = None) -> str: + messages = [{"role": "system", "content": settings.system_prompt}] + if history: + messages += history + messages.append({"role": "user", "content": text}) payload = { "model": model or settings.model, - "messages": [ - {"role": "system", "content": settings.system_prompt}, - {"role": "user", "content": text}, - ], + "messages": messages, "max_tokens": settings.max_tokens, "temperature": settings.temperature, } diff --git a/stt/server/stt_server/memory.py b/stt/server/stt_server/memory.py new file mode 100644 index 0000000..88f3f74 --- /dev/null +++ b/stt/server/stt_server/memory.py @@ -0,0 +1,38 @@ +"""Mémoire court-terme côté serveur : historique de conversation par session. + +En mémoire (process), borné et avec TTL. Suffisant pour un usage mono-utilisateur ; +le Deployment tourne en 1 worker pour que les sessions soient cohérentes. +La mémoire long-terme sémantique (Qdrant) viendra en phase 5b. +""" + +from __future__ import annotations + +import time +from collections import deque + + +class SessionStore: + def __init__(self, max_turns: int = 10, ttl_sec: int = 3600): + self.max_turns = max_turns # paires user/assistant conservées + self.ttl = ttl_sec + self._data: dict[str, dict] = {} # sid -> {"turns": deque, "ts": float} + + def _gc(self) -> None: + now = time.time() + for sid in [s for s, v in self._data.items() if now - v["ts"] > self.ttl]: + del self._data[sid] + + def history(self, sid: str) -> list[dict]: + self._gc() + sess = self._data.get(sid) + return list(sess["turns"]) if sess else [] + + def add(self, sid: str, role: str, content: str) -> None: + sess = self._data.setdefault( + sid, {"turns": deque(maxlen=self.max_turns * 2), "ts": time.time()} + ) + sess["turns"].append({"role": role, "content": content}) + sess["ts"] = time.time() + + def reset(self, sid: str) -> None: + self._data.pop(sid, None)