From 6947b394f1a8f20d4a6db6fa647f3fc1cf877dc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 09:37:34 +0000 Subject: [PATCH] =?UTF-8?q?refactor(stt):=20pivot=20client-serveur=20?= =?UTF-8?q?=E2=80=94=20STT-server=20in-cluster=20+=20client=20pipx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013FmcxGsyXZXogiAHQLjnZT --- .github/workflows/build-stt-server.yml | 46 ++++++ admin/ia/stt.md | 159 +++++++++------------ k8s/apps-of-apps/apps/stt.yaml | 24 ++++ k8s/apps/stt/deployment.yaml | 47 ++++++ k8s/apps/stt/ingress.yaml | 14 ++ k8s/apps/stt/litellm-external.yaml | 22 +++ k8s/apps/stt/service.yaml | 11 ++ stt/README.md | 87 ++++++----- stt/{ => client}/config/README.md | 0 stt/client/config/stt.example.toml | 28 ++++ stt/{ => client}/pyproject.toml | 10 +- stt/{ => client}/stt/README.md | 0 stt/{ => client}/stt/__init__.py | 0 stt/client/stt/api.py | 35 +++++ stt/{ => client}/stt/cli.py | 29 ++-- stt/{ => client}/stt/config.py | 35 ++--- stt/{ => client}/stt/hud/README.md | 0 stt/{ => client}/stt/hud/index.html | 0 stt/{ => client}/stt/memory/__init__.py | 0 stt/{ => client}/stt/memory/store.py | 0 stt/client/stt/ui/__init__.py | 5 + stt/{stt/server => client/stt/ui}/app.py | 0 stt/{ => client}/stt/voice/__init__.py | 0 stt/{ => client}/stt/voice/engine.py | 19 +-- stt/config/stt.example.toml | 44 ------ stt/server/Dockerfile | 10 ++ stt/server/README.md | 44 ++++++ stt/{ => server}/memory/.gitignore | 0 stt/{ => server}/memory/README.md | 0 stt/{ => server}/memory/distilled/.gitkeep | 0 stt/server/pyproject.toml | 22 +++ stt/server/stt_server/__init__.py | 3 + stt/server/stt_server/app.py | 49 +++++++ stt/server/stt_server/brain.py | 29 ++++ stt/server/stt_server/config.py | 27 ++++ stt/stt/brain/__init__.py | 5 - stt/stt/brain/router.py | 113 --------------- stt/stt/server/__init__.py | 5 - 38 files changed, 566 insertions(+), 356 deletions(-) create mode 100644 .github/workflows/build-stt-server.yml create mode 100644 k8s/apps-of-apps/apps/stt.yaml create mode 100644 k8s/apps/stt/deployment.yaml create mode 100644 k8s/apps/stt/ingress.yaml create mode 100644 k8s/apps/stt/litellm-external.yaml create mode 100644 k8s/apps/stt/service.yaml rename stt/{ => client}/config/README.md (100%) create mode 100644 stt/client/config/stt.example.toml rename stt/{ => client}/pyproject.toml (65%) rename stt/{ => client}/stt/README.md (100%) rename stt/{ => client}/stt/__init__.py (100%) create mode 100644 stt/client/stt/api.py rename stt/{ => client}/stt/cli.py (62%) rename stt/{ => client}/stt/config.py (53%) rename stt/{ => client}/stt/hud/README.md (100%) rename stt/{ => client}/stt/hud/index.html (100%) rename stt/{ => client}/stt/memory/__init__.py (100%) rename stt/{ => client}/stt/memory/store.py (100%) create mode 100644 stt/client/stt/ui/__init__.py rename stt/{stt/server => client/stt/ui}/app.py (100%) rename stt/{ => client}/stt/voice/__init__.py (100%) rename stt/{ => client}/stt/voice/engine.py (94%) delete mode 100644 stt/config/stt.example.toml create mode 100644 stt/server/Dockerfile create mode 100644 stt/server/README.md rename stt/{ => server}/memory/.gitignore (100%) rename stt/{ => server}/memory/README.md (100%) rename stt/{ => server}/memory/distilled/.gitkeep (100%) create mode 100644 stt/server/pyproject.toml create mode 100644 stt/server/stt_server/__init__.py create mode 100644 stt/server/stt_server/app.py create mode 100644 stt/server/stt_server/brain.py create mode 100644 stt/server/stt_server/config.py delete mode 100644 stt/stt/brain/__init__.py delete mode 100644 stt/stt/brain/router.py delete mode 100644 stt/stt/server/__init__.py diff --git a/.github/workflows/build-stt-server.yml b/.github/workflows/build-stt-server.yml new file mode 100644 index 0000000..985665c --- /dev/null +++ b/.github/workflows/build-stt-server.yml @@ -0,0 +1,46 @@ +name: build-stt-server + +# Construit et pousse l'image du STT-server vers ghcr.io quand stt/server/ change. +on: + push: + branches: ["main", "claude/**"] + paths: + - "stt/server/**" + - ".github/workflows/build-stt-server.yml" + workflow_dispatch: + +env: + IMAGE: ghcr.io/alkatrazz24/funk-stt-server + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Metadata (tags) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=short + + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: stt/server + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 51c6355..77b05f0 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -1,15 +1,16 @@ # STT — Assistant vocal "Jarvis" du homelab Funk **STT** est l'interface vocale et graphique de Funk : un assistant type *Jarvis / Iron Man* -qui écoute, parle, affiche un HUD animé, et **agit sur le homelab** via Hermes Agent. -Il s'installe sur le poste perso comme **une commande unique** (`stt`) et démarre au boot. +qui écoute, parle et affiche un HUD animé. **Architecture client-serveur** : un client `stt` +sur le poste (voix + HUD) qui interroge un **STT-server in-cluster** (orchestration AI). +Objectif ultérieur : « agir sur le homelab » via les outils de Hermes (phase 7). > **Nom de code** : `STT`. À l'origine « Speech-To-Text », mais le projet couvre toute la > chaîne voix → cerveau → voix + interface + mémoire. Renommable plus tard sans impact > technique (FRIDAY, etc.) — c'est juste un identifiant de répertoire/commande. -> ⚠️ **Conception validée (2026-06-17)** — squelette en place, implémentation par phases -> (voir Roadmap). Aucun composant n'est encore déployé sur le poste. +> ⚠️ **2026-06-17** — client (phase 1) + serveur (phase 2) + manifests cluster (phase 3) écrits. +> Reste : build image (CI), déploiement ArgoCD (après merge sur `main`), test audio sur poste. --- @@ -34,74 +35,53 @@ Maillon réellement manquant : **l'interface graphique HUD** + le packaging + la | Décision | Choix retenu | |---|---| -| Interface | **HUD web sur-mesure** (canvas/WebGL) + backend Python local | -| STT / TTS | **Local sur le poste** (faster-whisper CPU + Piper) | -| Packaging | **Commande `stt` via pipx** (`pipx install git+…#subdirectory=stt`) | -| Cerveau | **3 modes + auto-détection LAN** (voir ci-dessous) | -| Mémoire | **3 tiers** : local (SQLite) + s01 (Qdrant) + GitHub (distillée, versionnée) | +| Architecture | **Client-serveur** : client sur le poste, STT-server **in-cluster** (révise le tout-local) | +| Interface | **HUD web sur-mesure** côté client (canvas/WebGL) | +| STT / TTS | **Local sur le poste** (faster-whisper CPU + Piper) — le serveur ne touche pas à l'audio | +| Packaging | Client : **commande `stt` via pipx** (`#subdirectory=stt/client`). Serveur : **conteneur** (ghcr) déployé par ArgoCD | +| Cerveau | **Côté serveur** : route vers LiteLLM `:4000` (Qwen3 / Claude). Outils Hermes = phase ultérieure | +| Client → serveur | Client **serveur-only** (pas de repli Claude). URL serveur **paramétrable** | +| Mémoire | **Côté serveur** (futur) : Qdrant s01 + distillée GitHub. Client : cache local SQLite | + +> Pivot 2026-06-17 (post-test) : on est passé du tout-local à un modèle client-serveur. +> Le « cerveau » (ex-routeur 3 modes côté client) a migré côté serveur. --- ## Architecture ``` -┌─ POSTE PERSO — commande `stt` (systemd --user, démarrage auto) ─────┐ -│ │ -│ ┌──────────────┐ websocket ┌───────────────────────────┐ │ -│ │ HUD web │ ◀────────────▶ │ backend `stt` (Python) │ │ -│ │ (kiosk) │ états + │ • micro + VAD + wake word │ │ -│ │ • visualiseur│ transcript │ • faster-whisper (STT) │ │ -│ │ • avatar │ + réponses │ • Piper (TTS) │ │ -│ │ • thème │ │ • routeur cerveau (3 modes)│ │ -│ │ • réglages │ │ • gestionnaire mémoire │ │ -│ └──────────────┘ └───┬─────────────┬──────────┘ │ -│ │ │ │ -│ mémoire │ │ cerveau │ -│ ┌────────────────────┘ │ │ -│ ▼ ▼ │ -│ SQLite local + cache (selon mode + LAN) │ -│ git pull/push GitHub │ -└───────────────────────────────────────────────────┼─────────────────┘ - │ LAN (192.168.1.200) - ┌───────────────────────────────────┼──────────────────┐ - ▼ ▼ ▼ ▼ - Hermes :8080 LiteLLM :4000 Qdrant :6333 API Anthropic - (mode hermes) (mode local-direct) (mémoire s01) (mode claude-direct, - agit sur Funk chat g01/Claude sémantique hors-LAN, chat seul) + LAN / *.lab.local + ┌─ POSTE — client `stt` (pipx) ─┐ ┌─ CLUSTER k8s (namespace ai) ─────────┐ + │ • micro + VAD + wake word │ HTTP │ STT-server (Deployment + Service) │ + │ • faster-whisper (STT) │ ─────▶ │ GET /healthz │ + │ • Piper (TTS) │ POST │ POST /v1/ask {text} → {reply} │ + │ • HUD web (ui/ + hud/) │ /v1/ask│ brain → LiteLLM (httpx) │ + │ • api.py → ServerClient │ ◀───── │ IngressRoute : stt.lab.local │ + └────────────────────────────────┘ reply │ Service Endpoints : litellm-ext │ + └──────────────┬─────────────────────────┘ + ▼ + LiteLLM :4000 (storage-01, hors cluster) + → Qwen3 (g01) / Claude (hermes-default) ``` -### Le cerveau — 3 modes + auto-détection +### Le cerveau — côté serveur -Au démarrage (et périodiquement), `stt` ping s01 (`192.168.1.200`) : +Le STT-server appelle **LiteLLM `:4000`** (OpenAI-compatible), joint depuis le cluster via +un Service sans sélecteur + Endpoints manuel (`litellm-ext` → `192.168.10.1:4000`). LiteLLM +route lui-même vers Qwen3 (g01) ou Claude selon l'alias `hermes-default` / `hermes-switch`. -| Mode | Déclenché quand | Chemin | Agit sur Funk ? | Coût | -|---|---|---|---|---| -| **`hermes`** | LAN OK (défaut maison) | PC → Hermes `:8080` (route Qwen3 g01 ou Claude via `hermes-switch`) | ✅ oui | gratuit/local sauf si switch claude | -| **`local-direct`** | LAN OK, chat pur voulu | PC → LiteLLM `:4000` (Qwen3 g01) | ❌ chat seul | gratuit | -| **`claude-direct`** | hors-LAN (nomade) | PC → API Anthropic (clé locale) | ❌ (Funk injoignable) | facturé | +> **« Agir sur Funk »** (outils de l'agent Hermes via le gateway `:8080`) n'est **pas** dans le +> MVP : l'API `:8080` n'est pas spécifiée, et depuis un pod le SSH vers Hermes est impraticable. +> MVP = inférence/chat. L'intégration Hermes est une étape ultérieure. -> **Clé de lecture** : « agir sur Funk » vit dans Hermes (sur s01). À la maison, même pour -> l'intelligence de Claude, on passe par Hermes (`hermes-switch claude`) → pouvoirs Funk -> conservés. Le mode `claude-direct` est le fallback nomade (Funk inatteignable de toute façon). -> Auto-détection par défaut, **override manuel** dans les réglages. +### La mémoire — côté serveur (futur) -### La mémoire — 3 tiers +- **Client** : cache local SQLite (`~/.local/share/stt/`, gitignoré) — historique brut. +- **Serveur** (à venir) : Qdrant s01 (sémantique, partagé RAG) + mémoire **distillée** + versionnée dans `stt/server/memory/distilled/`. Sessions multi-clients. -| Tier | Support | Contenu | Disponibilité | -|---|---|---|---| -| **Local** | SQLite + cache (`stt/memory/` local, gitignoré) | Historique brut récent, working memory | Toujours (offline) | -| **s01** | Qdrant `:6333`, collection dédiée STT | Mémoire sémantique long-terme, partagée avec le RAG Funk | Sur LAN | -| **GitHub** | `stt/memory/distilled/` (versionné) | Mémoire **distillée** : faits, préférences, résumés — source de vérité portable | Via git pull/push | - -**Flux de synchro :** -1. Démarrage → `git pull` la mémoire distillée GitHub → charge en local. -2. Pendant l'usage → écriture immédiate en SQLite local. -3. Périodiquement / à l'arrêt → distillation (résumé via le cerveau) → `git commit` GitHub - **+** (si LAN) embedding dans Qdrant s01. -4. Sur nouveau poste / réinstall → GitHub re-seed local + Qdrant. - -> ⚠️ **Vie privée** : seule la mémoire **distillée** est committée (faits/préférences). -> Les transcripts bruts restent locaux et **gitignorés**. Repo privé impératif. +> ⚠️ **Vie privée** : seule la mémoire distillée serait committée. Repo privé impératif. --- @@ -109,56 +89,53 @@ Au démarrage (et périodiquement), `stt` ping s01 (`192.168.1.200`) : | Composant | Emplacement | Description | |---|---|---| -| Commande `stt` | `stt/pyproject.toml` → entrypoint | `stt` (lance), `stt --setup` (install Whisper/Piper/voix), `stt --stop`, `stt --mode ` | -| Backend | `stt/stt/` (package Python) | Extension de `tools/hermes-voice.py` : wake word, STT, TTS, routeur cerveau, mémoire, serveur websocket | -| HUD web | `stt/stt/hud/` | Front statique (HTML/JS/canvas) embarqué dans le package (livré par pipx) : visualiseur animé, conversation, écran de réglages. Servi localement, ouvert en kiosk | -| Config | `stt/config/` | `stt.example.toml` + thèmes, avatars, voix Piper | -| Mémoire | `stt/memory/` | `distilled/` versionné + raw local gitignoré | -| Auto-start | `stt/install-service.sh` | `systemd --user` : backend + ouverture HUD au login | -| (opt.) cluster | `k8s/apps/stt/` | Phase 6 : STT/TTS déportés en Wyoming sur gpu-01 | +| Client `stt` | `stt/client/` (pipx) | `stt`, `stt --setup`, `stt --server `, `stt --no-tts` | +| — voix | `stt/client/stt/voice/` | wake word, STT faster-whisper, TTS Piper | +| — api | `stt/client/stt/api.py` | `ServerClient` → `POST /v1/ask` | +| — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket d'états ; HUD embarqué dans le package | +| 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`), `litellm-ext` | +| Application ArgoCD | `k8s/apps-of-apps/apps/stt.yaml` | déploie depuis `main` | --- ## Personnalisation (« l'image, le design, la voix ») -Pilotable depuis `stt/config/` + l'écran de réglages du HUD, sans recompiler : +Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du HUD : -- **Avatar / image** : asset swappable (`config/avatars/`). -- **Thème / design** : couleurs, fond, style du visualiseur (`config/themes/`). -- **Voix** : modèle Piper `.onnx` interchangeable (`config/voices/`), fr_FR par défaut. -- **Wake word** : configurable (défaut « Ok Hermès »). -- **Cerveau** : mode par défaut + override, clé API Anthropic locale (pour `claude-direct`). +- **Avatar / image** (`config/avatars/`), **thème** (`config/themes/`), + **voix** Piper `.onnx` (`config/voices/`), **wake word**, **URL serveur**. --- ## Prérequis / dépendances -- **Poste** : micro, Python 3.11+, pipx, navigateur (kiosk). `stt --setup` gère Whisper/Piper/voix. -- **LAN** : accès `192.168.1.200` (s01) pour modes `hermes`/`local-direct` + Qdrant. -- **Qdrant réparé** requis pour la mémoire tier s01 (crash-loop depuis 05/06, voir - `admin/incidents.md`). **Non bloquant** : MVP fonctionne avec mémoire locale + GitHub seules. -- **Clé Anthropic** locale (poste) pour le mode `claude-direct` nomade. +- **Poste** : micro, Python 3.11+, pipx, navigateur. Piper + voix dans `~/.local/share/piper/`, `aplay`. +- **Cluster** : image poussée sur ghcr ; ArgoCD déploie **depuis `main`** (donc merge requis) ; + ghcr privé ⇒ éventuel `imagePullSecret`. +- **LiteLLM** joignable depuis le cluster (`192.168.10.1:4000`). --- ## Roadmap -| Phase | Objectif | Livrable | État | -|---|---|---|---| -| **0 — Cadrage** | Conception validée | doc + squelette `stt/` | ✅ fait | -| **1 — Commande + backend** | `stt`/`stt --setup` lancent le moteur vocal + websocket | `pyproject.toml`, `stt/` (cli, config, voice, server) | ✅ fait | -| **2 — Routeur cerveau** | 3 modes + auto-détection LAN | module `brain` (hermes/local/claude) | ✅ fait | -| **3 — HUD** | Visualiseur + conversation | `stt/stt/hud/index.html` (MVP) → visualiseur avancé | 🟡 MVP fait | -| **4 — Mémoire 3 tiers** | local ✅ + GitHub + Qdrant | `memory` : SQLite ✅ ; distillation/sync git + Qdrant | 🟡 local fait | -| **5 — Personnalisation** | Thèmes / avatar / voix / wake word | écran réglages + `stt/config/` | ⏳ | -| **6 — Auto-start** | Démarre au boot du poste | `install-service.sh` + kiosk | ⏳ | -| **7 — Cluster (opt.)** | STT/TTS sur GPU | Wyoming sur gpu-01 + `k8s/apps/stt/` | ⏳ | +| Phase | Objectif | État | +|---|---|---| +| **0 — Cadrage** | Conception validée | ✅ | +| **1 — Client voix + HUD** | `stt` : voix locale + HUD + websocket | ✅ | +| **2 — STT-server** | FastAPI `/v1/ask` → LiteLLM | ✅ | +| **3 — Déploiement cluster** | image ghcr + manifests k8s + ArgoCD + `litellm-ext` | ✅ (à merger sur `main`) | +| **4 — HUD avancé** | visualiseur arc-reactor + thèmes + écran réglages | ⏳ | +| **5 — Mémoire serveur** | Qdrant s01 + distillée GitHub + sessions | ⏳ | +| **6 — Auto-start client** | `install-service.sh` (systemd --user) + kiosk | ⏳ | +| **7 — Outils Hermes** | « agir sur Funk » via gateway `:8080` (API à spécifier) | ⏳ | -### État phase 1-2 (testé hors-LAN) +### État (testé hors-LAN) -`py_compile` OK sur tout le package ; `stt --help`, chargement config, routeur cerveau -(auto-détection → `claude` hors-LAN) et mémoire SQLite locale vérifiés. Le test bout-en-bout -audio (Whisper/Piper/micro) se fait sur le poste après `pipx install` + `stt --setup`. +`py_compile` OK (client + serveur) ; client : `stt --help`, config, `ServerClient` vérifiés ; +serveur : import/compile OK. Reste à valider sur cible : build image (CI), déploiement ArgoCD +après merge, et test audio bout-en-bout sur le poste. --- diff --git a/k8s/apps-of-apps/apps/stt.yaml b/k8s/apps-of-apps/apps/stt.yaml new file mode 100644 index 0000000..3f131b0 --- /dev/null +++ b/k8s/apps-of-apps/apps/stt.yaml @@ -0,0 +1,24 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: stt + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: git@github.com:Alkatrazz24/Funk-lab.git + targetRevision: main + path: k8s/apps/stt + directory: + recurse: true + destination: + server: https://kubernetes.default.svc + namespace: ai + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/k8s/apps/stt/deployment.yaml b/k8s/apps/stt/deployment.yaml new file mode 100644 index 0000000..8455eb6 --- /dev/null +++ b/k8s/apps/stt/deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: stt-server + namespace: ai +spec: + replicas: 1 + selector: + matchLabels: + app: stt-server + template: + metadata: + labels: + app: stt-server + spec: + containers: + - name: stt-server + image: ghcr.io/alkatrazz24/funk-stt-server:latest + ports: + - containerPort: 8000 + env: + # LiteLLM (s01) joint via le Service Endpoints litellm-ext + - name: STT_LITELLM_URL + value: "http://litellm-ext:4000/v1/chat/completions" + - name: STT_LITELLM_KEY + value: "lm-studio" + - name: STT_MODEL + value: "hermes-default" + readinessProbe: + httpGet: + path: /healthz + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 20 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/k8s/apps/stt/ingress.yaml b/k8s/apps/stt/ingress.yaml new file mode 100644 index 0000000..43798c4 --- /dev/null +++ b/k8s/apps/stt/ingress.yaml @@ -0,0 +1,14 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: stt-server + namespace: ai +spec: + entryPoints: + - web + routes: + - match: Host(`stt.lab.local`) + kind: Rule + services: + - name: stt-server + port: 8000 diff --git a/k8s/apps/stt/litellm-external.yaml b/k8s/apps/stt/litellm-external.yaml new file mode 100644 index 0000000..28528e4 --- /dev/null +++ b/k8s/apps/stt/litellm-external.yaml @@ -0,0 +1,22 @@ +# LiteLLM tourne sur storage-01 (HORS cluster) — on l'expose comme un Service interne +# via un Service sans sélecteur + Endpoints manuel pointant vers l'IP LAN cluster de s01. +apiVersion: v1 +kind: Service +metadata: + name: litellm-ext + namespace: ai +spec: + ports: + - port: 4000 + targetPort: 4000 +--- +apiVersion: v1 +kind: Endpoints +metadata: + name: litellm-ext + namespace: ai +subsets: + - addresses: + - ip: 192.168.10.1 # storage-01, IP LAN cluster (cf. CLAUDE.md) + ports: + - port: 4000 diff --git a/k8s/apps/stt/service.yaml b/k8s/apps/stt/service.yaml new file mode 100644 index 0000000..a12c14a --- /dev/null +++ b/k8s/apps/stt/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: stt-server + namespace: ai +spec: + selector: + app: stt-server + ports: + - port: 8000 + targetPort: 8000 diff --git a/stt/README.md b/stt/README.md index cad363b..87c301e 100644 --- a/stt/README.md +++ b/stt/README.md @@ -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@#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é. diff --git a/stt/config/README.md b/stt/client/config/README.md similarity index 100% rename from stt/config/README.md rename to stt/client/config/README.md diff --git a/stt/client/config/stt.example.toml b/stt/client/config/stt.example.toml new file mode 100644 index 0000000..4751f25 --- /dev/null +++ b/stt/client/config/stt.example.toml @@ -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/.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" diff --git a/stt/pyproject.toml b/stt/client/pyproject.toml similarity index 65% rename from stt/pyproject.toml rename to stt/client/pyproject.toml index 98281ac..4583a9a 100644 --- a/stt/pyproject.toml +++ b/stt/client/pyproject.toml @@ -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 ] diff --git a/stt/stt/README.md b/stt/client/stt/README.md similarity index 100% rename from stt/stt/README.md rename to stt/client/stt/README.md diff --git a/stt/stt/__init__.py b/stt/client/stt/__init__.py similarity index 100% rename from stt/stt/__init__.py rename to stt/client/stt/__init__.py diff --git a/stt/client/stt/api.py b/stt/client/stt/api.py new file mode 100644 index 0000000..1a6197c --- /dev/null +++ b/stt/client/stt/api.py @@ -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 diff --git a/stt/stt/cli.py b/stt/client/stt/cli.py similarity index 62% rename from stt/stt/cli.py rename to stt/client/stt/cli.py index dadc0c9..7b4209e 100644 --- a/stt/stt/cli.py +++ b/stt/client/stt/cli.py @@ -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/.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) diff --git a/stt/stt/config.py b/stt/client/stt/config.py similarity index 53% rename from stt/stt/config.py rename to stt/client/stt/config.py index 444efd1..be723b5 100644 --- a/stt/stt/config.py +++ b/stt/client/stt/config.py @@ -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) diff --git a/stt/stt/hud/README.md b/stt/client/stt/hud/README.md similarity index 100% rename from stt/stt/hud/README.md rename to stt/client/stt/hud/README.md diff --git a/stt/stt/hud/index.html b/stt/client/stt/hud/index.html similarity index 100% rename from stt/stt/hud/index.html rename to stt/client/stt/hud/index.html diff --git a/stt/stt/memory/__init__.py b/stt/client/stt/memory/__init__.py similarity index 100% rename from stt/stt/memory/__init__.py rename to stt/client/stt/memory/__init__.py diff --git a/stt/stt/memory/store.py b/stt/client/stt/memory/store.py similarity index 100% rename from stt/stt/memory/store.py rename to stt/client/stt/memory/store.py diff --git a/stt/client/stt/ui/__init__.py b/stt/client/stt/ui/__init__.py new file mode 100644 index 0000000..aa21a21 --- /dev/null +++ b/stt/client/stt/ui/__init__.py @@ -0,0 +1,5 @@ +"""UI locale : sert le HUD (HTTP statique) + diffuse les états (websocket).""" + +from stt.ui.app import serve + +__all__ = ["serve"] diff --git a/stt/stt/server/app.py b/stt/client/stt/ui/app.py similarity index 100% rename from stt/stt/server/app.py rename to stt/client/stt/ui/app.py diff --git a/stt/stt/voice/__init__.py b/stt/client/stt/voice/__init__.py similarity index 100% rename from stt/stt/voice/__init__.py rename to stt/client/stt/voice/__init__.py diff --git a/stt/stt/voice/engine.py b/stt/client/stt/voice/engine.py similarity index 94% rename from stt/stt/voice/engine.py rename to stt/client/stt/voice/engine.py index f5ac53a..77542b0 100644 --- a/stt/stt/voice/engine.py +++ b/stt/client/stt/voice/engine.py @@ -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) diff --git a/stt/config/stt.example.toml b/stt/config/stt.example.toml deleted file mode 100644 index f4eb4f9..0000000 --- a/stt/config/stt.example.toml +++ /dev/null @@ -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/.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" diff --git a/stt/server/Dockerfile b/stt/server/Dockerfile new file mode 100644 index 0000000..b3d02bc --- /dev/null +++ b/stt/server/Dockerfile @@ -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"] diff --git a/stt/server/README.md b/stt/server/README.md new file mode 100644 index 0000000..bbafb32 --- /dev/null +++ b/stt/server/README.md @@ -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. diff --git a/stt/memory/.gitignore b/stt/server/memory/.gitignore similarity index 100% rename from stt/memory/.gitignore rename to stt/server/memory/.gitignore diff --git a/stt/memory/README.md b/stt/server/memory/README.md similarity index 100% rename from stt/memory/README.md rename to stt/server/memory/README.md diff --git a/stt/memory/distilled/.gitkeep b/stt/server/memory/distilled/.gitkeep similarity index 100% rename from stt/memory/distilled/.gitkeep rename to stt/server/memory/distilled/.gitkeep diff --git a/stt/server/pyproject.toml b/stt/server/pyproject.toml new file mode 100644 index 0000000..e992fb3 --- /dev/null +++ b/stt/server/pyproject.toml @@ -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*"] diff --git a/stt/server/stt_server/__init__.py b/stt/server/stt_server/__init__.py new file mode 100644 index 0000000..f614a39 --- /dev/null +++ b/stt/server/stt_server/__init__.py @@ -0,0 +1,3 @@ +"""STT-server — orchestrateur AI in-cluster pour les clients STT.""" + +__version__ = "0.1.0" diff --git a/stt/server/stt_server/app.py b/stt/server/stt_server/app.py new file mode 100644 index 0000000..288517a --- /dev/null +++ b/stt/server/stt_server/app.py @@ -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 diff --git a/stt/server/stt_server/brain.py b/stt/server/stt_server/brain.py new file mode 100644 index 0000000..dadba4e --- /dev/null +++ b/stt/server/stt_server/brain.py @@ -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() diff --git a/stt/server/stt_server/config.py b/stt/server/stt_server/config.py new file mode 100644 index 0000000..c7dca04 --- /dev/null +++ b/stt/server/stt_server/config.py @@ -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() diff --git a/stt/stt/brain/__init__.py b/stt/stt/brain/__init__.py deleted file mode 100644 index 00da79a..0000000 --- a/stt/stt/brain/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Routeur cerveau — 3 modes + auto-détection LAN.""" - -from stt.brain.router import BrainRouter - -__all__ = ["BrainRouter"] diff --git a/stt/stt/brain/router.py b/stt/stt/brain/router.py deleted file mode 100644 index 60d34b2..0000000 --- a/stt/stt/brain/router.py +++ /dev/null @@ -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 diff --git a/stt/stt/server/__init__.py b/stt/stt/server/__init__.py deleted file mode 100644 index 2fe108f..0000000 --- a/stt/stt/server/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Serveur : websocket (états → HUD) + petit HTTP statique pour servir le HUD.""" - -from stt.server.app import serve - -__all__ = ["serve"]