mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 23:34:42 +02:00
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:
parent
1ff3fd9bed
commit
6947b394f1
38 changed files with 566 additions and 356 deletions
46
.github/workflows/build-stt-server.yml
vendored
Normal file
46
.github/workflows/build-stt-server.yml
vendored
Normal file
|
|
@ -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 }}
|
||||
159
admin/ia/stt.md
159
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 <hermes\|local\|claude>` |
|
||||
| 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 <url>`, `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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
24
k8s/apps-of-apps/apps/stt.yaml
Normal file
24
k8s/apps-of-apps/apps/stt.yaml
Normal file
|
|
@ -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
|
||||
47
k8s/apps/stt/deployment.yaml
Normal file
47
k8s/apps/stt/deployment.yaml
Normal file
|
|
@ -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
|
||||
14
k8s/apps/stt/ingress.yaml
Normal file
14
k8s/apps/stt/ingress.yaml
Normal file
|
|
@ -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
|
||||
22
k8s/apps/stt/litellm-external.yaml
Normal file
22
k8s/apps/stt/litellm-external.yaml
Normal file
|
|
@ -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
|
||||
11
k8s/apps/stt/service.yaml
Normal file
11
k8s/apps/stt/service.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: stt-server
|
||||
namespace: ai
|
||||
spec:
|
||||
selector:
|
||||
app: stt-server
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
|
|
@ -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é.
|
||||
|
|
|
|||
28
stt/client/config/stt.example.toml
Normal file
28
stt/client/config/stt.example.toml
Normal 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"
|
||||
|
|
@ -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
35
stt/client/stt/api.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
5
stt/client/stt/ui/__init__.py
Normal file
5
stt/client/stt/ui/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""UI locale : sert le HUD (HTTP statique) + diffuse les états (websocket)."""
|
||||
|
||||
from stt.ui.app import serve
|
||||
|
||||
__all__ = ["serve"]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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
10
stt/server/Dockerfile
Normal 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
44
stt/server/README.md
Normal 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
22
stt/server/pyproject.toml
Normal 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*"]
|
||||
3
stt/server/stt_server/__init__.py
Normal file
3
stt/server/stt_server/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
49
stt/server/stt_server/app.py
Normal file
49
stt/server/stt_server/app.py
Normal 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
|
||||
29
stt/server/stt_server/brain.py
Normal file
29
stt/server/stt_server/brain.py
Normal 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()
|
||||
27
stt/server/stt_server/config.py
Normal file
27
stt/server/stt_server/config.py
Normal 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()
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
"""Routeur cerveau — 3 modes + auto-détection LAN."""
|
||||
|
||||
from stt.brain.router import BrainRouter
|
||||
|
||||
__all__ = ["BrainRouter"]
|
||||
|
|
@ -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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
"""Serveur : websocket (états → HUD) + petit HTTP statique pour servir le HUD."""
|
||||
|
||||
from stt.server.app import serve
|
||||
|
||||
__all__ = ["serve"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue