diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 648eb11..2018aea 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -87,6 +87,21 @@ 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` (=hermes-default), `qwen`, `claude` (=claude-sonnet-4-6), `opus`. +**Contextes présélectionnables (visualiseur + presets live)** : le client envoie un `context` +par requête à `/v1/ask` (`GET /v1/contexts` liste les profils). Chaque profil (`contexts.py`) +a son **system prompt** et ses **sources live** injectées dans le contexte (`sources.py`, +best-effort, env-config) : +- `funk` — grounding strict + RAG `funk-docs` (défaut, comportement historique) +- `ghostfolio` — valeur/positions du portefeuille (API Ghostfolio, `STT_GHOSTFOLIO_TOKEN`) +- `grafana` — métriques clés (Prometheus) +- `alerting` — alertes actives (Alertmanager, hors Watchdog) +- `cluster` — état pods/nœuds (Prometheus/kube-state-metrics) + RAG doc + +La réponse `/v1/ask` renvoie le **contexte assemblé** (`context`: system prompt + blocs live + +extraits RAG + mémoire) → alimente le **visualiseur** du HUD (« voir ce qu'on envoie à Asa »). +URLs in-cluster dans `k8s/apps/stt/deployment.yaml` (Prometheus/Alertmanager `monitoring`, +Ghostfolio `ai`). Jeton Ghostfolio = secret optionnel `stt-server-secrets/ghostfolio-token`. + **Caveat Qwen3 — mode « thinking » (corrigé)** : Qwen3 est un modèle de raisonnement. Sans précaution, il dépense tout le budget `max_tokens` (200) dans `reasoning_content` → `content` **vide**, ou réfléchit trop longtemps → **timeout 502** (`upstream LiteLLM : ` au message vide — diff --git a/k8s/apps/stt/deployment.yaml b/k8s/apps/stt/deployment.yaml index 020af1e..22b1b43 100644 --- a/k8s/apps/stt/deployment.yaml +++ b/k8s/apps/stt/deployment.yaml @@ -51,6 +51,24 @@ spec: value: "http://192.168.10.20:1238/v1/embeddings" - name: STT_EMBED_MODEL value: "nomic-embed-text" + # Contextes présélectionnables (sources live) — services in-cluster + - name: STT_DEFAULT_CONTEXT + value: "funk" + - name: STT_PROMETHEUS_URL + value: "http://kube-prometheus-stack-prometheus.monitoring:9090" + - name: STT_ALERTMANAGER_URL + value: "http://kube-prometheus-stack-alertmanager.monitoring:9093" + - name: STT_GHOSTFOLIO_URL + value: "http://ghostfolio:3333" # même namespace (ai) + # Jeton Ghostfolio (« Security token ») — secret optionnel créé à la main : + # kubectl -n ai create secret generic stt-server-secrets \ + # --from-literal=ghostfolio-token= + - name: STT_GHOSTFOLIO_TOKEN + valueFrom: + secretKeyRef: + name: stt-server-secrets + key: ghostfolio-token + optional: true readinessProbe: httpGet: path: /healthz diff --git a/stt/server/stt_server/__init__.py b/stt/server/stt_server/__init__.py index 5e37c1d..68864ea 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.3.1" +__version__ = "0.4.0" diff --git a/stt/server/stt_server/app.py b/stt/server/stt_server/app.py index 334c60d..4bd0c36 100644 --- a/stt/server/stt_server/app.py +++ b/stt/server/stt_server/app.py @@ -19,9 +19,11 @@ 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.contexts import CONTEXTS, assemble, get_context from stt_server.knowledge import Knowledge from stt_server.longterm import LongTermMemory from stt_server.memory import SessionStore +from stt_server.sources import fetch_blocks log = logging.getLogger("stt_server") # uvicorn ne configure que ses propres loggers : on attache notre handler en INFO @@ -54,13 +56,16 @@ app = FastAPI(title="STT-server", version=__version__, lifespan=lifespan) 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 + context: str | None = None # contexte présélectionné (funk/ghostfolio/…) class AskReply(BaseModel): reply: str model: str + context_id: str # contexte effectivement utilisé + context: dict | None = None # contexte assemblé (visualiseur HUD) @app.get("/healthz") @@ -73,6 +78,18 @@ async def v1_models() -> dict: return {"default": settings.model, "available": settings.allowed_models} +@app.get("/v1/contexts") +async def v1_contexts() -> dict: + """Contextes présélectionnables (pour le sélecteur du HUD).""" + return { + "default": settings.default_context, + "contexts": [ + {"id": c.id, "label": c.label, "icon": c.icon, "description": c.description} + for c in CONTEXTS.values() + ], + } + + @app.get("/v1/memory/health") async def v1_memory_health() -> dict: """État de la mémoire long-terme (embeddings + Qdrant + collection), erreurs exposées.""" @@ -99,15 +116,25 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply: status_code=400, detail=f"modèle '{model}' non autorisé ; dispo : {settings.allowed_models}", ) + ctx = get_context(req.context) 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é 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 [] + # RAG doc : seulement si le contexte le demande (réutilise qvec, même embedder nomic) + docs = [] + if "docs" in ctx.sources and knowledge: + docs = await knowledge.search(text, qvec) + # Sources live du contexte (Ghostfolio / Prometheus / Alertmanager) — best-effort + blocks: list = [] + live = tuple(s for s in ctx.sources if s != "docs") + if live: + async with httpx.AsyncClient() as c: + blocks = await fetch_blocks(c, live) t_recall = time.perf_counter() + system, ctx_debug = assemble(ctx, blocks=blocks, docs=docs, memories=memories) try: - reply = await brain_ask(text, model, history, memories, docs) + reply = await brain_ask(text, system, model, history) except httpx.HTTPError as e: raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e t_gen = time.perf_counter() @@ -118,15 +145,14 @@ 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 docs=%d", - model, + "ask ctx=%s model=%s recall+src=%.0fms gen=%.0fms total=%.0fms mem=%d docs=%d blocks=%d", + ctx.id, model, (t_recall - t0) * 1000, (t_gen - t_recall) * 1000, (t_gen - t0) * 1000, - len(memories), - len(docs), + len(memories), len(docs), len(blocks), ) - return AskReply(reply=reply, model=model) + return AskReply(reply=reply, model=model, context_id=ctx.id, context=ctx_debug) def run() -> None: diff --git a/stt/server/stt_server/brain.py b/stt/server/stt_server/brain.py index c12885d..4a2f0a8 100644 --- a/stt/server/stt_server/brain.py +++ b/stt/server/stt_server/brain.py @@ -31,32 +31,21 @@ async def aclose() -> None: async def ask( text: str, + system: str, model: str | None = None, history: list[dict] | None = None, - memories: list[str] | None = None, - knowledge: list[str] | None = None, ) -> str: - system = settings.system_prompt + """Interroge le LLM avec un system prompt **déjà assemblé** (voir contexts.assemble). + + Le choix du contexte, l'injection des sources live et du RAG sont faits en amont + (app.v1_ask + contexts) ; ici on ne fait que le réglage thinking + l'appel LLM. + """ if settings.disable_thinking: # Qwen3 est un modèle « thinking » : sans ça il dépense tout le budget max_tokens # en raisonnement (`reasoning_content`) → `content` vide, ou part en réflexion longue # → 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 += ( - "\n\nÉléments de mémoire long-terme (peuvent aider, ignore si hors-sujet) :\n" - + souvenirs - ) messages = [{"role": "system", "content": system}] if history: messages += history diff --git a/stt/server/stt_server/config.py b/stt/server/stt_server/config.py index 2718901..c475a1c 100644 --- a/stt/server/stt_server/config.py +++ b/stt/server/stt_server/config.py @@ -67,5 +67,16 @@ class Settings: # 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")) + # Contextes présélectionnables (visualiseur + presets live). Le contexte « funk » + # garde le system_prompt grounding-strict ci-dessus ; les autres injectent des + # données live. URLs surchargeables selon la résolution réseau in-cluster. + default_context: str = os.getenv("STT_DEFAULT_CONTEXT", "funk") + sources_timeout: float = float(os.getenv("STT_SOURCES_TIMEOUT", "4")) + ghostfolio_url: str = os.getenv("STT_GHOSTFOLIO_URL", "http://ghostfolio.lab.local") + # Jeton « Security token » du compte Ghostfolio (vide → données indisponibles). + ghostfolio_token: str = os.getenv("STT_GHOSTFOLIO_TOKEN", "") + prometheus_url: str = os.getenv("STT_PROMETHEUS_URL", "http://prometheus.lab.local") + alertmanager_url: str = os.getenv("STT_ALERTMANAGER_URL", "http://alertmanager.lab.local") + settings = Settings() diff --git a/stt/server/stt_server/contexts.py b/stt/server/stt_server/contexts.py new file mode 100644 index 0000000..9221b79 --- /dev/null +++ b/stt/server/stt_server/contexts.py @@ -0,0 +1,133 @@ +"""Contextes présélectionnables d'Asa — profils de domaine + assemblage du prompt. + +Chaque profil définit un system prompt et les **sources live** à injecter dans le +contexte (Ghostfolio, Prometheus, Alertmanager, doc Funk). Le client choisit un +contexte par requête (`context` dans /v1/ask) ; le serveur assemble le prompt et +renvoie le contexte assemblé pour le **visualiseur** du HUD. + +Ajouter un contexte = une entrée dans CONTEXTS (id, label, icône, prompt, sources). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from stt_server.config import settings + +# Préambule commun (ton, langue, concision) — chaque prompt de contexte le complète. +_BASE = ( + "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." +) + + +@dataclass(frozen=True) +class Context: + id: str + label: str + icon: str + description: str + system_prompt: str + sources: tuple[str, ...] = () # "docs","ghostfolio","alerts","cluster","metrics" + + +# Le contexte « funk » garde le prompt grounding-strict historique (env override possible). +CONTEXTS: dict[str, Context] = { + "funk": Context( + id="funk", label="Funk · cluster", icon="🛠️", + description="Questions sur le homelab depuis la documentation.", + system_prompt=settings.system_prompt, + sources=("docs",), + ), + "ghostfolio": Context( + id="ghostfolio", label="Ghostfolio", icon="💰", + description="Ton portefeuille et tes investissements.", + system_prompt=_BASE + " Tu aides sur le portefeuille d'investissement de " + "l'utilisateur. Appuie-toi sur les données Ghostfolio fournies ci-dessous ; " + "donne des montants précis quand ils y figurent. Si une donnée n'est pas " + "fournie, dis-le simplement.", + sources=("ghostfolio",), + ), + "grafana": Context( + id="grafana", label="Grafana · métriques", icon="📊", + description="Métriques et observabilité du homelab.", + system_prompt=_BASE + " Tu réponds sur les métriques et l'observabilité du " + "homelab à partir des données fournies ci-dessous (Prometheus). Reste factuel.", + sources=("metrics",), + ), + "alerting": Context( + id="alerting", label="Alerting", icon="🚨", + description="Alertes actives et leur signification.", + system_prompt=_BASE + " Tu expliques et tries les alertes actives fournies " + "ci-dessous : gravité, cause probable, quoi vérifier. S'il n'y a aucune " + "alerte, rassure brièvement.", + sources=("alerts",), + ), + "cluster": Context( + id="cluster", label="Gestion cluster", icon="🚢", + description="État et gestion du cluster Kubernetes Funk (lecture seule).", + system_prompt=_BASE + " Tu réponds sur l'état et la gestion du cluster " + "Kubernetes Funk à partir de l'état fourni ci-dessous et de la documentation. " + "Tu fais de la LECTURE SEULE : tu n'exécutes aucune action.", + sources=("cluster", "docs"), + ), +} + +# Titres des blocs injectés (visualiseur + prompt) +_SOURCE_TITLES = { + "ghostfolio": "Portefeuille Ghostfolio (données live)", + "metrics": "Métriques Prometheus (live)", + "alerts": "Alertes actives (Alertmanager)", + "cluster": "État du cluster (live)", + "docs": "Documentation Funk", +} + + +def get_context(context_id: str | None) -> Context: + return CONTEXTS.get(context_id or "", CONTEXTS[settings.default_context]) + + +def assemble( + ctx: Context, + *, + blocks: list[tuple[str, str]], + docs: list[str], + memories: list[str], +) -> tuple[str, dict]: + """Construit le system prompt final + la structure de visualisation. + + `blocks` : (source_id, texte) des sources live déjà récupérées. + Renvoie (system_prompt, debug) où debug alimente le visualiseur du HUD. + """ + system = ctx.system_prompt + debug_blocks: list[dict] = [] + for src, text in blocks: + if not text: + continue + title = _SOURCE_TITLES.get(src, src) + system += f"\n\n=== {title} ===\n{text}" + debug_blocks.append({"source": src, "title": title, "text": text}) + + if "docs" in ctx.sources and docs: + extraits = "\n\n".join(docs) + 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 += ( + "\n\nÉléments de mémoire long-terme (peuvent aider, ignore si hors-sujet) :\n" + + souvenirs + ) + + debug = { + "id": ctx.id, + "label": ctx.label, + "icon": ctx.icon, + "system_prompt": ctx.system_prompt, + "blocks": debug_blocks, + "docs": docs, + "memories": memories, + } + return system, debug diff --git a/stt/server/stt_server/sources.py b/stt/server/stt_server/sources.py new file mode 100644 index 0000000..6bc0b7a --- /dev/null +++ b/stt/server/stt_server/sources.py @@ -0,0 +1,169 @@ +"""Sources de données live injectées dans le contexte d'Asa (lecture seule). + +Chaque fonction renvoie un bloc de texte court (ou une note d'indisponibilité) à +injecter dans le prompt. Tout est best-effort : une source injoignable renvoie une +note explicative, jamais une exception (on n'ajoute jamais de latence fatale au tour). + +URLs et jeton via variables d'environnement (cf. config.Settings) → surchargeables +selon la résolution réseau in-cluster. +""" + +from __future__ import annotations + +import httpx + +from stt_server.config import settings + +# Alerte toujours active (test du pipeline) — exclue des résumés. +_ALWAYS_ON = {"Watchdog"} + + +async def _prom_query(client: httpx.AsyncClient, expr: str) -> list[tuple[dict, float]]: + r = await client.get( + f"{settings.prometheus_url.rstrip('/')}/api/v1/query", + params={"query": expr}, timeout=settings.sources_timeout, + ) + r.raise_for_status() + data = r.json() + out: list[tuple[dict, float]] = [] + for s in data.get("data", {}).get("result", []): + try: + out.append((s.get("metric", {}), float(s["value"][1]))) + except (KeyError, IndexError, ValueError, TypeError): + pass + return out + + +async def ghostfolio_block(client: httpx.AsyncClient) -> str: + base = settings.ghostfolio_url.rstrip("/") + token = settings.ghostfolio_token + if not token: + return "Données indisponibles : jeton Ghostfolio non configuré côté serveur." + try: + r = await client.post( + f"{base}/api/v1/auth/anonymous", + json={"accessToken": token}, timeout=settings.sources_timeout, + ) + if r.status_code >= 400: + return "Données indisponibles : authentification Ghostfolio refusée (jeton ?)." + jwt = (r.json() or {}).get("authToken") + if not jwt: + return "Données indisponibles : jeton Ghostfolio invalide." + r = await client.get( + f"{base}/api/v1/portfolio/details", + headers={"Authorization": f"Bearer {jwt}"}, timeout=settings.sources_timeout, + ) + r.raise_for_status() + data = r.json() or {} + summary = data.get("summary", {}) or {} + val = (summary.get("currentValueInBaseCurrency") + or summary.get("currentValue") or summary.get("netWorth")) + # quelques positions principales (par valeur), si présentes + holdings = data.get("holdings", {}) or {} + tops = sorted( + (h for h in holdings.values() if isinstance(h, dict)), + key=lambda h: h.get("valueInBaseCurrency") or 0, reverse=True, + )[:5] + lines = [] + if val is not None: + lines.append(f"Valeur actuelle du portefeuille : {round(float(val)):,} (devise de base).".replace(",", " ")) + for h in tops: + name = h.get("name") or h.get("symbol") or "?" + hv = h.get("valueInBaseCurrency") + if hv: + lines.append(f"- {name} : {round(float(hv)):,}".replace(",", " ")) + return "\n".join(lines) if lines else "Aucune donnée de portefeuille renvoyée." + except httpx.HTTPError: + return "Données indisponibles : Ghostfolio injoignable." + except (ValueError, KeyError): + return "Données indisponibles : réponse Ghostfolio inattendue." + + +async def alerts_block(client: httpx.AsyncClient) -> str: + base = settings.alertmanager_url.rstrip("/") + try: + r = await client.get( + f"{base}/api/v2/alerts", + params={"active": "true", "silenced": "false"}, timeout=settings.sources_timeout, + ) + r.raise_for_status() + raw = r.json() + except (httpx.HTTPError, ValueError): + return "Données indisponibles : Alertmanager injoignable." + alerts = [] + for a in raw if isinstance(raw, list) else []: + labels = a.get("labels", {}) or {} + if labels.get("alertname") in _ALWAYS_ON: + continue + ann = a.get("annotations", {}) or {} + alerts.append( + f"- {labels.get('alertname', 'alerte')} " + f"({labels.get('severity', 'none')}) : " + f"{ann.get('summary') or ann.get('description') or ''}".strip() + ) + if not alerts: + return "Aucune alerte active actuellement." + return f"{len(alerts)} alerte(s) active(s) :\n" + "\n".join(alerts) + + +async def cluster_block(client: httpx.AsyncClient) -> str: + try: + nodes = await _prom_query(client, 'up{job=~"storage-01|gpu-01-node"}') + not_ready = await _prom_query( + client, 'kube_pod_status_ready{condition="false"} == 1' + ) + ready = await _prom_query(client, 'count(kube_pod_status_ready{condition="true"} == 1)') + except httpx.HTTPError: + return "Données indisponibles : Prometheus injoignable." + hosts_up = sum(1 for _, v in nodes if v == 1) + lines = [f"Hôtes hors-cluster joignables : {hosts_up}/{len(nodes) or 2} (storage-01, gpu-01)."] + if ready: + lines.append(f"Pods prêts (cluster) : {int(ready[0][1])}.") + if not_ready: + names = ", ".join( + m.get("pod", "?") for m, _ in not_ready[:6] if m.get("pod") + ) + lines.append(f"Pods NON prêts : {names or len(not_ready)}.") + else: + lines.append("Tous les pods scrutés sont prêts.") + return "\n".join(lines) + + +async def metrics_block(client: httpx.AsyncClient) -> str: + try: + llama = await _prom_query(client, 'up{job="llama-server-gpu"}') + targets_up = await _prom_query(client, 'count(up == 1)') + targets_all = await _prom_query(client, 'count(up)') + except httpx.HTTPError: + return "Données indisponibles : Prometheus injoignable." + lines = [] + if llama: + lines.append("llama-server (GPU) : " + ("en ligne" if llama[0][1] == 1 else "hors ligne") + ".") + if targets_up and targets_all: + lines.append(f"Cibles Prometheus UP : {int(targets_up[0][1])}/{int(targets_all[0][1])}.") + return "\n".join(lines) if lines else "Aucune métrique disponible." + + +# Aiguillage source_id → fetcher +_FETCHERS = { + "ghostfolio": ghostfolio_block, + "alerts": alerts_block, + "cluster": cluster_block, + "metrics": metrics_block, +} + + +async def fetch_blocks(client: httpx.AsyncClient, sources: tuple[str, ...]) -> list[tuple[str, str]]: + """Récupère les blocs live demandés (hors « docs », géré par le RAG). Best-effort.""" + import asyncio + + wanted = [s for s in sources if s in _FETCHERS] + if not wanted: + return [] + results = await asyncio.gather( + *(_FETCHERS[s](client) for s in wanted), return_exceptions=True + ) + out: list[tuple[str, str]] = [] + for src, res in zip(wanted, results): + out.append((src, res if isinstance(res, str) else "")) + return out