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:
ALI YESILKAYA 2026-06-20 23:31:39 +02:00 committed by GitHub
parent 704d212e15
commit 6e30f9535c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 259 additions and 10 deletions

View file

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

View file

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

View file

@ -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,
},
}

View file

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

View file

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

View 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."

View 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]

View file

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

View file

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