feat(stt): assistant vocal Jarvis — client pipx + STT-server in-cluster (#4)

* feat(stt): cadrage + squelette assistant vocal Jarvis

Conception validée du projet STT — assistant vocal/HUD du homelab Funk :
- HUD web sur-mesure + STT/TTS local (faster-whisper + Piper)
- Packaging commande pipx (stt), démarrage auto systemd --user
- Cerveau 3 modes + auto-détection LAN : hermes / local-direct / claude-direct
- Mémoire 3 tiers : SQLite local + Qdrant s01 + GitHub (distillée, versionnée)

Réutilise tools/hermes-voice, LiteLLM, Hermes Agent. Squelette + doc admin/ia/stt.md,
implémentation par phases (roadmap dans le doc).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013FmcxGsyXZXogiAHQLjnZT

* feat(stt): phase 1-2 — commande, backend vocal, routeur cerveau, HUD MVP

- cli.py : commande `stt` (--setup, --mode, --no-tts)
- config.py : défauts embarqués + ~/.config/stt/stt.toml
- voice/engine.py : refactor de hermes-voice en classe avec callbacks d'état
- brain/router.py : 3 modes (hermes SSH / local LiteLLM / claude API) + auto-détection LAN
- server/app.py : HTTP statique (HUD) + websocket (états → HUD)
- memory/store.py : tier local SQLite (Qdrant + sync GitHub = phase 4)
- hud/index.html : HUD MVP (visualiseur d'état + conversation)

Vérifié hors-LAN : py_compile, --help, config, routeur (→ claude), mémoire SQLite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013FmcxGsyXZXogiAHQLjnZT

* fix(stt): embarquer le HUD dans le package (404 après pipx install)

Le HUD était à la racine du projet (stt/hud/) donc absent du package installé
par pipx → HTTP 404 sur /. Déplacé dans le package (stt/stt/hud/) + package-data,
HUD_DIR ajusté. Vérifié : le wheel contient bien stt/hud/index.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013FmcxGsyXZXogiAHQLjnZT

* 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

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-17 12:08:58 +02:00 committed by GitHub
parent 22d40023e1
commit e390ddef12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1378 additions and 0 deletions

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.

5
stt/server/memory/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Mémoire brute — ne quitte jamais le poste
raw/
*.sqlite
*.sqlite-*
*.db

View file

@ -0,0 +1,26 @@
# stt/memory — mémoire 3 tiers
```
memory/
├── distilled/ # mémoire DISTILLÉE — versionnée sur GitHub (faits, préférences, résumés)
└── raw/ # historique BRUT local — gitignoré, ne quitte jamais le poste
```
## Les 3 tiers
| Tier | Support | Contenu | Versionné ? |
|---|---|---|---|
| **Local** | SQLite (`~/.local/share/stt/`) + `raw/` | Historique brut, working memory | ❌ gitignoré |
| **s01** | Qdrant `:6333` (collection `stt-memory`) | Sémantique long-terme, partagée RAG Funk | — (sur s01) |
| **GitHub** | `distilled/` | Faits, préférences, résumés distillés | ✅ committé |
## Synchro
1. **Démarrage**`git pull` `distilled/` → charge en local.
2. **Usage** → écriture immédiate en SQLite local.
3. **Arrêt / périodique** → distillation (résumé via le cerveau) → `git commit` `distilled/`
**+** embedding dans Qdrant s01 (si LAN).
4. **Nouveau poste**`distilled/` re-seed le local + Qdrant.
> ⚠️ **Vie privée** : ne committer QUE de la mémoire distillée (pas de transcripts bruts).
> `raw/` et la SQLite sont gitignorés. Le repo doit rester privé.

View file

@ -0,0 +1 @@
# Mémoire distillée versionnée (faits, préférences, résumés). Rempli par le backend.

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