From 8ed315f7f6fe90cac6832c3b1e0620c282eaab37 Mon Sep 17 00:00:00 2001 From: alkatrazz Date: Sun, 21 Jun 2026 03:02:09 +0200 Subject: [PATCH] fix(stt-client): restaure les intents vocaux (#37) + l'affichage version perdus de main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux features mergées n'avaient pas atteint main (effet de bord des PR empilées : #37 a été mergé dans sa branche de base supprimée, jamais propagé). Recollées proprement sur main à jour, en fusionnant engine._respond / app.py avec le travail contextes (#39). Intents vocaux (lecture seule, court-circuitent le LLM) : - portal/intents.py + ghostfolio.py (récupérés) + exports __init__. - engine.py : intent_router AVANT le LLM ; cohabite avec context_provider (pas de contexte émis sur un intent local). - app.py : construit le routeur (services, ouverture, santé, Ghostfolio). - config : section [ghostfolio] + doc example.toml. Affichage version : - app.py : meta_msg poussé à la connexion. - hud : case 'meta' + setVersion + ligne « Version installée » (section Service). - bump 0.11.0 → 0.12.0. Validé : compile, routage intents, coexistence intent/visualiseur dans _respond (intent → mode portail sans contexte ; LLM → contexte émis), HUD (meta/version). Co-Authored-By: Claude Opus 4.8 --- admin/ia/stt.md | 4 +- stt/client/config/stt.example.toml | 10 +++ stt/client/pyproject.toml | 2 +- stt/client/stt/config.py | 8 ++ stt/client/stt/hud/index.html | 9 +++ stt/client/stt/portal/__init__.py | 3 + stt/client/stt/portal/ghostfolio.py | 76 ++++++++++++++++++ stt/client/stt/portal/intents.py | 118 ++++++++++++++++++++++++++++ stt/client/stt/ui/app.py | 25 ++++++ stt/client/stt/voice/engine.py | 36 ++++++--- 10 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 stt/client/stt/portal/ghostfolio.py create mode 100644 stt/client/stt/portal/intents.py diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 2018aea..32b0e11 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -163,7 +163,9 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe | — ASR | `stt/client/stt/voice/asr/` | backend enfichable : `whisper` (faster-whisper CPU, défaut) \| `onnx` (Parakeet/Canary/Nemotron via onnx-asr, multilingue, streaming-ready). Choisi par `[voice] asr_engine` | | — api | `stt/client/stt/api.py` | `ServerClient` → `POST /v1/ask` | | — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD ; réglages **et messages texte** → backend) ; HUD embarqué dans le package | -| — portail | `stt/client/stt/portal/` | `registry.py` (services config-driven `[[services]]`) + `health.py` (StatusPoller). Panneau « Services » du HUD : tuiles + **pages détail** (description, **santé live**, composants, alertes, bouton Ouvrir). Santé = probe HTTP + **Prometheus** `up{}`/kube-state-metrics + **Alertmanager**. Ouvre l'URL dans le navigateur normal. Aussi : commande vocale « ouvre <service> » | +| — portail | `stt/client/stt/portal/` | `registry.py` (services config-driven `[[services]]`) + `health.py` (StatusPoller). Panneau « Services » du HUD : tuiles + **pages détail** (description, **santé live**, composants, alertes, bouton Ouvrir). Santé = probe HTTP + **Prometheus** `up{}`/kube-state-metrics + **Alertmanager**. Ouvre l'URL dans le navigateur normal | +| — intentions vocales | `stt/client/stt/portal/intents.py` + `ghostfolio.py` | lecture seule, court-circuitent le LLM (interceptées dans `engine._respond`) : « ouvre <service> », « état du cluster/services » (résumé santé parlé), « combien sur mon ghostfolio » (API Ghostfolio, `[ghostfolio] access_token`) | +| — contexte HUD | `stt/client/stt/ui/app.py` + `hud/` | sélecteur de contexte (Réglages) envoyé par requête + **visualiseur** (drawer) affichant le contexte assemblé renvoyé par le serveur ; affichage de la version installée | | STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM | | Image | `ghcr.io/alkatrazz24/funk-stt-server` | construite par `.github/workflows/build-stt-server.yml` | | Manifests | `k8s/apps/stt/` | Deployment, Service, IngressRoute (`stt.lab.local`) | diff --git a/stt/client/config/stt.example.toml b/stt/client/config/stt.example.toml index 8a484f5..dfc8be1 100644 --- a/stt/client/config/stt.example.toml +++ b/stt/client/config/stt.example.toml @@ -63,3 +63,13 @@ local_db = "~/.local/share/stt/memory.sqlite" # probe_timeout_sec = 4 # prometheus_url = "http://prometheus.lab.local" # alertmanager_url = "http://alertmanager.lab.local" + +# Intentions vocales (lecture seule), reconnues avant le LLM : +# « ouvre » → ouvre la page +# « état du cluster / des services » → résumé de santé (parlé) +# « combien sur mon ghostfolio » → valeur du portefeuille (ci-dessous) +# Ghostfolio : access_token = « Security token » du compte (Réglages Ghostfolio). +# [ghostfolio] +# url = "http://ghostfolio.lab.local" +# access_token = "TON_SECURITY_TOKEN" +# timeout_sec = 6 diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index adb3448..3e29de2 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.11.0" +version = "0.12.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py index feda808..cad7551 100644 --- a/stt/client/stt/config.py +++ b/stt/client/stt/config.py @@ -174,6 +174,14 @@ DEFAULT_CONFIG: dict[str, Any] = { "prometheus_url": "http://prometheus.lab.local", "alertmanager_url": "http://alertmanager.lab.local", }, + # Ghostfolio — pour l'intention vocale « combien sur mon portefeuille ». + # access_token = « Security token » du compte (vide → l'assistant répond de le + # configurer). Lecture seule. + "ghostfolio": { + "url": "http://ghostfolio.lab.local", + "access_token": "", + "timeout_sec": 6, + }, } diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html index 5049990..0a310f2 100644 --- a/stt/client/stt/hud/index.html +++ b/stt/client/stt/hud/index.html @@ -752,6 +752,8 @@ .svc-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .svc-actions .btn { width: auto; font-size: 14px; padding: 11px 10px; } .svc-actions .btn#svcStop { grid-column: 1 / -1; } /* Arrêter sur toute la largeur */ + .svc-version { font-size: 12.5px; color: var(--ink-soft); margin: 12px 0 0; text-align: center; } + .svc-version strong { color: var(--ink); font-weight: 600; } /* ============================================================ VISUALISEUR DE CONTEXTE @@ -1033,6 +1035,7 @@ +

Version installée :

@@ -1249,6 +1252,7 @@ function handleEvent(ev) { case 'services': renderServices(ev.services || []); break; // tuiles du portail case 'portal-status': applyPortalStatus(ev); break; // santé live des services case 'contexts': applyContexts(ev); break; // contextes présélectionnables + case 'meta': setVersion(ev.version || '?'); break; // version installée case 'context': renderContextViewer(ev.context); break; // contexte assemblé (visualiseur) case 'toast': showToast(ev.text || ''); break; // confirmation (clic/voix) default: console.warn('Événement inconnu :', ev); @@ -1540,6 +1544,11 @@ function openService(s) { document.getElementById('svcBack').addEventListener('click', showHome); +function setVersion(v) { + const strong = document.querySelector('#svcVersion strong'); + if (strong) strong.textContent = (v && v !== '?') ? ('v' + v) : 'inconnue'; +} + function showToast(text) { if (!text) return; toastEl.textContent = text; diff --git a/stt/client/stt/portal/__init__.py b/stt/client/stt/portal/__init__.py index 86faf94..393976b 100644 --- a/stt/client/stt/portal/__init__.py +++ b/stt/client/stt/portal/__init__.py @@ -8,9 +8,12 @@ La même source alimente le panneau « Services » du HUD et la commande vocale from __future__ import annotations +from .ghostfolio import GhostfolioClient from .health import StatusPoller +from .intents import route_intent, summarize_status from .registry import Component, Service, load_services, match_service __all__ = [ "Component", "Service", "load_services", "match_service", "StatusPoller", + "GhostfolioClient", "route_intent", "summarize_status", ] diff --git a/stt/client/stt/portal/ghostfolio.py b/stt/client/stt/portal/ghostfolio.py new file mode 100644 index 0000000..32ff4e4 --- /dev/null +++ b/stt/client/stt/portal/ghostfolio.py @@ -0,0 +1,76 @@ +"""Client minimal de l'API Ghostfolio — valeur du portefeuille (lecture seule). + +Flux Ghostfolio : POST /api/v1/auth/anonymous {accessToken} → JWT, puis +GET /api/v1/portfolio/details (Bearer) → summary.currentValueInBaseCurrency. + +Le token (« Security token » du compte Ghostfolio) est config-driven, jamais en +dur. Sans token → message d'aide ; toute erreur → message parlable, jamais d'exception. +""" + +from __future__ import annotations + +from typing import Any + + +def _fmt_money(value: float) -> str: + """12345.6 → « 12 345 » (séparateur de milliers fin, arrondi à l'euro).""" + try: + return f"{round(float(value)):,}".replace(",", " ") + except (TypeError, ValueError): + return str(value) + + +class GhostfolioClient: + def __init__(self, cfg: dict[str, Any]): + self.url = (cfg.get("url") or "http://ghostfolio.lab.local").rstrip("/") + self.token = cfg.get("access_token") or "" + self.timeout = float(cfg.get("timeout_sec", 6) or 6) + + def _auth_token(self) -> str | None: + import requests + + r = requests.post( + f"{self.url}/api/v1/auth/anonymous", + json={"accessToken": self.token}, timeout=self.timeout, + ) + if r.status_code >= 400: + return None + return (r.json() or {}).get("authToken") + + def _base_currency(self) -> str: + import requests + + try: + r = requests.get(f"{self.url}/api/v1/info", timeout=self.timeout) + return (r.json() or {}).get("baseCurrency") or "EUR" + except (requests.RequestException, ValueError): + return "EUR" + + def value_summary(self) -> str: + """Phrase parlable donnant la valeur du portefeuille (ou un message d'erreur clair).""" + import requests + + if not self.token: + return ("Je n'ai pas de jeton Ghostfolio configuré. " + "Ajoute ton « Security token » dans la section ghostfolio de la config.") + try: + jwt = self._auth_token() + if not jwt: + return "Je n'arrive pas à m'authentifier auprès de Ghostfolio — le jeton est peut-être invalide." + r = requests.get( + f"{self.url}/api/v1/portfolio/details", + headers={"Authorization": f"Bearer {jwt}"}, timeout=self.timeout, + ) + r.raise_for_status() + summary = (r.json() or {}).get("summary", {}) or {} + val = (summary.get("currentValueInBaseCurrency") + or summary.get("currentValue") + or summary.get("netWorth")) + if val is None: + return "Je n'ai pas trouvé la valeur du portefeuille dans la réponse de Ghostfolio." + cur = self._base_currency() + return f"Ton portefeuille Ghostfolio vaut environ {_fmt_money(val)} {cur}." + except requests.RequestException: + return "Ghostfolio est injoignable pour le moment." + except (ValueError, KeyError): + return "Réponse inattendue de Ghostfolio — je n'ai pas pu lire la valeur." diff --git a/stt/client/stt/portal/intents.py b/stt/client/stt/portal/intents.py new file mode 100644 index 0000000..7215329 --- /dev/null +++ b/stt/client/stt/portal/intents.py @@ -0,0 +1,118 @@ +"""Intentions vocales locales (lecture seule) — court-circuitent le LLM. + +Trois intentions déterministes, reconnues sur le transcript avant l'appel au +STT-server : + + • « ouvre / lance / montre » → ouvre la page dans le navigateur + • « état du cluster / des services » → résumé de la santé du portail + • « combien sur mon ghostfolio » → valeur du portefeuille (API Ghostfolio) + +`route_intent()` renvoie une phrase à dire si une intention matche, sinon None +(la question part alors au LLM comme d'habitude). +""" + +from __future__ import annotations + +import re +from typing import Any, Callable + +from .registry import Service, _norm, match_service + +# Verbes d'ouverture (normalisés, sans accent) +_OPEN_VERBS = ( + "ouvre", "ouvrir", "ouvres", "lance", "lancer", "affiche", "afficher", + "montre", "montrer", "accede", "acceder", "va sur", "amene moi sur", +) +# Mots vides retirés en tête de la cible (« ouvre LE grafana » → « grafana ») +_FILLERS = {"le", "la", "les", "l", "mon", "ma", "mes", "moi", "sur", "vers", "du", "de", "des", "un", "une"} + +_GHOSTFOLIO_WORDS = ("ghostfolio", "portefeuille", "portfolio") +_GHOSTFOLIO_QUALIFIERS = ( + "combien", "valeur", "vaut", "vaux", "solde", "montant", "total", + "cote", "riche", "argent", "capital", +) +_STATUS_WORDS = ("etat", "statut", "sante", "forme", "fonctionne", "marche", "va") +_STATUS_SCOPE = ("cluster", "service", "services", "homelab", "infra", "pods", "tout") + + +def _extract_open_target(norm: str) -> str | None: + """Renvoie ce qui suit un verbe d'ouverture, nettoyé, ou None si pas de verbe.""" + for verb in _OPEN_VERBS: + m = re.search(rf"\b{re.escape(verb)}\b\s+(.*)", norm) + if m: + tail = m.group(1).strip() + words = [w for w in tail.split() if w not in _FILLERS] + return " ".join(words).strip() or None + return None + + +def route_intent( + text: str, + *, + services: list[Service], + open_fn: Callable[[str], None], + status_fn: Callable[[], str], + ghostfolio_fn: Callable[[], str], +) -> str | None: + norm = _norm(text) + if not norm: + return None + + # 1) Ghostfolio — valeur (avant « ouvre », sinon « ouvre ghostfolio » serait capté) + if any(w in norm for w in _GHOSTFOLIO_WORDS) and any(q in norm for q in _GHOSTFOLIO_QUALIFIERS): + return ghostfolio_fn() + + # 2) État du cluster / des services + if any(w in norm for w in _STATUS_WORDS) and any(s in norm for s in _STATUS_SCOPE): + return status_fn() + + # 3) Ouvrir un service + target = _extract_open_target(norm) + if target: + svc = match_service(services, target) + if svc is not None: + open_fn(svc.id) + return f"J'ouvre {svc.name}." + return None + + +def summarize_status(status_msg: dict[str, Any] | None, services: list[Service]) -> str: + """Résumé parlable de la santé du portail (depuis le dernier portal-status).""" + if not status_msg or not status_msg.get("services"): + return "Je récupère encore l'état des services, réessaie dans un instant." + names = {s.id: s.name for s in services} + svc_status = status_msg.get("services", {}) + down, degraded, ok = [], [], 0 + for sid, st in svc_status.items(): + name = names.get(sid, sid) + h = (st or {}).get("health") + if h == "down": + down.append(name) + elif h == "degraded": + degraded.append(name) + elif h == "ok": + ok += 1 + total = len(svc_status) + alerts = status_msg.get("alerts_total") + + if not down and not degraded: + msg = f"Tout va bien : les {total} services du homelab sont en ligne." + else: + parts = [] + if down: + parts.append(_enum(down) + (" est hors ligne" if len(down) == 1 else " sont hors ligne")) + if degraded: + parts.append(_enum(degraded) + (" est dégradé" if len(degraded) == 1 else " sont dégradés")) + msg = "Attention : " + " ; ".join(parts) + "." + if ok: + msg += f" Les {ok} autres sont en ligne." + if alerts: + msg += f" Il y a {alerts} alerte{'s' if alerts > 1 else ''} active{'s' if alerts > 1 else ''}." + return msg + + +def _enum(items: list[str]) -> str: + """['A','B','C'] → 'A, B et C'.""" + if len(items) <= 1: + return items[0] if items else "" + return ", ".join(items[:-1]) + " et " + items[-1] diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py index f846b19..fa4668a 100644 --- a/stt/client/stt/ui/app.py +++ b/stt/client/stt/ui/app.py @@ -82,6 +82,16 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: "current": client.context or (contexts_info or {}).get("default"), } + # Version installée (affichée près du bouton « Mettre à jour » du HUD) + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as _pkg_version + + try: + _stt_version = _pkg_version("stt") + except PackageNotFoundError: + _stt_version = "?" + meta_msg = {"type": "meta", "version": _stt_version} + def broadcast(event: dict) -> None: nonlocal last_state, last_mic, last_status if event.get("type") == "state": @@ -153,6 +163,7 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: await ws.send(json.dumps(last_mic, ensure_ascii=False)) # état micro courant await ws.send(json.dumps(services_msg, ensure_ascii=False)) # tuiles du portail await ws.send(json.dumps(contexts_msg, ensure_ascii=False)) # contextes Asa + await ws.send(json.dumps(meta_msg, ensure_ascii=False)) # version installée if last_status is not None: # santé connue → l'envoyer tout de suite await ws.send(json.dumps(last_status, ensure_ascii=False)) # Le HUD envoie {"type":"settings","data":{...}} (réglage), @@ -199,6 +210,20 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: engine = make_engine(emit) # le moteur expose au HUD le dernier contexte assemblé renvoyé par le serveur engine.context_provider = lambda: client.last_context + + # Intentions vocales locales (lecture seule) : « ouvre X », « état du cluster », + # « combien sur ghostfolio ». Court-circuitent le LLM. + from stt.portal import GhostfolioClient, route_intent, summarize_status + + _gf = GhostfolioClient(cfg.get("ghostfolio") or {}) + engine.intent_router = lambda text: route_intent( + text, + services=services, + open_fn=open_service, + status_fn=lambda: summarize_status(last_status, services), + ghostfolio_fn=_gf.value_summary, + ) + # chargement Whisper + boucle audio dans un thread dédié threading.Thread(target=_run_engine, args=(engine, emit), daemon=True).start() diff --git a/stt/client/stt/voice/engine.py b/stt/client/stt/voice/engine.py index 23d36bb..ccbecf8 100644 --- a/stt/client/stt/voice/engine.py +++ b/stt/client/stt/voice/engine.py @@ -68,6 +68,10 @@ class VoiceEngine: # callable renvoyant le dernier contexte assemblé (serveur) → visualiseur HUD. # Renseigné après coup par le serveur (app.py) ; None tant qu'absent. self.context_provider: Callable[[], dict | None] | None = None + # routeur d'intentions locales (portail) : (text) -> réponse | None. + # Renseigné après coup par le serveur (app.py). Si une intention matche, on + # répond SANS appeler le LLM. + self.intent_router: Callable[[str], str | None] | None = None # backend ASR enfichable (whisper par défaut, onnx/parakeet en option) self._asr = make_backend(cfg) self._stop = False @@ -156,20 +160,32 @@ class VoiceEngine: self.emit({"type": "user", "text": text}) if self.memory: self.memory.log("user", text) - self.emit({"type": "state", "state": "thinking"}) - try: - resp = self.responder(text) - except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas - self.emit({"type": "error", "text": str(e)}) - self.emit({"type": "state", "state": "idle"}) - return + # 1) intention locale (portail) : réponse instantanée, sans LLM + resp = None + mode = None + if self.intent_router is not None: + try: + resp = self.intent_router(text) + except Exception: # noqa: BLE001 — un intent cassé ne bloque pas le tour + resp = None + if resp is not None: + mode = "portail" + else: + # 2) sinon : on interroge le LLM via le STT-server + self.emit({"type": "state", "state": "thinking"}) + try: + resp = self.responder(text) + except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas + self.emit({"type": "error", "text": str(e)}) + self.emit({"type": "state", "state": "idle"}) + return + mode = self.model_label() if self.model_label else None if self._interrupt.is_set(): # « stop » pressé pendant la génération self.emit({"type": "state", "state": "idle"}) return - mode = self.model_label() if self.model_label else None self.emit({"type": "assistant", "text": resp, "mode": mode}) - # contexte assemblé renvoyé par le serveur → visualiseur HUD (best-effort) - if self.context_provider is not None: + # contexte assemblé (serveur) → visualiseur HUD ; pas sur un intent local + if mode != "portail" and self.context_provider is not None: try: ctx = self.context_provider() except Exception: # noqa: BLE001