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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

46
.github/workflows/build-stt-server.yml vendored Normal file
View 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 }}

150
admin/ia/stt.md Normal file
View file

@ -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 <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 »)
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).

View 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

View 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
View 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

View 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
View 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

56
stt/README.md Normal file
View file

@ -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@<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/
├── 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
```

View file

@ -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é).

View file

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

32
stt/client/pyproject.toml Normal file
View file

@ -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/**/*"]

24
stt/client/stt/README.md Normal file
View file

@ -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.

View file

@ -0,0 +1,3 @@
"""STT — assistant vocal Jarvis du homelab Funk."""
__version__ = "0.0.1"

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

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

70
stt/client/stt/cli.py Normal file
View file

@ -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/<voix>.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())

77
stt/client/stt/config.py Normal file
View file

@ -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

View file

@ -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.

View file

@ -0,0 +1,82 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>STT — Funk</title>
<style>
:root { --accent:#36d1ff; --bg:#04080f; --dim:#1b2a3a; }
* { box-sizing:border-box; }
body {
margin:0; height:100vh; background:radial-gradient(circle at 50% 40%, #0a1626, var(--bg));
color:#cfe9ff; font-family:system-ui, sans-serif; overflow:hidden;
display:flex; flex-direction:column; align-items:center; justify-content:center;
}
#core {
width:240px; height:240px; border-radius:50%; position:relative;
display:flex; align-items:center; justify-content:center; transition:all .4s;
box-shadow:0 0 60px -10px var(--accent);
}
#core::before, #core::after {
content:""; position:absolute; border-radius:50%; border:2px solid var(--accent);
inset:0; opacity:.5; animation:pulse 3s ease-in-out infinite;
}
#core::after { inset:30px; opacity:.25; animation-delay:.6s; }
#state { font-size:1.1rem; letter-spacing:.25em; text-transform:uppercase; }
.idle #core { box-shadow:0 0 50px -15px var(--accent); filter:saturate(.6); }
.listening #core { box-shadow:0 0 80px 0 var(--accent); }
.thinking #core { box-shadow:0 0 70px 0 #ffd23f; }
.speaking #core { box-shadow:0 0 90px 5px #36ff9e; }
@keyframes pulse { 0%,100%{transform:scale(1);opacity:.5} 50%{transform:scale(1.08);opacity:.15} }
#log {
position:absolute; bottom:0; left:0; right:0; max-height:32vh; overflow:auto;
padding:1rem 1.5rem; font-size:.95rem; line-height:1.5; backdrop-filter:blur(4px);
}
.turn { margin:.3rem 0; }
.user { color:#9fd0ff; }
.asst { color:#aef5cf; }
.err { color:#ff8c8c; }
.tag { opacity:.5; font-size:.75rem; margin-left:.5rem; }
#conn { position:absolute; top:12px; right:16px; font-size:.75rem; opacity:.6; }
</style>
</head>
<body class="idle">
<div id="conn"></div>
<div id="core"></div>
<div id="state">veille</div>
<div id="log"></div>
<script>
const WS_PORT = 9300; // doit matcher ui.ws_port
const LABELS = { idle:"veille", listening:"écoute", thinking:"réflexion", speaking:"réponse" };
const body = document.body, stateEl = document.getElementById("state");
const logEl = document.getElementById("log"), connEl = document.getElementById("conn");
function setState(s) {
body.className = s;
stateEl.textContent = LABELS[s] || s;
}
function addTurn(cls, text, tag) {
const d = document.createElement("div");
d.className = "turn " + cls;
d.textContent = text;
if (tag) { const t = document.createElement("span"); t.className="tag"; t.textContent=tag; d.appendChild(t); }
logEl.appendChild(d);
logEl.scrollTop = logEl.scrollHeight;
}
function connect() {
const ws = new WebSocket(`ws://${location.hostname}:${WS_PORT}`);
ws.onopen = () => connEl.style.color = "#36ff9e";
ws.onclose = () => { connEl.style.color = "#ff8c8c"; setTimeout(connect, 1500); };
ws.onmessage = (e) => {
const ev = JSON.parse(e.data);
if (ev.type === "state") setState(ev.state);
else if (ev.type === "user") addTurn("user", "🗣️ " + ev.text);
else if (ev.type === "assistant") addTurn("asst", "🤖 " + ev.text, ev.mode);
else if (ev.type === "error") addTurn("err", "⚠️ " + ev.text);
};
}
connect();
</script>
</body>
</html>

View file

@ -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"]

View file

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

View file

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

96
stt/client/stt/ui/app.py Normal file
View file

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

View file

@ -0,0 +1,5 @@
"""Moteur vocal — wake word, STT (faster-whisper), TTS (Piper)."""
from stt.voice.engine import VoiceEngine
__all__ = ["VoiceEngine"]

View file

@ -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

10
stt/server/Dockerfile Normal file
View file

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

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

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

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

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,27 @@
"""Configuration du STT-server — via variables d'environnement (12-factor / k8s)."""
from __future__ import annotations
import os
class Settings:
# LiteLLM (s01) — joint via un Service ExternalName/Endpoints dans le cluster.
litellm_url: str = os.getenv(
"STT_LITELLM_URL", "http://litellm-ext:4000/v1/chat/completions"
)
litellm_key: str = os.getenv("STT_LITELLM_KEY", "lm-studio")
model: str = os.getenv("STT_MODEL", "hermes-default")
system_prompt: str = os.getenv(
"STT_SYSTEM_PROMPT",
"Tu es Hermes, l'assistant vocal du homelab Funk. "
"Réponds toujours en français, de façon concise (2-3 phrases maximum), "
"sans markdown ni listes.",
)
max_tokens: int = int(os.getenv("STT_MAX_TOKENS", "200"))
temperature: float = float(os.getenv("STT_TEMPERATURE", "0.7"))
request_timeout: float = float(os.getenv("STT_REQUEST_TIMEOUT", "60"))
settings = Settings()