mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 07:54:42 +02:00
feat(stt): mémoire court-terme de session côté serveur (5a) (#8)
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 <noreply@anthropic.com>
This commit is contained in:
parent
bcdb4f8b2a
commit
815beaf37c
8 changed files with 107 additions and 17 deletions
|
|
@ -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`
|
`GET /v1/models` liste le défaut + les alias autorisés. Noms courts client : `hermes`
|
||||||
(=hermes-default), `qwen`, `claude` (=claude-sonnet-4-6), `opus`.
|
(=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.
|
- **Court-terme (5a, fait)** : le serveur garde l'historique par `session_id` (en mémoire,
|
||||||
- **Serveur** (à venir) : Qdrant s01 (sémantique, partagé RAG) + mémoire **distillée**
|
borné `max_turns` + TTL) et l'injecte dans l'appel LLM. `/v1/ask {session_id}` + `/v1/reset`.
|
||||||
versionnée dans `stt/server/memory/distilled/`. Sessions multi-clients.
|
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.
|
> ⚠️ **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 | ✅ |
|
| **2 — STT-server** | FastAPI `/v1/ask` → LiteLLM | ✅ |
|
||||||
| **3 — Déploiement cluster** | image ghcr + manifests k8s + ArgoCD (LiteLLM en IP directe) | ✅ déployé |
|
| **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 | ⏳ |
|
| **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 | ⏳ |
|
| **6 — Auto-start client** | `install-service.sh` (systemd --user) + kiosk | ⏳ |
|
||||||
| **7 — Outils Hermes** | « agir sur Funk » via gateway `:8080` (API à spécifier) | ⏳ |
|
| **7 — Outils Hermes** | « agir sur Funk » via gateway `:8080` (API à spécifier) | ⏳ |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ choisit le modèle (alias LiteLLM) et le passe au serveur par requête.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,13 +16,19 @@ class ServerClient:
|
||||||
self.url = cfg["url"].rstrip("/")
|
self.url = cfg["url"].rstrip("/")
|
||||||
self.timeout = cfg.get("timeout_sec", 90)
|
self.timeout = cfg.get("timeout_sec", 90)
|
||||||
self.model = cfg.get("model", "hermes-default")
|
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:
|
def ask(self, text: str, model: str | None = None) -> str:
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{self.url}/v1/ask",
|
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,
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
if r.status_code == 400:
|
if r.status_code == 400:
|
||||||
|
|
@ -29,6 +36,18 @@ class ServerClient:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()["reply"].strip()
|
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:
|
def models(self) -> dict:
|
||||||
"""{'default': ..., 'available': [...]} depuis le serveur."""
|
"""{'default': ..., 'available': [...]} depuis le serveur."""
|
||||||
import requests
|
import requests
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ def _cmd_text(args) -> int:
|
||||||
print(f"STT (texte) — serveur {client.url} — modèle '{client.model}'")
|
print(f"STT (texte) — serveur {client.url} — modèle '{client.model}'")
|
||||||
if not client.healthy():
|
if not client.healthy():
|
||||||
print(" ⚠️ serveur injoignable — vérifie [server] url et le réseau")
|
print(" ⚠️ serveur injoignable — vérifie [server] url et le réseau")
|
||||||
print(" commandes : /model <nom>, /models, /quit\n")
|
print(" commandes : /model <nom>, /models, /reset, /quit\n")
|
||||||
|
|
||||||
from stt.config import resolve_model
|
from stt.config import resolve_model
|
||||||
|
|
||||||
|
|
@ -55,6 +55,10 @@ def _cmd_text(args) -> int:
|
||||||
continue
|
continue
|
||||||
if line in ("/quit", "/exit", "/q"):
|
if line in ("/quit", "/exit", "/q"):
|
||||||
return 0
|
return 0
|
||||||
|
if line == "/reset":
|
||||||
|
client.reset()
|
||||||
|
print(" → mémoire de session effacée\n")
|
||||||
|
continue
|
||||||
if line == "/models":
|
if line == "/models":
|
||||||
try:
|
try:
|
||||||
m = client.models()
|
m = client.models()
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@ COPY stt_server ./stt_server
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
# Service interne au cluster (exposé via Traefik). uvicorn multi-workers.
|
# 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", "2"]
|
CMD ["uvicorn", "stt_server.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,17 @@ STT et route l'inférence vers LiteLLM (s01) → Qwen3 (g01) / Claude.
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/healthz` | — | `{status, version}` |
|
| `GET` | `/healthz` | — | `{status, version}` |
|
||||||
| `GET` | `/v1/models` | — | `{default, available[]}` |
|
| `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
|
`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).
|
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)
|
## Configuration (variables d'env)
|
||||||
|
|
||||||
| Var | Défaut | Rôle |
|
| Var | Défaut | Rôle |
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,16 @@ from pydantic import BaseModel
|
||||||
from stt_server import __version__
|
from stt_server import __version__
|
||||||
from stt_server.brain import ask as brain_ask
|
from stt_server.brain import ask as brain_ask
|
||||||
from stt_server.config import settings
|
from stt_server.config import settings
|
||||||
|
from stt_server.memory import SessionStore
|
||||||
|
|
||||||
app = FastAPI(title="STT-server", version=__version__)
|
app = FastAPI(title="STT-server", version=__version__)
|
||||||
|
sessions = SessionStore()
|
||||||
|
|
||||||
|
|
||||||
class AskRequest(BaseModel):
|
class AskRequest(BaseModel):
|
||||||
text: str
|
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):
|
class AskReply(BaseModel):
|
||||||
|
|
@ -38,6 +41,13 @@ async def v1_models() -> dict:
|
||||||
return {"default": settings.model, "available": settings.allowed_models}
|
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)
|
@app.post("/v1/ask", response_model=AskReply)
|
||||||
async def v1_ask(req: AskRequest) -> AskReply:
|
async def v1_ask(req: AskRequest) -> AskReply:
|
||||||
text = req.text.strip()
|
text = req.text.strip()
|
||||||
|
|
@ -49,10 +59,14 @@ async def v1_ask(req: AskRequest) -> AskReply:
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"modèle '{model}' non autorisé ; dispo : {settings.allowed_models}",
|
detail=f"modèle '{model}' non autorisé ; dispo : {settings.allowed_models}",
|
||||||
)
|
)
|
||||||
|
history = sessions.history(req.session_id) if req.session_id else None
|
||||||
try:
|
try:
|
||||||
reply = await brain_ask(text, model)
|
reply = await brain_ask(text, model, history)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from 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)
|
return AskReply(reply=reply, model=model)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,14 @@ import httpx
|
||||||
from stt_server.config import settings
|
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 = {
|
payload = {
|
||||||
"model": model or settings.model,
|
"model": model or settings.model,
|
||||||
"messages": [
|
"messages": messages,
|
||||||
{"role": "system", "content": settings.system_prompt},
|
|
||||||
{"role": "user", "content": text},
|
|
||||||
],
|
|
||||||
"max_tokens": settings.max_tokens,
|
"max_tokens": settings.max_tokens,
|
||||||
"temperature": settings.temperature,
|
"temperature": settings.temperature,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
stt/server/stt_server/memory.py
Normal file
38
stt/server/stt_server/memory.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue