refactor(stt): pivot client-serveur — STT-server in-cluster + client pipx

Sépare STT en deux :
- stt/client/ : commande `stt` (pipx), voix locale (Whisper/Piper) + HUD ; envoie
  le texte au serveur via api.py (ServerClient → POST /v1/ask). URL serveur paramétrable,
  pas de cerveau local (suppression du routeur 3 modes).
- stt/server/ : STT-server FastAPI (conteneur), /healthz + /v1/ask → LiteLLM (Qwen3/Claude).

Déploiement cluster :
- k8s/apps/stt/ : Deployment, Service, IngressRoute (stt.lab.local), litellm-ext
  (Service + Endpoints → 192.168.10.1:4000 pour joindre LiteLLM hors cluster)
- k8s/apps-of-apps/apps/stt.yaml : Application ArgoCD (depuis main)
- .github/workflows/build-stt-server.yml : build/push image → ghcr.io/alkatrazz24/funk-stt-server

Inférence/chat seulement (outils Hermes 'agir sur Funk' = phase ultérieure, API :8080 à spécifier).
Vérifié : py_compile client+serveur, YAML manifests, ServerClient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013FmcxGsyXZXogiAHQLjnZT
This commit is contained in:
Claude 2026-06-17 09:37:34 +00:00
parent 1ff3fd9bed
commit 6947b394f1
No known key found for this signature in database
38 changed files with 566 additions and 356 deletions

View file

@ -1,59 +1,56 @@
# STT — Assistant vocal "Jarvis" de Funk
Assistant vocal + HUD pour le poste perso, branché sur le homelab Funk.
Écoute (wake word « Ok Hermès »), parle (Piper), affiche un HUD animé, et **agit sur Funk**
via Hermes Agent — ou discute avec Claude directement en déplacement.
Assistant vocal type *Jarvis* pour le homelab Funk, en **architecture client-serveur** :
> 📐 **Conception complète** : `admin/ia/stt.md`. Ce dossier est le squelette du projet ;
> l'implémentation se fait par phases (voir la Roadmap dans le doc de conception).
- **`server/`** — STT-server : orchestrateur AI **dans le cluster k8s**. Reçoit les requêtes
des clients et route l'inférence vers LiteLLM (Qwen3 / Claude). Mémoire centralisée (futur).
- **`client/`** — commande `stt` installable sur le poste : capture micro, STT (Whisper),
TTS (Piper), **HUD graphique**. Envoie le texte au serveur (URL paramétrable).
## Installation (cible : poste perso)
> 📐 Conception complète : `admin/ia/stt.md`.
```bash
pipx install "git+https://github.com/Alkatrazz24/Funk-lab.git#subdirectory=stt"
stt --setup # télécharge le modèle Whisper, Piper et la voix fr_FR
stt # lance le backend + ouvre le HUD
```
LAN / *.lab.local
┌─ POSTE ──────────┐ ┌─ CLUSTER k8s (ns ai) ──────────────┐
│ client `stt` │ HTTP │ STT-server (Deployment) │
│ • micro + Whisper│ ─────▶ │ POST /v1/ask → LiteLLM │
│ • Piper (TTS) │ ◀───── │ (litellm-ext → s01:4000) │
│ • HUD web │ reply │ Ingress: stt.lab.local │
└───────────────────┘ └──────────────┬──────────────────────┘
LiteLLM :4000 (s01)
→ Qwen3 (g01) / Claude
```
## Commandes prévues
## Client — installation (poste)
| Commande | Effet |
|---|---|
| `stt` | Lance le backend vocal + ouvre le HUD en kiosk |
| `stt --setup` | Première install : Whisper, Piper, voix, config par défaut |
| `stt --mode hermes\|local\|claude` | Force le mode cerveau (sinon auto-détection LAN) |
| `stt --no-tts` | Réponses texte seulement (debug) |
| `stt --stop` | Arrête le service |
```bash
pipx install "git+ssh://git@github.com/Alkatrazz24/Funk-lab.git@<branche>#subdirectory=stt/client"
stt --setup # génère ~/.config/stt/stt.toml (dont [server] url)
stt # lance la voix + ouvre le HUD ; dis "Ok Hermès …"
```
Prérequis poste : micro, Piper + voix dans `~/.local/share/piper/`, `aplay` (alsa-utils).
## Serveur — déploiement (cluster)
1. **Image** : poussée sur `ghcr.io/alkatrazz24/funk-stt-server` par le workflow
`.github/workflows/build-stt-server.yml` (sur push touchant `stt/server/`).
2. **ArgoCD** : `k8s/apps-of-apps/apps/stt.yaml` → déploie `k8s/apps/stt/` **depuis `main`**.
Tant que ce n'est pas mergé sur `main`, le cluster ne le prend pas.
3. Le serveur joint LiteLLM (s01, hors cluster) via le Service `litellm-ext`
(Endpoints manuel → `192.168.10.1:4000`).
## Structure
```
stt/
├── pyproject.toml # définit la commande `stt` (entrypoint pipx)
├── stt/ # package Python — backend
│ ├── cli.py # entrypoint `stt`
│ ├── config.py # défauts + ~/.config/stt/stt.toml
│ ├── voice/ # wake word, STT (faster-whisper), TTS (Piper)
│ ├── brain/ # routeur 3 modes : hermes / local-direct / claude-direct
│ ├── memory/ # mémoire 3 tiers (local SQLite ✅, s01/GitHub phase 4)
│ ├── server/ # HTTP statique (HUD) + websocket (états → HUD)
│ └── hud/ # front web embarqué dans le package (livré par pipx)
├── config/ # stt.example.toml + thèmes / avatars / voix
├── memory/ # distilled/ (versionné) + raw local (gitignoré)
└── install-service.sh # systemd --user (démarrage auto) — phase 6
├── client/ # commande `stt` (pipx) — voix + HUD
│ ├── pyproject.toml
│ ├── config/ # stt.example.toml + thèmes/avatars/voix
│ └── stt/ # cli, config, voice/, api.py (→ serveur), ui/ (HUD), hud/
└── server/ # STT-server (conteneur) — API + orchestration AI
├── pyproject.toml
├── Dockerfile
├── memory/ # mémoire distillée GitHub (futur, server-side)
└── stt_server/ # app.py (FastAPI), brain.py (→ LiteLLM), config.py
```
## Cerveau — 3 modes
Auto-détection au démarrage (ping s01 `192.168.1.200`) :
- **`hermes`** (LAN) → Hermes `:8080`, agit sur Funk.
- **`local-direct`** (LAN) → LiteLLM `:4000`, chat Qwen3 sur g01.
- **`claude-direct`** (hors-LAN) → API Anthropic, chat seul.
## Mémoire — 3 tiers
- **Local** : SQLite, historique brut, offline (gitignoré).
- **s01** : Qdrant `:6333`, sémantique long-terme (partagée RAG Funk, sur LAN).
- **GitHub** : `memory/distilled/`, mémoire distillée versionnée, portable.
> ⚠️ Seule la mémoire distillée est committée. Transcripts bruts gitignorés. Repo privé.

View file

@ -0,0 +1,28 @@
# Configuration du CLIENT STT — copier vers ~/.config/stt/stt.toml (`stt --setup` le génère).
# Reflète les défauts embarqués (stt/client/stt/config.py : DEFAULT_CONFIG).
# Le client n'a plus de cerveau : il envoie le texte au STT-server (in-cluster).
[server]
url = "http://stt.lab.local" # endpoint du STT-server (paramétrable)
timeout_sec = 90
[voice]
wake_word = "hermes" # "Ok Hermès"
whisper_model = "large-v3" # STT faster-whisper (CPU int8) — "small" pour tester
piper_voice = "fr_FR-upmc-medium" # TTS — ~/.local/share/piper/<voix>.onnx
language = "fr"
silence_sec = 1.2
min_speech_sec = 0.6
chat_timeout_sec = 60
[ui]
theme = "arc-reactor"
avatar = "default"
kiosk = true
ws_host = "127.0.0.1"
ws_port = 9300 # websocket états → HUD (doit matcher hud/index.html)
http_port = 9301 # HTTP statique qui sert le HUD
[memory]
enabled = true # cache local de conversation (brut, gitignoré)
local_db = "~/.local/share/stt/memory.sqlite"

View file

@ -1,20 +1,18 @@
[project]
name = "stt"
version = "0.0.1"
description = "STT — assistant vocal Jarvis du homelab Funk (voix + HUD + Hermes/Claude)"
version = "0.1.0"
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
requires-python = ">=3.11"
readme = "README.md"
# Réutilise les dépendances éprouvées de tools/hermes-voice + ajouts STT
# Client : voix locale + HUD. L'inférence est déléguée au STT-server (pas d'anthropic/qdrant ici).
dependencies = [
"faster-whisper>=1.0.0", # STT local
"sounddevice>=0.4.6", # capture micro
"numpy>=1.24.0",
"requests>=2.31.0", # appels Hermes / LiteLLM
"requests>=2.31.0", # appels HTTP vers le STT-server
"webrtcvad-wheels>=2.0.10",# VAD / wake word
"websockets>=12.0", # backend ↔ HUD
"anthropic>=0.40.0", # mode claude-direct (hors-LAN)
"qdrant-client>=1.13.0", # mémoire tier s01
"tomli-w>=1.0.0", # écriture config TOML
]

35
stt/client/stt/api.py Normal file
View file

@ -0,0 +1,35 @@
"""Client HTTP vers le STT-server (in-cluster).
Le client n'a plus de cerveau : il envoie le texte transcrit au serveur, qui
orchestre l'inférence (LiteLLM → Qwen3/Claude) et renvoie la réponse.
"""
from __future__ import annotations
from typing import Any
class ServerClient:
def __init__(self, cfg: dict[str, Any]):
self.url = cfg["url"].rstrip("/")
self.timeout = cfg.get("timeout_sec", 90)
def ask(self, text: str) -> str:
import requests
r = requests.post(
f"{self.url}/v1/ask",
json={"text": text},
timeout=self.timeout,
)
r.raise_for_status()
return r.json()["reply"].strip()
def healthy(self) -> bool:
import requests
try:
r = requests.get(f"{self.url}/healthz", timeout=5)
return r.ok
except requests.RequestException:
return False

View file

@ -1,4 +1,4 @@
"""Entrypoint de la commande `stt`."""
"""Entrypoint de la commande `stt` (client)."""
from __future__ import annotations
@ -12,33 +12,37 @@ def _cmd_setup() -> int:
path = write_default_config()
print(f"✓ Config : {path}")
print("\nÀ installer séparément sur le poste :")
print("\nÀ installer / configurer sur le poste :")
print(" • modèle Whisper (téléchargé au 1er lancement par faster-whisper)")
print(" • Piper + voix → ~/.local/share/piper/<voix>.onnx")
print(" • clé ANTHROPIC_API_KEY (env) pour le mode claude-direct")
print(f" • URL du STT-server dans {path} ([server] url)")
return 0
def _cmd_run(args) -> int:
from stt.brain import BrainRouter
from stt.api import ServerClient
from stt.config import load_config
from stt.memory import LocalMemory
from stt.server import serve
from stt.ui import serve
from stt.voice import VoiceEngine
cfg = load_config()
if args.mode:
cfg["brain"]["mode"] = args.mode
if args.server:
cfg["server"]["url"] = args.server
brain = BrainRouter(cfg["brain"])
print(f"Cerveau : mode '{brain.mode}' (configuré : '{brain._configured}')")
client = ServerClient(cfg["server"])
print(f"STT-server : {client.url}")
if not client.healthy():
print(" ⚠️ serveur injoignable — vérifie [server] url et le réseau")
memory = None
if cfg["memory"].get("enabled"):
memory = LocalMemory(cfg["memory"]["local_db"])
def make_engine(emit):
return VoiceEngine(cfg["voice"], brain, emit, memory=memory, no_tts=args.no_tts)
return VoiceEngine(
cfg["voice"], client.ask, emit, memory=memory, no_tts=args.no_tts
)
try:
asyncio.run(serve(cfg, make_engine))
@ -51,10 +55,9 @@ def _cmd_run(args) -> int:
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="stt", description="Assistant vocal Jarvis de Funk")
p = argparse.ArgumentParser(prog="stt", description="Client vocal Jarvis de Funk")
p.add_argument("--setup", action="store_true", help="Écrit la config par défaut")
p.add_argument("--mode", choices=["hermes", "local", "claude"],
help="Force le mode cerveau (sinon auto-détection LAN)")
p.add_argument("--server", help="Force l'URL du STT-server (sinon config)")
p.add_argument("--no-tts", action="store_true", help="Réponses texte seulement")
args = p.parse_args(argv)

View file

@ -1,8 +1,9 @@
"""Chargement / écriture de la configuration STT.
"""Configuration du client STT.
Les défauts sont embarqués (pas de data-file robuste pour pipx). `stt --setup`
écrit ces défauts dans ~/.config/stt/stt.toml ; au lancement on fusionne le fichier
utilisateur par-dessus les défauts.
Défauts embarqués (robuste pour pipx). `stt --setup` les écrit dans
~/.config/stt/stt.toml ; au lancement on fusionne le fichier utilisateur par-dessus.
Le client ne contient PLUS le cerveau : il envoie le texte au STT-server (URL paramétrable).
"""
from __future__ import annotations
@ -16,22 +17,10 @@ CONFIG_PATH = CONFIG_DIR / "stt.toml"
DATA_DIR = Path.home() / ".local" / "share" / "stt"
DEFAULT_CONFIG: dict[str, Any] = {
"brain": {
"mode": "auto", # auto | hermes | local | claude
"lan_probe_host": "192.168.1.200",
"lan_probe_port": 4000, # LiteLLM — présent dès que s01 est joignable
"hermes": { # mode hermes (LAN) — agit sur Funk
"ssh_host": "s01", # accès SSH éprouvé (cf. tools/hermes-voice)
"profile": "funk-ai",
},
"local": { # mode local-direct (LAN) — chat Qwen3 g01
"url": "http://192.168.1.200:4000/v1/chat/completions",
"api_key": "lm-studio",
"model": "hermes-default",
},
"claude": { # mode claude-direct (hors-LAN) — chat seul
"model": "claude-sonnet-4-6", # clé via env ANTHROPIC_API_KEY
},
"server": {
# STT-server (in-cluster) — joignable via Traefik / wildcard *.lab.local
"url": "http://stt.lab.local",
"timeout_sec": 90,
},
"voice": {
"wake_word": "hermes",
@ -51,11 +40,9 @@ DEFAULT_CONFIG: dict[str, Any] = {
"http_port": 9301,
},
"memory": {
# cache local de conversation (brut, gitignoré) — la vraie mémoire est côté serveur
"enabled": True,
"local_db": str(DATA_DIR / "memory.sqlite"),
"distill_on_exit": True,
"qdrant_url": "http://192.168.1.200:6333",
"qdrant_collection": "stt-memory",
},
}
@ -71,7 +58,6 @@ def _deep_merge(base: dict, over: dict) -> dict:
def load_config() -> dict[str, Any]:
"""Défauts + surcharge du fichier utilisateur s'il existe."""
if CONFIG_PATH.exists():
with CONFIG_PATH.open("rb") as f:
user = tomllib.load(f)
@ -80,7 +66,6 @@ def load_config() -> dict[str, Any]:
def write_default_config(force: bool = False) -> Path:
"""Écrit la config par défaut dans ~/.config/stt/stt.toml (idempotent)."""
import tomli_w
CONFIG_DIR.mkdir(parents=True, exist_ok=True)

View file

@ -0,0 +1,5 @@
"""UI locale : sert le HUD (HTTP statique) + diffuse les états (websocket)."""
from stt.ui.app import serve
__all__ = ["serve"]

View file

@ -37,24 +37,25 @@ def _deaccent(s: str) -> str:
class VoiceEngine:
"""Écoute le micro, transcrit, appelle le cerveau, synthétise la réponse.
"""Écoute le micro, transcrit, interroge le STT-server, synthétise la réponse.
`responder(text) -> str` envoie le texte au serveur et renvoie la réponse.
`emit(event: dict)` est appelé à chaque changement d'état / message, pour le HUD.
Événements : {"type": "state", "state": idle|listening|thinking|speaking},
{"type": "user", "text": }, {"type": "assistant", "text": , "mode": },
{"type": "user", "text": }, {"type": "assistant", "text": },
{"type": "error", "text": }.
"""
def __init__(
self,
cfg: dict[str, Any],
brain,
responder: Callable[[str], str],
emit: Callable[[dict], None],
memory=None,
no_tts: bool = False,
):
self.cfg = cfg
self.brain = brain
self.responder = responder
self.emit = emit
self.memory = memory
self.no_tts = no_tts
@ -117,21 +118,21 @@ class VoiceEngine:
finally:
out.unlink(missing_ok=True)
# ── cerveau ──────────────────────────────────────────────────────────
# ── interrogation du serveur ─────────────────────────────────────────
def _respond(self, text: str) -> None:
self.emit({"type": "user", "text": text})
if self.memory:
self.memory.log("user", text, self.brain.mode)
self.memory.log("user", text)
self.emit({"type": "state", "state": "thinking"})
try:
resp = self.brain.ask(text)
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
self.emit({"type": "assistant", "text": resp, "mode": self.brain.mode})
self.emit({"type": "assistant", "text": resp})
if self.memory:
self.memory.log("assistant", resp, self.brain.mode)
self.memory.log("assistant", resp)
self.emit({"type": "state", "state": "speaking"})
self._speak(resp)

View file

@ -1,44 +0,0 @@
# Configuration STT — copier vers ~/.config/stt/stt.toml (`stt --setup` le génère).
# Ces valeurs reflètent les défauts embarqués (stt/stt/config.py : DEFAULT_CONFIG).
[brain]
# Mode cerveau : "auto" (ping s01 → hermes si LAN, sinon claude), "hermes", "local", "claude"
mode = "auto"
lan_probe_host = "192.168.1.200"
lan_probe_port = 4000 # LiteLLM — présent dès que s01 est joignable
[brain.hermes] # mode hermes (LAN) — agit sur Funk (via SSH, éprouvé)
ssh_host = "s01"
profile = "funk-ai"
[brain.local] # mode local-direct (LAN) — chat Qwen3 sur g01
url = "http://192.168.1.200:4000/v1/chat/completions"
api_key = "lm-studio"
model = "hermes-default"
[brain.claude] # mode claude-direct (hors-LAN) — chat seul
model = "claude-sonnet-4-6" # clé via env ANTHROPIC_API_KEY (jamais ici)
[voice]
wake_word = "hermes" # "Ok Hermès"
whisper_model = "large-v3" # STT faster-whisper (CPU int8)
piper_voice = "fr_FR-upmc-medium" # TTS — ~/.local/share/piper/<voix>.onnx
language = "fr"
silence_sec = 1.2
min_speech_sec = 0.6
chat_timeout_sec = 60
[ui]
theme = "arc-reactor"
avatar = "default"
kiosk = true
ws_host = "127.0.0.1"
ws_port = 9300 # websocket états → HUD (doit matcher hud/index.html)
http_port = 9301 # HTTP statique qui sert le HUD
[memory]
enabled = true
local_db = "~/.local/share/stt/memory.sqlite" # tier local (gitignoré)
distill_on_exit = true # phase 4
qdrant_url = "http://192.168.1.200:6333" # tier s01 (phase 4)
qdrant_collection = "stt-memory"

10
stt/server/Dockerfile Normal file
View file

@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml ./
COPY stt_server ./stt_server
RUN pip install --no-cache-dir .
EXPOSE 8000
# Service interne au cluster (exposé via Traefik). uvicorn multi-workers.
CMD ["uvicorn", "stt_server.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

44
stt/server/README.md Normal file
View file

@ -0,0 +1,44 @@
# STT-server — orchestrateur AI (in-cluster)
API FastAPI déployée dans le cluster k8s (namespace `ai`). Reçoit les requêtes des clients
STT et route l'inférence vers LiteLLM (s01) → Qwen3 (g01) / Claude.
## API
| Méthode | Route | Corps | Réponse |
|---|---|---|---|
| `GET` | `/healthz` | — | `{status, version}` |
| `POST` | `/v1/ask` | `{text}` | `{reply}` |
## Configuration (variables d'env)
| Var | Défaut | Rôle |
|---|---|---|
| `STT_LITELLM_URL` | `http://litellm-ext:4000/v1/chat/completions` | endpoint LiteLLM (via Service Endpoints) |
| `STT_LITELLM_KEY` | `lm-studio` | clé LiteLLM (valeur exacte attendue) |
| `STT_MODEL` | `hermes-default` | alias LiteLLM (bascule Qwen/Claude via `hermes-switch`) |
| `STT_SYSTEM_PROMPT` | prompt vocal FR concis | persona |
| `STT_MAX_TOKENS` / `STT_TEMPERATURE` | `200` / `0.7` | génération |
## Dev local
```bash
cd stt/server
pip install -e .
STT_LITELLM_URL=http://192.168.1.200:4000/v1/chat/completions stt-server # :8000
curl -s localhost:8000/healthz
curl -s localhost:8000/v1/ask -H 'content-type: application/json' -d '{"text":"bonjour"}'
```
## Déploiement
- Image construite/poussée par `.github/workflows/build-stt-server.yml``ghcr.io/alkatrazz24/funk-stt-server`.
- Manifests : `k8s/apps/stt/` ; Application ArgoCD : `k8s/apps-of-apps/apps/stt.yaml` (depuis `main`).
- Accès LiteLLM (hors cluster) : Service + Endpoints `litellm-ext``192.168.10.1:4000`.
## À venir
- Intégration des **outils Hermes** (« agir sur Funk ») via le gateway `:8080` — nécessite
de spécifier son API. Aujourd'hui : inférence/chat seulement (via LiteLLM).
- **Mémoire** centralisée : Qdrant (s01) + distillation versionnée (`server/memory/`).
- Sessions multi-clients + historique.

22
stt/server/pyproject.toml Normal file
View file

@ -0,0 +1,22 @@
[project]
name = "stt-server"
version = "0.1.0"
description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"httpx>=0.27",
]
[project.scripts]
stt-server = "stt_server.app:run"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["stt_server*"]

View file

@ -0,0 +1,3 @@
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
__version__ = "0.1.0"

View file

@ -0,0 +1,49 @@
"""STT-server — API FastAPI pour les clients STT.
Endpoints :
GET /healthz état du service
POST /v1/ask {text} {reply} (requête AI, orchestrée vers LiteLLM)
"""
from __future__ import annotations
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from stt_server import __version__
from stt_server.brain import ask as brain_ask
app = FastAPI(title="STT-server", version=__version__)
class AskRequest(BaseModel):
text: str
class AskReply(BaseModel):
reply: str
@app.get("/healthz")
async def healthz() -> dict:
return {"status": "ok", "version": __version__}
@app.post("/v1/ask", response_model=AskReply)
async def v1_ask(req: AskRequest) -> AskReply:
text = req.text.strip()
if not text:
raise HTTPException(status_code=400, detail="text vide")
try:
reply = await brain_ask(text)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
return AskReply(reply=reply)
def run() -> None:
"""Entrypoint `stt-server` (dev local). En prod : uvicorn via le conteneur."""
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) # noqa: S104 — service interne au cluster

View file

@ -0,0 +1,29 @@
"""Orchestration AI : route les requêtes des clients vers LiteLLM (s01).
LiteLLM (:4000) est OpenAI-compatible et route lui-même vers Qwen3 (g01) ou Claude
selon l'alias `hermes-default` / `hermes-switch`. L'intégration des outils Hermes
(« agir sur Funk » via le gateway :8080) est une étape ultérieure.
"""
from __future__ import annotations
import httpx
from stt_server.config import settings
async def ask(text: str) -> str:
payload = {
"model": settings.model,
"messages": [
{"role": "system", "content": settings.system_prompt},
{"role": "user", "content": text},
],
"max_tokens": settings.max_tokens,
"temperature": settings.temperature,
}
headers = {"Authorization": f"Bearer {settings.litellm_key}"}
async with httpx.AsyncClient(timeout=settings.request_timeout) as client:
r = await client.post(settings.litellm_url, json=payload, headers=headers)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"].strip()

View file

@ -0,0 +1,27 @@
"""Configuration du STT-server — via variables d'environnement (12-factor / k8s)."""
from __future__ import annotations
import os
class Settings:
# LiteLLM (s01) — joint via un Service ExternalName/Endpoints dans le cluster.
litellm_url: str = os.getenv(
"STT_LITELLM_URL", "http://litellm-ext:4000/v1/chat/completions"
)
litellm_key: str = os.getenv("STT_LITELLM_KEY", "lm-studio")
model: str = os.getenv("STT_MODEL", "hermes-default")
system_prompt: str = os.getenv(
"STT_SYSTEM_PROMPT",
"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.",
)
max_tokens: int = int(os.getenv("STT_MAX_TOKENS", "200"))
temperature: float = float(os.getenv("STT_TEMPERATURE", "0.7"))
request_timeout: float = float(os.getenv("STT_REQUEST_TIMEOUT", "60"))
settings = Settings()

View file

@ -1,5 +0,0 @@
"""Routeur cerveau — 3 modes + auto-détection LAN."""
from stt.brain.router import BrainRouter
__all__ = ["BrainRouter"]

View file

@ -1,113 +0,0 @@
"""Routeur cerveau — décide où envoyer le texte transcrit.
3 modes :
- hermes : SSH vers s01 `hermes --profile -z` (agit sur Funk). Éprouvé dans
tools/hermes-voice. Le gateway HTTP :8080 pourra le remplacer plus tard.
- local-direct : HTTP LiteLLM :4000 (Qwen3 sur g01) chat seul.
- claude-direct : API Anthropic depuis le poste (hors-LAN) chat seul.
mode "auto" : ping TCP s01 joignable hermes, sinon claude.
"""
from __future__ import annotations
import socket
import subprocess
from collections import deque
from typing import Any
VOICE_INJECT = (
"[Réponse vocale : courte, 1-2 phrases max, sans markdown ni listes] "
)
SYSTEM_PROMPT = (
"Tu es Hermes, l'assistant vocal du homelab Funk. "
"Réponds toujours en français, de façon concise (2-3 phrases maximum)."
)
MAX_HISTORY = 10
class BrainRouter:
def __init__(self, cfg: dict[str, Any]):
self.cfg = cfg
self._configured = cfg.get("mode", "auto")
self._history: deque[dict] = deque(maxlen=MAX_HISTORY * 2)
self.mode = self.resolve_mode()
# ── détection de mode ────────────────────────────────────────────────
def lan_available(self) -> bool:
host = self.cfg.get("lan_probe_host", "192.168.1.200")
port = int(self.cfg.get("lan_probe_port", 4000))
try:
with socket.create_connection((host, port), timeout=1.5):
return True
except OSError:
return False
def resolve_mode(self) -> str:
if self._configured != "auto":
return self._configured
return "hermes" if self.lan_available() else "claude"
def refresh_mode(self) -> str:
"""Re-évalue le mode (ex. changement de réseau). Retourne le mode courant."""
self.mode = self.resolve_mode()
return self.mode
# ── dispatch ─────────────────────────────────────────────────────────
def ask(self, text: str) -> str:
if self.mode == "hermes":
return self._ask_hermes(text)
if self.mode == "local":
return self._ask_openai(text, self.cfg["local"])
return self._ask_claude(text)
def _ask_hermes(self, text: str) -> str:
c = self.cfg["hermes"]
query = (VOICE_INJECT + text).replace("'", "'\\''")
cmd = [
"ssh", c.get("ssh_host", "s01"),
"sudo -i -u hermes bash -c "
f"'HERMES_HOME=/srv/data/hermes hermes --profile {c.get('profile', 'funk-ai')} "
f'-z "{query}"\'',
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
out = r.stdout.strip()
if not out and r.returncode != 0:
raise RuntimeError(r.stderr.strip() or "hermes -z a échoué")
return out
def _ask_openai(self, text: str, c: dict) -> str:
import requests
self._history.append({"role": "user", "content": text})
r = requests.post(
c["url"],
json={
"model": c["model"],
"messages": [{"role": "system", "content": SYSTEM_PROMPT}]
+ list(self._history),
"max_tokens": 200,
"temperature": 0.7,
},
headers={"Authorization": f"Bearer {c['api_key']}"},
timeout=30,
)
r.raise_for_status()
resp = r.json()["choices"][0]["message"]["content"].strip()
self._history.append({"role": "assistant", "content": resp})
return resp
def _ask_claude(self, text: str) -> str:
from anthropic import Anthropic
self._history.append({"role": "user", "content": text})
client = Anthropic() # lit ANTHROPIC_API_KEY
msg = client.messages.create(
model=self.cfg["claude"]["model"],
max_tokens=300,
system=SYSTEM_PROMPT,
messages=list(self._history),
)
resp = "".join(b.text for b in msg.content if b.type == "text").strip()
self._history.append({"role": "assistant", "content": resp})
return resp

View file

@ -1,5 +0,0 @@
"""Serveur : websocket (états → HUD) + petit HTTP statique pour servir le HUD."""
from stt.server.app import serve
__all__ = ["serve"]