feat(stt-server): grounding RAG — le cerveau répond depuis la doc Funk (#27)

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=<n>. 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 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-19 21:53:30 +02:00 committed by GitHub
parent 767a94b1d0
commit 1db84e9041
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 132 additions and 6 deletions

View file

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

View file

@ -1,3 +1,3 @@
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
__version__ = "0.2.1"
__version__ = "0.3.0"

View file

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

View file

@ -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 += (

View file

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

View file

@ -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 []