feat(stt): mémoire court-terme de session côté serveur (5a)

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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013FmcxGsyXZXogiAHQLjnZT
This commit is contained in:
Claude 2026-06-17 13:25:20 +00:00
parent bcdb4f8b2a
commit 20dc7e4f82
No known key found for this signature in database
8 changed files with 107 additions and 17 deletions

View file

@ -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)

View file

@ -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,
}

View 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)