mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 05:34:43 +02:00
feat(stt-client): intentions vocales lecture seule — ouvrir, état du cluster, valeur Ghostfolio (#37)
Asa répond/agit en local (sans LLM) sur trois intentions déterministes, interceptées dans engine._respond avant l'appel au STT-server : • « ouvre <service> » → ouvre la page dans le navigateur • « état du cluster / des services » → résumé de santé parlé (depuis le poller) • « combien sur mon ghostfolio » → valeur du portefeuille (API Ghostfolio) - portal/intents.py : route_intent (ordre ghostfolio > statut > ouvrir) + summarize_status. - portal/ghostfolio.py : client API config-driven (auth/anonymous → JWT → portfolio/details), lecture seule, dégradation propre si pas de token. - voice/engine.py : attribut intent_router branché dans _respond (mode « portail », court-circuite le LLM). - ui/app.py : construit le routeur (services, ouverture, dernière santé, Ghostfolio). - config : section [ghostfolio] (url, access_token vide par défaut, timeout). - bump 0.10.0 → 0.11.0. Stacked sur #36 (le résumé d'état utilise le StatusPoller). Validé : routage des intents (ouvrir via alias, ghostfolio avec qualificatif, statut, hors-sujet → LLM), résumé d'état, et interception moteur de bout en bout (LLM non appelé sur intent). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
704d212e15
commit
6e30f9535c
10 changed files with 259 additions and 10 deletions
|
|
@ -148,7 +148,8 @@ 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) + `intents.py` (intentions vocales) + `ghostfolio.py`. Panneau « Services » du HUD : tuiles + **pages détail** (description, **santé live**, composants, alertes, bouton Ouvrir). Santé = probe HTTP + **Prometheus** `up{}`/kube-state-metrics + **Alertmanager** |
|
||||
| — intentions vocales | `stt/client/stt/portal/intents.py` | lecture seule, court-circuitent le LLM (interceptées dans `engine._respond`) : « ouvre <service> » (ouvre la page), « état du cluster/services » (résumé santé parlé), « combien sur mon ghostfolio » (API Ghostfolio, `[ghostfolio] access_token`) |
|
||||
| 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`) |
|
||||
|
|
|
|||
|
|
@ -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 <service> » → 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "stt"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
|
||||
requires-python = ">=3.11"
|
||||
readme = "README.md"
|
||||
|
|
|
|||
|
|
@ -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 (Réglages → vide par défaut →
|
||||
# l'assistant répondra de le configurer). Lecture seule.
|
||||
"ghostfolio": {
|
||||
"url": "http://ghostfolio.lab.local",
|
||||
"access_token": "",
|
||||
"timeout_sec": 6,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1172,6 +1172,8 @@ function handleEvent(ev) {
|
|||
case 'services': renderServices(ev.services || []); break; // tuiles du portail
|
||||
case 'portal-status': applyPortalStatus(ev); break; // santé live des services
|
||||
case 'meta': setVersion(ev.version || '?'); break; // version installée
|
||||
// (les intentions vocales « ouvre X » / « état cluster » / « ghostfolio »
|
||||
// sont traitées côté backend ; elles arrivent ici comme bulles assistant)
|
||||
case 'toast': showToast(ev.text || ''); break; // confirmation (clic/voix)
|
||||
default: console.warn('Événement inconnu :', ev);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
76
stt/client/stt/portal/ghostfolio.py
Normal file
76
stt/client/stt/portal/ghostfolio.py
Normal file
|
|
@ -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."
|
||||
118
stt/client/stt/portal/intents.py
Normal file
118
stt/client/stt/portal/intents.py
Normal file
|
|
@ -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 <service> » → 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]
|
||||
|
|
@ -197,6 +197,21 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
|
|||
_start_http(ui["ws_host"], ui["http_port"])
|
||||
|
||||
engine = make_engine(emit)
|
||||
|
||||
# Intentions vocales locales (lecture seule) : « ouvre X », « état du cluster »,
|
||||
# « combien sur ghostfolio ». Court-circuitent le LLM. Le routeur a accès au
|
||||
# portail (services, ouverture, dernière santé connue, Ghostfolio).
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ class VoiceEngine:
|
|||
self.no_tts = no_tts
|
||||
# callable renvoyant l'alias modèle courant → affiché en tag dans le HUD
|
||||
self.model_label = model_label
|
||||
# routeur d'intentions locales (portail) : (text) -> réponse | None.
|
||||
# Renseigné après coup par le serveur (app.py) qui a accès au portail.
|
||||
# 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
|
||||
|
|
@ -153,17 +157,29 @@ 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})
|
||||
if self.memory:
|
||||
self.memory.log("assistant", resp)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue