From 1db84e904197c945136ba94a50974ad2f61446bc Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Fri, 19 Jun 2026 21:53:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt-server):=20grounding=20RAG=20=E2=80=94?= =?UTF-8?q?=20le=20cerveau=20r=C3=A9pond=20depuis=20la=20doc=20Funk=20(#27?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans contexte, le modèle (qwen3-8b) hallucinait sur le homelab (« stockage décentralisé », « worker nodes »…) car il ignore tout du cluster réel. Le cerveau interroge désormais la collection funk-docs (la doc admin/ indexée) et injecte les passages pertinents au prompt → il répond à partir de la vraie doc. Coût ~nul : funk-docs et stt-memory partagent le même embedder nomic (:1238, 768 dim) → le vecteur de requête calculé pour le recall mémoire est réutilisé pour la recherche doc (une embed, deux recherches). Dégrade en silence si Qdrant/embed down (réponse sans doc plutôt qu'échec). Module knowledge.py (STT_DOCS_RAG, TOPK=6, MIN_SCORE=0.45). Log /v1/ask expose docs=. Serveur 0.2.1 → 0.3.0. Validé : « c'est quoi le nom des nœuds ? » → avant « worker/master nodes » (inventé) ; après « Kubernetes ; nœuds storage-01, compute-01, compute-02 » (tiré de la doc). Co-authored-by: Claude Opus 4.8 --- admin/ia/stt.md | 15 ++++++ stt/server/pyproject.toml | 2 +- stt/server/stt_server/__init__.py | 2 +- stt/server/stt_server/app.py | 13 +++-- stt/server/stt_server/brain.py | 9 ++++ stt/server/stt_server/config.py | 16 +++++- stt/server/stt_server/knowledge.py | 81 ++++++++++++++++++++++++++++++ 7 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 stt/server/stt_server/knowledge.py diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 96f56b7..71bc179 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -120,6 +120,20 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe espace). Aucun drop manuel — la collection est reconstruite à la première requête après bascule. - **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`. +- **RAG documentaire / grounding (5f, fait)** : le cerveau interroge aussi la collection + **`funk-docs`** (la doc `admin/` indexée) et injecte les passages pertinents au prompt → il + répond **à partir de la vraie doc du homelab** au lieu d'halluciner des généralités (« stockage + décentralisé », « worker nodes »…). Module `stt_server/knowledge.py`, activé par `STT_DOCS_RAG`. + - **Coût ~nul** : `funk-docs` et `stt-memory` partagent le **même embedder** (`nomic-embed-text` + `:1238`, dim 768) → le vecteur de requête calculé pour le recall mémoire est **réutilisé** pour + la recherche doc (une seule embed, deux recherches Qdrant). Dégrade en silence (réponse sans + doc si Qdrant/embed down). + - **Réglages** : `STT_DOCS_TOPK` (6), `STT_DOCS_MIN_SCORE` (0.45), `STT_DOCS_TIMEOUT` (4 s), + `STT_DOCS_COLLECTION` (`funk-docs`). Le `funk-docs` doit être ingéré (`rag-ingest`) **avec le + même modèle** nomic — sinon dimensions incompatibles (cf. rôle `rag`). + - **Validé** : « c'est quoi le nom des nœuds ? » → sans RAG « worker/master nodes » (inventé) ; + avec RAG « Kubernetes ; nœuds storage-01, compute-01, compute-02 » (tiré de la doc). Le log + `/v1/ask` expose `docs=` (nb d'extraits injectés). > ⚠️ **Vie privée** : seule la mémoire distillée serait committée. Repo privé impératif. @@ -242,6 +256,7 @@ optionnel — son absence n'empêche rien. | **5c — Réparation Qdrant** | drop `funk-docs` corrompu + restart (s01) | ✅ (17/06) | | **5d — Embeddings dédiés** | `nomic-embed-text` (dim 768) sur gpu-01 `:1238` → recherche plus précise | ✅ (rôle llama_server + migration auto collection) | | **5e — Fix thinking Qwen3** | `/no_think` (content vide / timeout 502) + fallback reasoning_content | ✅ (17/06) | +| **5f — RAG documentaire (grounding)** | le cerveau interroge `funk-docs` (doc `admin/`) et injecte les passages au prompt → répond depuis la vraie doc au lieu d'halluciner ; réutilise le vecteur nomic du recall | ✅ (`knowledge.py`, `STT_DOCS_RAG`) | | **6 — Auto-start client** | `stt --install-service` (systemd --user, `graphical-session.target`) + kiosk | ✅ (à tester sur poste) | | **7 — Outils Hermes** | « agir sur Funk » via gateway `:8080` (API à spécifier) | ⏳ | diff --git a/stt/server/pyproject.toml b/stt/server/pyproject.toml index 2e46234..b8e5fc2 100644 --- a/stt/server/pyproject.toml +++ b/stt/server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt-server" -version = "0.2.1" +version = "0.3.0" description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)" requires-python = ">=3.11" diff --git a/stt/server/stt_server/__init__.py b/stt/server/stt_server/__init__.py index c2022bd..9e6f9b0 100644 --- a/stt/server/stt_server/__init__.py +++ b/stt/server/stt_server/__init__.py @@ -1,3 +1,3 @@ """STT-server — orchestrateur AI in-cluster pour les clients STT.""" -__version__ = "0.2.1" +__version__ = "0.3.0" diff --git a/stt/server/stt_server/app.py b/stt/server/stt_server/app.py index aa81777..334c60d 100644 --- a/stt/server/stt_server/app.py +++ b/stt/server/stt_server/app.py @@ -19,6 +19,7 @@ from stt_server import __version__ from stt_server import brain from stt_server.brain import ask as brain_ask from stt_server.config import settings +from stt_server.knowledge import Knowledge from stt_server.longterm import LongTermMemory from stt_server.memory import SessionStore @@ -34,6 +35,7 @@ if not log.handlers: sessions = SessionStore() longterm = LongTermMemory() if settings.memory_longterm else None +knowledge = Knowledge() if settings.docs_rag else None @asynccontextmanager @@ -43,6 +45,8 @@ async def lifespan(app: FastAPI): await brain.aclose() if longterm: await longterm.aclose() + if knowledge: + await knowledge.aclose() app = FastAPI(title="STT-server", version=__version__, lifespan=lifespan) @@ -97,11 +101,13 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply: ) t0 = time.perf_counter() history = sessions.history(req.session_id) if req.session_id else None - # recall : timeout serré, dégrade vite ; renvoie aussi le vecteur (réutilisé par store) + # recall : timeout serré, dégrade vite ; renvoie aussi le vecteur (réutilisé ci-dessous) memories, qvec = await longterm.recall(text) if longterm else ([], None) + # RAG doc : on réutilise qvec (même embedder nomic) → ancre la réponse dans funk-docs + docs = await knowledge.search(text, qvec) if knowledge else [] t_recall = time.perf_counter() try: - reply = await brain_ask(text, model, history, memories) + reply = await brain_ask(text, model, history, memories, docs) except httpx.HTTPError as e: raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e t_gen = time.perf_counter() @@ -112,12 +118,13 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply: if longterm: background.add_task(longterm.store, req.session_id or "anon", text, qvec) log.info( - "ask model=%s recall=%.0fms gen=%.0fms total=%.0fms mem=%d", + "ask model=%s recall=%.0fms gen=%.0fms total=%.0fms mem=%d docs=%d", model, (t_recall - t0) * 1000, (t_gen - t_recall) * 1000, (t_gen - t0) * 1000, len(memories), + len(docs), ) return AskReply(reply=reply, model=model) diff --git a/stt/server/stt_server/brain.py b/stt/server/stt_server/brain.py index 570abf0..c12885d 100644 --- a/stt/server/stt_server/brain.py +++ b/stt/server/stt_server/brain.py @@ -34,6 +34,7 @@ async def ask( model: str | None = None, history: list[dict] | None = None, memories: list[str] | None = None, + knowledge: list[str] | None = None, ) -> str: system = settings.system_prompt if settings.disable_thinking: @@ -42,6 +43,14 @@ async def ask( # → timeout (502). Le token de contrôle `/no_think` désactive le mode raisonnement. # Inoffensif pour les modèles non-Qwen (simple texte ignoré). system += "\n/no_think" + if knowledge: + # Contexte AUTORITAIRE : extraits de la doc Funk (RAG funk-docs). Le modèle doit + # répondre à partir de ça pour les questions sur le homelab (sinon il hallucine). + extraits = "\n\n".join(knowledge) + system += ( + "\n\n=== Documentation Funk (réponds À PARTIR de ces extraits ; " + "si l'info n'y est pas, dis-le) ===\n" + extraits + ) if memories: souvenirs = "\n".join(f"- {m}" for m in memories) system += ( diff --git a/stt/server/stt_server/config.py b/stt/server/stt_server/config.py index 7ebee70..ae7f3aa 100644 --- a/stt/server/stt_server/config.py +++ b/stt/server/stt_server/config.py @@ -26,7 +26,10 @@ class Settings: "STT_SYSTEM_PROMPT", "Tu es Hermes, l'assistant vocal du homelab Funk. " "Réponds toujours en français, de façon concise (2-3 phrases maximum), " - "sans markdown ni listes.", + "sans markdown ni listes. " + "Pour toute question sur Funk (cluster, machines, services, configuration), appuie-toi " + "EXCLUSIVEMENT sur la documentation fournie ci-dessous ; si l'information ne s'y " + "trouve pas, dis-le franchement plutôt que d'inventer.", ) max_tokens: int = int(os.getenv("STT_MAX_TOKENS", "200")) temperature: float = float(os.getenv("STT_TEMPERATURE", "0.7")) @@ -51,5 +54,16 @@ class Settings: memory_recall_timeout: float = float(os.getenv("STT_MEMORY_RECALL_TIMEOUT", "4")) memory_store_timeout: float = float(os.getenv("STT_MEMORY_STORE_TIMEOUT", "20")) + # RAG documentaire : ancre les réponses dans la doc Funk (collection `funk-docs`, + # même embedder nomic :1238 que la mémoire → le vecteur de requête est réutilisé). + # Sans ça, le modèle répond de connaissances générales (hallucine sur le homelab). + docs_rag: bool = os.getenv("STT_DOCS_RAG", "true").lower() == "true" + docs_collection: str = os.getenv("STT_DOCS_COLLECTION", "funk-docs") + docs_top_k: int = int(os.getenv("STT_DOCS_TOPK", "6")) + # nomic étale les scores plus bas → seuil permissif (aligné sur rag-query). + docs_min_score: float = float(os.getenv("STT_DOCS_MIN_SCORE", "0.45")) + # Sur le chemin de réponse (1 recherche Qdrant, vecteur déjà calculé) → timeout serré. + docs_timeout: float = float(os.getenv("STT_DOCS_TIMEOUT", "4")) + settings = Settings() diff --git a/stt/server/stt_server/knowledge.py b/stt/server/stt_server/knowledge.py new file mode 100644 index 0000000..5df7153 --- /dev/null +++ b/stt/server/stt_server/knowledge.py @@ -0,0 +1,81 @@ +"""RAG documentaire — ancre les réponses dans la doc Funk (collection `funk-docs`). + +Recherche sémantique dans Qdrant pour injecter des passages de la documentation `admin/` +dans le prompt → le cerveau répond à partir de la **vraie doc du homelab** plutôt que de +connaissances générales (sinon il hallucine : « stockage décentralisé », « worker nodes »…). + +Même embedder que la mémoire long-terme (`nomic-embed-text`, gpu-01 `:1238`, dim 768) → le +vecteur de requête déjà calculé par `LongTermMemory.recall` est **réutilisé** ici (pas de 2ᵉ +embed). Dégrade proprement : si Qdrant/embeddings sont injoignables, renvoie `[]` et le cerveau +répond sans contexte doc plutôt que d'échouer. +""" + +from __future__ import annotations + +import httpx + +from stt_server.config import settings + + +class Knowledge: + def __init__(self) -> None: + self.qdrant = settings.qdrant_url.rstrip("/") + self.collection = settings.docs_collection + self.embed_url = settings.embed_url + self.embed_model = settings.embed_model + self.top_k = settings.docs_top_k + self.min_score = settings.docs_min_score + self.timeout = settings.docs_timeout + # Client persistant : pooling + keep-alive (évite un handshake TCP par appel). + self._http: httpx.AsyncClient | None = None + + def _client(self) -> httpx.AsyncClient: + if self._http is None or self._http.is_closed: + self._http = httpx.AsyncClient() + return self._http + + async def aclose(self) -> None: + if self._http is not None and not self._http.is_closed: + await self._http.aclose() + + async def search(self, text: str, vec: list[float] | None = None) -> list[str]: + """Passages de doc pertinents pour `text`. + + `vec` : embedding nomic de la requête, réutilisé depuis le recall mémoire (même + modèle/dim) → évite un 2ᵉ embed. Absent (mémoire off / recall échoué) → on embed ici. + Timeout serré, dégrade en `[]` (jamais d'exception sur le chemin de réponse). + """ + try: + client = self._client() + if vec is None: + r = await client.post( + self.embed_url, + json={"model": self.embed_model, "input": text}, + timeout=self.timeout, + ) + r.raise_for_status() + vec = r.json()["data"][0]["embedding"] + r = await client.post( + f"{self.qdrant}/collections/{self.collection}/points/search", + json={ + "vector": vec, + "limit": self.top_k, + "with_payload": True, + "score_threshold": self.min_score, + }, + timeout=self.timeout, + ) + if r.status_code == 404: # collection absente (RAG pas ingéré) → pas de contexte + return [] + r.raise_for_status() + out: list[str] = [] + for p in r.json().get("result", []): + payload = p.get("payload", {}) + txt = payload.get("text") + if not txt: + continue + src = payload.get("file", "") + out.append(f"[{src}] {txt}" if src else txt) + return out + except Exception: # noqa: BLE001 — dégrade silencieusement (réponse sans doc) + return []