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 new file mode 100644 index 0000000..77b05f0 --- /dev/null +++ b/admin/ia/stt.md @@ -0,0 +1,150 @@ +# 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 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. + +> ⚠️ **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. + +--- + +## Principe directeur : réutiliser, ne pas réinventer + +~70 % du backend existe déjà dans Funk. STT n'ajoute que **le visage** (HUD), la +**personnalisation**, la **mémoire multi-tiers** et le **packaging en commande**. + +| Besoin Jarvis | Brique Funk réutilisée | État | +|---|---|---| +| Modèle local **ou** Claude | LiteLLM `:4000` + `hermes-default` + `hermes-switch qwen\|claude` | ✅ opérationnel | +| Agir sur le homelab | Hermes Agent (`:8080`, profils funk-ai/system/monitor/brain) | ✅ opérationnel | +| Voix (STT + TTS + wake word) | `tools/hermes-voice/` — faster-whisper + Piper + webrtcvad | ✅ existe (CLI only) | +| Démarrage auto au boot | pattern `systemd --user` (`tools/hermes-voice/install-service.sh`) | ✅ éprouvé | +| Mémoire sémantique | Qdrant `:6333` + RAG (`rag-query`/`rag-ingest`) sur s01 | ⚠️ **HS depuis 05/06** | + +Maillon réellement manquant : **l'interface graphique HUD** + le packaging + la mémoire perso. + +--- + +## Décisions d'architecture (verrouillées 2026-06-17) + +| Décision | Choix retenu | +|---|---| +| 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 + +``` + 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 — côté serveur + +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`. + +> **« 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. + +### La mémoire — côté serveur (futur) + +- **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. + +> ⚠️ **Vie privée** : seule la mémoire distillée serait committée. Repo privé impératif. + +--- + +## Composants + +| Composant | Emplacement | Description | +|---|---|---| +| 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 ») + +Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du HUD : + +- **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. 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 | É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 (testé hors-LAN) + +`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. + +--- + +## Projets de référence (inspiration, non forkés) + +Aucun ne se branche proprement sur LiteLLM + Hermes (ils embarquent leur propre LLM/agent), +mais bons pour le design et le code à piocher : + +- [AlexandreSajus/JARVIS](https://github.com/AlexandreSajus/JARVIS) — Voice→LLM→Speech, interface web (proche du front visé). +- [InterGenJLU/jarvis](https://github.com/InterGenJLU/jarvis) — AMD ROCm (comme la RX 6700XT), HUD santé, streaming TTS. +- [novik133/jarvis](https://github.com/novik133/jarvis) — whisper.cpp + Piper (même TTS) + monitoring système. +- [rhasspy/wyoming-addons](https://github.com/rhasspy/wyoming-addons) — faster-whisper / Piper conteneurisés (option phase 7). 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 new file mode 100644 index 0000000..87c301e --- /dev/null +++ b/stt/README.md @@ -0,0 +1,56 @@ +# STT — Assistant vocal "Jarvis" de Funk + +Assistant vocal type *Jarvis* pour le homelab Funk, en **architecture client-serveur** : + +- **`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). + +> 📐 Conception complète : `admin/ia/stt.md`. + +``` + 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 +``` + +## Client — installation (poste) + +```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/ +├── 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 +``` diff --git a/stt/client/config/README.md b/stt/client/config/README.md new file mode 100644 index 0000000..0bc2138 --- /dev/null +++ b/stt/client/config/README.md @@ -0,0 +1,25 @@ +# stt/config — personnalisation + +Tout ce qui se change **sans recompiler**. `stt --setup` copie `stt.example.toml` vers +`~/.config/stt/stt.toml`. + +``` +config/ +├── stt.example.toml # config de référence (modes cerveau, voix, thème, mémoire) +├── themes/ # thèmes HUD : couleurs, fond, style du visualiseur (CSS/JSON) +├── avatars/ # images / avatars affichés dans le HUD +└── voices/ # modèles Piper .onnx (TTS) — fr_FR par défaut +``` + +## Points de personnalisation + +| Quoi | Où | Note | +|---|---|---| +| Image / avatar | `avatars/` + `ui.avatar` | swappable à chaud | +| Thème / design | `themes/` + `ui.theme` | couleurs, fond, visualiseur | +| Voix | `voices/*.onnx` + `voice.piper_voice` | géré par `stt --setup` | +| Wake word | `voice.wake_word` | défaut « Ok Hermès » | +| Cerveau | `[brain]` | mode + override + endpoints | + +> 🔒 La clé Anthropic (mode `claude-direct`) passe par la variable d'env `ANTHROPIC_API_KEY`, +> **jamais** dans ce fichier (qui peut finir versionné). 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/client/pyproject.toml b/stt/client/pyproject.toml new file mode 100644 index 0000000..4583a9a --- /dev/null +++ b/stt/client/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "stt" +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" + +# 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 HTTP vers le STT-server + "webrtcvad-wheels>=2.0.10",# VAD / wake word + "websockets>=12.0", # backend ↔ HUD + "tomli-w>=1.0.0", # écriture config TOML +] + +[project.scripts] +stt = "stt.cli:main" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["stt*"] + +# Le HUD (assets web) est livré comme données du package stt/ +[tool.setuptools.package-data] +stt = ["hud/*", "hud/**/*"] diff --git a/stt/client/stt/README.md b/stt/client/stt/README.md new file mode 100644 index 0000000..372daef --- /dev/null +++ b/stt/client/stt/README.md @@ -0,0 +1,24 @@ +# stt/stt — package backend + +Cœur Python de la commande `stt`. Réutilise le moteur vocal de `tools/hermes-voice.py`. + +``` +stt/ +├── cli.py # entrypoint `stt` (--setup, --mode, --no-tts) ✅ phase 1 +├── config.py # défauts embarqués + chargement ~/.config/stt/stt.toml ✅ phase 1 +├── voice/ # wake word + VAD, STT faster-whisper, TTS Piper ✅ phase 1 +├── brain/ # routeur 3 modes hermes/local/claude + auto-détect LAN ✅ phase 2 +├── server/ # HTTP statique (HUD) + websocket (états → HUD) ✅ phase 1 +└── memory/ # tier local SQLite ✅ ; Qdrant s01 + sync GitHub ⏳ phase 4 +``` + +## États exposés au HUD (via websocket) + +`idle` (veille) → `listening` (écoute) → `thinking` (réflexion cerveau) → `speaking` (TTS) → `idle`. +Le HUD anime le visualiseur selon l'état et affiche transcript utilisateur + réponse. + +## Point de départ + +Le module `voice/` part de `tools/hermes-voice.py` (déjà fonctionnel : wake word « Ok Hermès », +faster-whisper, Piper, webrtcvad). À refactorer en classe + callbacks d'état pour le websocket, +puis brancher `brain/` (routeur) à la place de l'appel direct actuel. diff --git a/stt/client/stt/__init__.py b/stt/client/stt/__init__.py new file mode 100644 index 0000000..f317fe6 --- /dev/null +++ b/stt/client/stt/__init__.py @@ -0,0 +1,3 @@ +"""STT — assistant vocal Jarvis du homelab Funk.""" + +__version__ = "0.0.1" 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/client/stt/cli.py b/stt/client/stt/cli.py new file mode 100644 index 0000000..7b4209e --- /dev/null +++ b/stt/client/stt/cli.py @@ -0,0 +1,70 @@ +"""Entrypoint de la commande `stt` (client).""" + +from __future__ import annotations + +import argparse +import asyncio +import sys + + +def _cmd_setup() -> int: + from stt.config import write_default_config + + path = write_default_config() + print(f"✓ Config : {path}") + 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(f" • URL du STT-server dans {path} ([server] url)") + return 0 + + +def _cmd_run(args) -> int: + from stt.api import ServerClient + from stt.config import load_config + from stt.memory import LocalMemory + from stt.ui import serve + from stt.voice import VoiceEngine + + cfg = load_config() + if args.server: + cfg["server"]["url"] = args.server + + 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"], client.ask, emit, memory=memory, no_tts=args.no_tts + ) + + try: + asyncio.run(serve(cfg, make_engine)) + except KeyboardInterrupt: + print("\n👋 Au revoir") + finally: + if memory: + memory.close() + return 0 + + +def main(argv: list[str] | None = None) -> int: + 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("--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) + + if args.setup: + return _cmd_setup() + return _cmd_run(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py new file mode 100644 index 0000000..be723b5 --- /dev/null +++ b/stt/client/stt/config.py @@ -0,0 +1,77 @@ +"""Configuration du client STT. + +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 + +import tomllib +from pathlib import Path +from typing import Any + +CONFIG_DIR = Path.home() / ".config" / "stt" +CONFIG_PATH = CONFIG_DIR / "stt.toml" +DATA_DIR = Path.home() / ".local" / "share" / "stt" + +DEFAULT_CONFIG: dict[str, Any] = { + "server": { + # STT-server (in-cluster) — joignable via Traefik / wildcard *.lab.local + "url": "http://stt.lab.local", + "timeout_sec": 90, + }, + "voice": { + "wake_word": "hermes", + "whisper_model": "large-v3", + "piper_voice": "fr_FR-upmc-medium", + "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, + "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"), + }, +} + + +def _deep_merge(base: dict, over: dict) -> dict: + out = dict(base) + for k, v in over.items(): + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = v + return out + + +def load_config() -> dict[str, Any]: + if CONFIG_PATH.exists(): + with CONFIG_PATH.open("rb") as f: + user = tomllib.load(f) + return _deep_merge(DEFAULT_CONFIG, user) + return DEFAULT_CONFIG + + +def write_default_config(force: bool = False) -> Path: + import tomli_w + + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + DATA_DIR.mkdir(parents=True, exist_ok=True) + if CONFIG_PATH.exists() and not force: + return CONFIG_PATH + with CONFIG_PATH.open("wb") as f: + tomli_w.dump(DEFAULT_CONFIG, f) + return CONFIG_PATH diff --git a/stt/client/stt/hud/README.md b/stt/client/stt/hud/README.md new file mode 100644 index 0000000..553e4ad --- /dev/null +++ b/stt/client/stt/hud/README.md @@ -0,0 +1,20 @@ +# stt/hud — interface HUD web (à implémenter, phase 3) + +Front statique servi localement par le backend, ouvert en kiosk (`ui.kiosk = true`). +Se connecte au serveur websocket du backend. + +## Contenu prévu + +- **Visualiseur** : canvas/WebGL animé (arc-reactor / waveform) réagissant aux états + `idle / listening / thinking / speaking`. +- **Conversation** : transcript utilisateur + réponses de l'assistant. +- **Réglages** : thème, avatar, voix, wake word, mode cerveau — écrit dans `~/.config/stt/stt.toml`. + +## Design + +S'inspire de [AlexandreSajus/JARVIS](https://github.com/AlexandreSajus/JARVIS) (interface web) +et de l'esthétique Iron Man. Thèmes pilotés par `stt/config/themes/`, avatar par +`stt/config/avatars/`. + +Pas de framework lourd imposé — HTML/JS + canvas suffit pour le MVP. Le choix (vanilla vs +petit framework) sera tranché en phase 3. diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html new file mode 100644 index 0000000..20e1b06 --- /dev/null +++ b/stt/client/stt/hud/index.html @@ -0,0 +1,82 @@ + + + + + + STT — Funk + + + +
+
+
veille
+
+ + + + diff --git a/stt/client/stt/memory/__init__.py b/stt/client/stt/memory/__init__.py new file mode 100644 index 0000000..79b9d19 --- /dev/null +++ b/stt/client/stt/memory/__init__.py @@ -0,0 +1,8 @@ +"""Mémoire STT — tier local (SQLite) en phase 1. + +Tiers Qdrant (s01) et distillation/sync GitHub : phase 4 (voir admin/ia/stt.md). +""" + +from stt.memory.store import LocalMemory + +__all__ = ["LocalMemory"] diff --git a/stt/client/stt/memory/store.py b/stt/client/stt/memory/store.py new file mode 100644 index 0000000..2f15cb3 --- /dev/null +++ b/stt/client/stt/memory/store.py @@ -0,0 +1,49 @@ +"""Tier mémoire local : journal de conversation en SQLite (brut, gitignoré). + +Phase 1 : on persiste juste les tours de conversation. La distillation vers GitHub +et l'embedding Qdrant (s01) arrivent en phase 4. +""" + +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path + + +class LocalMemory: + def __init__(self, db_path: str): + self.path = Path(db_path).expanduser() + self.path.parent.mkdir(parents=True, exist_ok=True) + self._db = sqlite3.connect(str(self.path), check_same_thread=False) + self._db.execute( + """CREATE TABLE IF NOT EXISTS turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts REAL NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + mode TEXT + )""" + ) + self._db.commit() + + def log(self, role: str, content: str, mode: str | None = None) -> None: + self._db.execute( + "INSERT INTO turns (ts, role, content, mode) VALUES (?, ?, ?, ?)", + (time.time(), role, content, mode), + ) + self._db.commit() + + def recent(self, limit: int = 20) -> list[dict]: + cur = self._db.execute( + "SELECT ts, role, content, mode FROM turns ORDER BY id DESC LIMIT ?", + (limit,), + ) + rows = [ + {"ts": ts, "role": role, "content": content, "mode": mode} + for ts, role, content, mode in cur.fetchall() + ] + return list(reversed(rows)) + + def close(self) -> None: + self._db.close() 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/client/stt/ui/app.py b/stt/client/stt/ui/app.py new file mode 100644 index 0000000..ca25f20 --- /dev/null +++ b/stt/client/stt/ui/app.py @@ -0,0 +1,96 @@ +"""Serveur asyncio : sert le HUD (HTTP statique) et diffuse les états (websocket). + +Le moteur vocal tourne dans un thread (audio bloquant) et pousse ses événements via +`emit()`, relayés sur la boucle asyncio puis diffusés à tous les clients HUD connectés. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +# Le HUD est embarqué DANS le package (stt/hud) pour être livré par pipx. +HUD_DIR = Path(__file__).resolve().parent.parent / "hud" + + +def _start_http(host: str, port: int) -> ThreadingHTTPServer: + handler = partial(SimpleHTTPRequestHandler, directory=str(HUD_DIR)) + httpd = ThreadingHTTPServer((host, port), handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd + + +async def serve(cfg: dict[str, Any], make_engine) -> None: + """make_engine(emit) -> VoiceEngine. emit est thread-safe (depuis le thread voix).""" + import websockets + + ui = cfg["ui"] + loop = asyncio.get_running_loop() + clients: set = set() + last_state = {"type": "state", "state": "idle"} + + def broadcast(event: dict) -> None: + nonlocal last_state + if event.get("type") == "state": + last_state = event + data = json.dumps(event, ensure_ascii=False) + for ws in list(clients): + asyncio.create_task(_safe_send(ws, data)) + + async def _safe_send(ws, data: str) -> None: + try: + await ws.send(data) + except Exception: + clients.discard(ws) + + # emit appelé depuis le thread voix → on repasse sur la boucle asyncio + def emit(event: dict) -> None: + loop.call_soon_threadsafe(broadcast, event) + + async def handler(ws): + clients.add(ws) + try: + await ws.send(json.dumps(last_state, ensure_ascii=False)) + async for _ in ws: # le HUD n'envoie rien pour l'instant (phase 5 : réglages) + pass + finally: + clients.discard(ws) + + _start_http(ui["ws_host"], ui["http_port"]) + + engine = make_engine(emit) + # chargement Whisper + boucle audio dans un thread dédié + threading.Thread(target=_run_engine, args=(engine, emit), daemon=True).start() + + async with websockets.serve(handler, ui["ws_host"], ui["ws_port"]): + url = f"http://{ui['ws_host']}:{ui['http_port']}/" + print(f" HUD : {url}") + if ui.get("kiosk"): + _open_browser(url, kiosk=True) + await asyncio.Future() # tourne indéfiniment + + +def _run_engine(engine, emit) -> None: + try: + engine.load() + engine.run() + except Exception as e: # noqa: BLE001 + emit({"type": "error", "text": f"moteur vocal : {e}"}) + + +def _open_browser(url: str, kiosk: bool) -> None: + import shutil + import subprocess + import webbrowser + + for browser in ("chromium", "google-chrome", "chromium-browser"): + if shutil.which(browser): + flag = "--kiosk" if kiosk else "--new-window" + subprocess.Popen([browser, flag, url]) + return + webbrowser.open(url) diff --git a/stt/client/stt/voice/__init__.py b/stt/client/stt/voice/__init__.py new file mode 100644 index 0000000..f0bf754 --- /dev/null +++ b/stt/client/stt/voice/__init__.py @@ -0,0 +1,5 @@ +"""Moteur vocal — wake word, STT (faster-whisper), TTS (Piper).""" + +from stt.voice.engine import VoiceEngine + +__all__ = ["VoiceEngine"] diff --git a/stt/client/stt/voice/engine.py b/stt/client/stt/voice/engine.py new file mode 100644 index 0000000..77542b0 --- /dev/null +++ b/stt/client/stt/voice/engine.py @@ -0,0 +1,233 @@ +"""Moteur vocal STT. + +Refactor de tools/hermes-voice.py en classe avec callbacks d'état (`emit`), pour que +le serveur websocket puisse pousser l'état au HUD. Boucle : + veille → wake word "Ok Hermès" → conversation (parole libre) → retour veille. + +Dépendances lourdes (sounddevice, faster_whisper, webrtcvad) importées à la volée : +`stt --help` et la config fonctionnent sans micro ni modèle installé. +""" + +from __future__ import annotations + +import queue +import subprocess +import tempfile +import time +import unicodedata +from collections import deque +from pathlib import Path +from typing import Any, Callable + +SAMPLE_RATE = 16000 +PRE_ROLL = 15 +CHAT_EXIT = {"au revoir", "bonne nuit", "stop hermes", "fin"} + +PIPER_BIN = Path.home() / ".local/share/piper-runtime/piper" +PIPER_VOICE_DIR = Path.home() / ".local/share/piper" + + +def _deaccent(s: str) -> str: + return ( + unicodedata.normalize("NFD", s) + .encode("ascii", "ignore") + .decode("ascii") + .lower() + ) + + +class VoiceEngine: + """É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": …}, + {"type": "error", "text": …}. + """ + + def __init__( + self, + cfg: dict[str, Any], + responder: Callable[[str], str], + emit: Callable[[dict], None], + memory=None, + no_tts: bool = False, + ): + self.cfg = cfg + self.responder = responder + self.emit = emit + self.memory = memory + self.no_tts = no_tts + self._whisper = None + self._stop = False + + # ── chargement modèle ──────────────────────────────────────────────── + def load(self) -> None: + from faster_whisper import WhisperModel + + self._whisper = WhisperModel( + self.cfg["whisper_model"], device="cpu", compute_type="int8" + ) + + # ── STT ────────────────────────────────────────────────────────────── + def _transcribe(self, audio) -> str: + import numpy as np + + audio_f32 = audio.astype(np.float32) / 32768.0 + segments, _ = self._whisper.transcribe( + audio_f32, + language=self.cfg.get("language", "fr"), + task="transcribe", + beam_size=3, + vad_filter=True, + initial_prompt="Transcription en français.", + ) + return " ".join(seg.text for seg in segments).strip() + + def _detect_wake(self, text: str) -> tuple[bool, str]: + wake = self.cfg.get("wake_word", "hermes") + words = text.strip().split() + low = [_deaccent(w).strip(",.!?«»") for w in words] + try: + idx = next(i for i, w in enumerate(low) if wake in w) + return True, " ".join(words[idx + 1:]).strip() + except StopIteration: + return False, "" + + # ── TTS ────────────────────────────────────────────────────────────── + def _speak(self, text: str) -> None: + if self.no_tts: + return + voice = PIPER_VOICE_DIR / f"{self.cfg['piper_voice']}.onnx" + if not voice.exists() or not PIPER_BIN.exists(): + return + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + out = Path(f.name) + try: + subprocess.run( + [str(PIPER_BIN), "--model", str(voice), "--output_file", str(out)], + input=text.encode(), + capture_output=True, + check=True, + env={"LD_LIBRARY_PATH": str(PIPER_BIN.parent)}, + ) + subprocess.run(["aplay", "-q", str(out)], check=False) + except FileNotFoundError: + pass + finally: + out.unlink(missing_ok=True) + + # ── interrogation du serveur ───────────────────────────────────────── + def _respond(self, text: str) -> None: + self.emit({"type": "user", "text": text}) + if self.memory: + self.memory.log("user", text) + self.emit({"type": "state", "state": "thinking"}) + try: + resp = self.responder(text) + except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas + self.emit({"type": "error", "text": str(e)}) + self.emit({"type": "state", "state": "idle"}) + return + self.emit({"type": "assistant", "text": resp}) + if self.memory: + self.memory.log("assistant", resp) + self.emit({"type": "state", "state": "speaking"}) + self._speak(resp) + + # ── boucle principale (VAD + wake word) ────────────────────────────── + def run(self) -> None: + import numpy as np + import sounddevice as sd + import webrtcvad + + vad = webrtcvad.Vad(mode=3) + frame_ms = 20 + frame_samples = int(SAMPLE_RATE * frame_ms / 1000) + silence_sec = self.cfg.get("silence_sec", 1.2) + min_speech = self.cfg.get("min_speech_sec", 0.6) + chat_timeout = self.cfg.get("chat_timeout_sec", 60) + required_silent = int(silence_sec * 1000 / frame_ms) + + audio_q: queue.Queue = queue.Queue() + pre_roll: deque = deque(maxlen=PRE_ROLL) + chat_mode = False + last_activity = 0.0 + + def callback(indata, frames, time_info, status): + chunk = (indata[:, 0] if indata.ndim > 1 else indata.flatten()).copy() + audio_q.put(chunk) + + def record_until_silence() -> "np.ndarray | None": + captured, silent = [], 0 + while not self._stop: + try: + frame = audio_q.get(timeout=3.0) + except queue.Empty: + break + captured.append(frame) + try: + speech = vad.is_speech(frame.tobytes(), SAMPLE_RATE) + except Exception: + speech = True + silent = 0 if speech else silent + 1 + if silent >= required_silent: + break + if not captured: + return None + audio = np.concatenate(captured) + return audio if len(audio) / SAMPLE_RATE >= min_speech else None + + self.emit({"type": "state", "state": "idle"}) + with sd.InputStream( + samplerate=SAMPLE_RATE, channels=1, dtype="int16", + blocksize=frame_samples, callback=callback, + ): + while not self._stop: + if chat_mode and time.time() - last_activity > chat_timeout: + chat_mode = False + self.emit({"type": "state", "state": "idle"}) + + frame = audio_q.get() + pre_roll.append(frame) + try: + if not vad.is_speech(frame.tobytes(), SAMPLE_RATE): + continue + except Exception: + continue + + self.emit({"type": "state", "state": "listening"}) + captured = list(pre_roll) + tail = record_until_silence() + if tail is None: + self.emit({"type": "state", "state": "idle" if not chat_mode else "listening"}) + continue + audio = np.concatenate([np.concatenate(captured), tail]) + text = self._transcribe(audio) + if not text: + continue + + if chat_mode: + if any(w in _deaccent(text) for w in CHAT_EXIT): + chat_mode = False + self.emit({"type": "user", "text": text}) + self._speak("À bientôt !") + self.emit({"type": "state", "state": "idle"}) + continue + last_activity = time.time() + self._respond(text) + self.emit({"type": "state", "state": "listening"}) + else: + found, command = self._detect_wake(text) + if not found: + self.emit({"type": "state", "state": "idle"}) + continue + chat_mode = True + last_activity = time.time() + if command: + self._respond(command) + self.emit({"type": "state", "state": "listening"}) + + def stop(self) -> None: + self._stop = True 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/server/memory/.gitignore b/stt/server/memory/.gitignore new file mode 100644 index 0000000..6361353 --- /dev/null +++ b/stt/server/memory/.gitignore @@ -0,0 +1,5 @@ +# Mémoire brute — ne quitte jamais le poste +raw/ +*.sqlite +*.sqlite-* +*.db diff --git a/stt/server/memory/README.md b/stt/server/memory/README.md new file mode 100644 index 0000000..b4250e0 --- /dev/null +++ b/stt/server/memory/README.md @@ -0,0 +1,26 @@ +# stt/memory — mémoire 3 tiers + +``` +memory/ +├── distilled/ # mémoire DISTILLÉE — versionnée sur GitHub (faits, préférences, résumés) +└── raw/ # historique BRUT local — gitignoré, ne quitte jamais le poste +``` + +## Les 3 tiers + +| Tier | Support | Contenu | Versionné ? | +|---|---|---|---| +| **Local** | SQLite (`~/.local/share/stt/`) + `raw/` | Historique brut, working memory | ❌ gitignoré | +| **s01** | Qdrant `:6333` (collection `stt-memory`) | Sémantique long-terme, partagée RAG Funk | — (sur s01) | +| **GitHub** | `distilled/` | Faits, préférences, résumés distillés | ✅ committé | + +## Synchro + +1. **Démarrage** → `git pull` `distilled/` → charge en local. +2. **Usage** → écriture immédiate en SQLite local. +3. **Arrêt / périodique** → distillation (résumé via le cerveau) → `git commit` `distilled/` + **+** embedding dans Qdrant s01 (si LAN). +4. **Nouveau poste** → `distilled/` re-seed le local + Qdrant. + +> ⚠️ **Vie privée** : ne committer QUE de la mémoire distillée (pas de transcripts bruts). +> `raw/` et la SQLite sont gitignorés. Le repo doit rester privé. diff --git a/stt/server/memory/distilled/.gitkeep b/stt/server/memory/distilled/.gitkeep new file mode 100644 index 0000000..74b8f62 --- /dev/null +++ b/stt/server/memory/distilled/.gitkeep @@ -0,0 +1 @@ +# Mémoire distillée versionnée (faits, préférences, résumés). Rempli par le backend. 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()