mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-10 18:44:41 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
de4b66ed56
commit
ae4d5bd20e
10 changed files with 279 additions and 12 deletions
|
|
@ -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` |
|
| — 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` |
|
| — 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 |
|
| — 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 |
|
| 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` |
|
| 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`) |
|
| 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
|
# probe_timeout_sec = 4
|
||||||
# prometheus_url = "http://prometheus.lab.local"
|
# prometheus_url = "http://prometheus.lab.local"
|
||||||
# alertmanager_url = "http://alertmanager.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]
|
[project]
|
||||||
name = "stt"
|
name = "stt"
|
||||||
version = "0.11.0"
|
version = "0.12.0"
|
||||||
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
|
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,14 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"prometheus_url": "http://prometheus.lab.local",
|
"prometheus_url": "http://prometheus.lab.local",
|
||||||
"alertmanager_url": "http://alertmanager.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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -752,6 +752,8 @@
|
||||||
.svc-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
.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 { width: auto; font-size: 14px; padding: 11px 10px; }
|
||||||
.svc-actions .btn#svcStop { grid-column: 1 / -1; } /* Arrêter sur toute la largeur */
|
.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
|
VISUALISEUR DE CONTEXTE
|
||||||
|
|
@ -1033,6 +1035,7 @@
|
||||||
<button class="btn" id="svcUpdate" title="Met à jour STT puis redémarre">⬆ Mettre à jour</button>
|
<button class="btn" id="svcUpdate" title="Met à jour STT puis redémarre">⬆ Mettre à jour</button>
|
||||||
<button class="btn danger" id="svcStop" title="Arrête le service">⏻ Arrêter</button>
|
<button class="btn danger" id="svcStop" title="Arrête le service">⏻ Arrêter</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="svc-version" id="svcVersion">Version installée : <strong>…</strong></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1249,6 +1252,7 @@ function handleEvent(ev) {
|
||||||
case 'services': renderServices(ev.services || []); break; // tuiles du portail
|
case 'services': renderServices(ev.services || []); break; // tuiles du portail
|
||||||
case 'portal-status': applyPortalStatus(ev); break; // santé live des services
|
case 'portal-status': applyPortalStatus(ev); break; // santé live des services
|
||||||
case 'contexts': applyContexts(ev); break; // contextes présélectionnables
|
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 'context': renderContextViewer(ev.context); break; // contexte assemblé (visualiseur)
|
||||||
case 'toast': showToast(ev.text || ''); break; // confirmation (clic/voix)
|
case 'toast': showToast(ev.text || ''); break; // confirmation (clic/voix)
|
||||||
default: console.warn('Événement inconnu :', ev);
|
default: console.warn('Événement inconnu :', ev);
|
||||||
|
|
@ -1540,6 +1544,11 @@ function openService(s) {
|
||||||
|
|
||||||
document.getElementById('svcBack').addEventListener('click', showHome);
|
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) {
|
function showToast(text) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
toastEl.textContent = text;
|
toastEl.textContent = text;
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@ La même source alimente le panneau « Services » du HUD et la commande vocale
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .ghostfolio import GhostfolioClient
|
||||||
from .health import StatusPoller
|
from .health import StatusPoller
|
||||||
|
from .intents import route_intent, summarize_status
|
||||||
from .registry import Component, Service, load_services, match_service
|
from .registry import Component, Service, load_services, match_service
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Component", "Service", "load_services", "match_service", "StatusPoller",
|
"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]
|
||||||
|
|
@ -82,6 +82,16 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
|
||||||
"current": client.context or (contexts_info or {}).get("default"),
|
"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:
|
def broadcast(event: dict) -> None:
|
||||||
nonlocal last_state, last_mic, last_status
|
nonlocal last_state, last_mic, last_status
|
||||||
if event.get("type") == "state":
|
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(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(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(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
|
if last_status is not None: # santé connue → l'envoyer tout de suite
|
||||||
await ws.send(json.dumps(last_status, ensure_ascii=False))
|
await ws.send(json.dumps(last_status, ensure_ascii=False))
|
||||||
# Le HUD envoie {"type":"settings","data":{...}} (réglage),
|
# 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)
|
engine = make_engine(emit)
|
||||||
# le moteur expose au HUD le dernier contexte assemblé renvoyé par le serveur
|
# le moteur expose au HUD le dernier contexte assemblé renvoyé par le serveur
|
||||||
engine.context_provider = lambda: client.last_context
|
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é
|
# chargement Whisper + boucle audio dans un thread dédié
|
||||||
threading.Thread(target=_run_engine, args=(engine, emit), daemon=True).start()
|
threading.Thread(target=_run_engine, args=(engine, emit), daemon=True).start()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,10 @@ class VoiceEngine:
|
||||||
# callable renvoyant le dernier contexte assemblé (serveur) → visualiseur HUD.
|
# callable renvoyant le dernier contexte assemblé (serveur) → visualiseur HUD.
|
||||||
# Renseigné après coup par le serveur (app.py) ; None tant qu'absent.
|
# Renseigné après coup par le serveur (app.py) ; None tant qu'absent.
|
||||||
self.context_provider: Callable[[], dict | None] | None = None
|
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)
|
# backend ASR enfichable (whisper par défaut, onnx/parakeet en option)
|
||||||
self._asr = make_backend(cfg)
|
self._asr = make_backend(cfg)
|
||||||
self._stop = False
|
self._stop = False
|
||||||
|
|
@ -156,20 +160,32 @@ class VoiceEngine:
|
||||||
self.emit({"type": "user", "text": text})
|
self.emit({"type": "user", "text": text})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.log("user", text)
|
self.memory.log("user", text)
|
||||||
self.emit({"type": "state", "state": "thinking"})
|
# 1) intention locale (portail) : réponse instantanée, sans LLM
|
||||||
try:
|
resp = None
|
||||||
resp = self.responder(text)
|
mode = None
|
||||||
except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas
|
if self.intent_router is not None:
|
||||||
self.emit({"type": "error", "text": str(e)})
|
try:
|
||||||
self.emit({"type": "state", "state": "idle"})
|
resp = self.intent_router(text)
|
||||||
return
|
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
|
if self._interrupt.is_set(): # « stop » pressé pendant la génération
|
||||||
self.emit({"type": "state", "state": "idle"})
|
self.emit({"type": "state", "state": "idle"})
|
||||||
return
|
return
|
||||||
mode = self.model_label() if self.model_label else None
|
|
||||||
self.emit({"type": "assistant", "text": resp, "mode": mode})
|
self.emit({"type": "assistant", "text": resp, "mode": mode})
|
||||||
# contexte assemblé renvoyé par le serveur → visualiseur HUD (best-effort)
|
# contexte assemblé (serveur) → visualiseur HUD ; pas sur un intent local
|
||||||
if self.context_provider is not None:
|
if mode != "portail" and self.context_provider is not None:
|
||||||
try:
|
try:
|
||||||
ctx = self.context_provider()
|
ctx = self.context_provider()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue