mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 21:04:43 +02:00
feat(stt-server): grounding RAG — le cerveau répond depuis la doc Funk
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:
parent
9feb02ed70
commit
3275885a39
7 changed files with 132 additions and 6 deletions
|
|
@ -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=<n>` (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) | ⏳ |
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.3.0"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 += (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
81
stt/server/stt_server/knowledge.py
Normal file
81
stt/server/stt_server/knowledge.py
Normal 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 []
|
||||
Loading…
Add table
Add a link
Reference in a new issue