diff --git a/.gitignore b/.gitignore index a5a40d6..6ea07e8 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ keys.txt # Divers .DS_Store *.swp + +# Playwright MCP session artifacts +.playwright-mcp/ diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 0bd7b29..39b7c9d 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -132,7 +132,7 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe | Client `stt` | `stt/client/` (pipx) | `stt` (voix+HUD), `stt --text` (chat texte), `stt --setup`, `stt --server `, `stt --model ` | | — 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 bidirectionnel (états → HUD, réglages → backend) ; HUD embarqué dans le package | +| — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD ; réglages **et messages texte** → backend) ; 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`) | @@ -231,7 +231,7 @@ optionnel — son absence n'empêche rien. | **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 en IP directe) | ✅ déployé | -| **4 — HUD avancé** | avatar portrait réactif (anneau/ping/spinner) + transcript + drawer réglages (modèle/reset/wake à chaud) + thème accent | ✅ (à tester sur poste) | +| **4 — HUD avancé** | avatar portrait réactif (anneau/ping/spinner) + transcript + **composer texte** (chat clavier sans micro) + drawer réglages (modèle/reset/wake à chaud) + thème accent | ✅ (à tester sur poste) | | **5a — Mémoire court-terme** | historique de session côté serveur | ✅ | | **5b — Mémoire long-terme** | Qdrant `stt-memory` + embeddings (dégrade si down) | ✅ validé 17/06 (rappel cross-session OK) | | **5c — Réparation Qdrant** | drop `funk-docs` corrompu + restart (s01) | ✅ (17/06) | @@ -246,6 +246,8 @@ Serveur déployé in-cluster via ArgoCD (image `sha-` gérée par CI). M validées en prod** : court-terme (historique de session) et long-terme (rappel cross-session « Felix », `points_count` confirmé via `/v1/memory/health`). HUD avancé (4) **implémenté** (design Claude Design, câblé au websocket backend) ; auto-start (6) via `stt --install-service`. +Le HUD inclut un **composer texte** (`{"type":"text"}` → `VoiceEngine.respond_text`) pour +dialoguer au clavier sans micro — utile tant que le bout-en-bout audio n'est pas validé. Reste côté poste : test audio bout-en-bout (micro → STT → serveur → TTS) et test du HUD/kiosk avec portraits réels. diff --git a/admin/incidents-llm-gpu.md b/admin/incidents-llm-gpu.md new file mode 100644 index 0000000..51d7ee1 --- /dev/null +++ b/admin/incidents-llm-gpu.md @@ -0,0 +1,87 @@ +# Incidents LLM / GPU — llama-server (gpu-01) + +Journal dédié des incidents d'**inférence** : `llama-server` (ROCm / RX 6700XT sur +gpu-01) et chaîne LLM `STT-server / Hermes → LiteLLM → llama-server`. Voir aussi +`incidents.md` (matériel & services storage-01/k8s) et `admin/ia/llama_server.md`. + +--- + +## 2026-06-19 — llama-server gelé : `/health` OK mais inférence bloquée → 502 sur tout le LLM + +### Symptômes + +- STT (HUD, **composer texte**) : `502 Server Error: Bad Gateway for url: http://stt.lab.local/v1/ask` +- Petite **activité GPU** visible sur gpu-01 (le slot d'inférence tourne en rond) +- STT-server : `GET /healthz` → 200, mais `POST /v1/ask` → **502** +- llama-server `:1234` : `/health` → `{"status":"ok"}` et `/v1/models` → `qwen3-8b` listé + → **l'apparence est « up »**, ce qui masque la panne +- Mais une **génération réelle ne renvoie jamais rien** : `curl /v1/chat/completions` + hang (45 s sans un seul token) +- LiteLLM (storage-01) : + `litellm.Timeout: APITimeoutError - Request timed out. timeout value=600.0, time taken=600.0s. + Received Model Group=hermes-default` (après 2 retries) + +### Cause + +Le `llama-server` (gpu-01, `:1234`, `qwen3-8b`) était dans un état **wedgé** après +~2 jours d'uptime (démarré le 2026-06-17 21:04). Les endpoints de contrôle +(`/health`, `/v1/models`) répondaient encore, mais le **slot d'inférence ROCm était +bloqué** (vraisemblablement une requête avortée laissée dans le slot). Chaîne du 502 : + +``` +HUD composer → STT-server (/v1/ask) → LiteLLM :4000 (hermes-default) → llama-server gpu-01 :1234 ← GELÉ +``` + +LiteLLM attend `request_timeout = 600 s`, échoue, et le STT-server renvoie 502. + +### Diagnostic (commandes utiles) + +```bash +# 1) STT-server : confirmer que le 502 vient de lui (et pas de Traefik) +kubectl -n ai logs --tail=50 # → "POST /v1/ask HTTP/1.1" 502 + +# 2) LiteLLM : voir le timeout +ssh storage-01 'journalctl -u litellm -n 40 --no-pager --output=cat' +# → litellm.Timeout ... timeout value=600.0 ... Model Group=hermes-default + +# 3) llama-server : /health MENT — tester une VRAIE génération +ssh gpu-01 'curl -s -m 30 http://127.0.0.1:1234/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d "{\"model\":\"qwen3-8b\",\"messages\":[{\"role\":\"user\",\"content\":\"Dis bonjour. /no_think\"}],\"max_tokens\":30}"' +# gelé (aucune sortie) = wedgé | réponse JSON = sain +``` + +### Résolution + +Redémarrer le service d'inférence sur gpu-01 : + +```bash +ssh gpu-01 'sudo systemctl restart llama-server' +``` + +- L'ancienne instance gelée met **~40 s à mourir** (état `deactivating` le temps que + le slot bloqué se libère) — `systemctl restart` peut dépasser un timeout SSH court, + c'est normal ; vérifier avec `systemctl is-active llama-server` jusqu'à `active`. +- Au redémarrage, `/v1/chat/completions` renvoie `503 {"error":"Loading model"}` + pendant ~30 s (rechargement du modèle en VRAM) — **normal, attendre**. +- **Validation** : génération directe (`"Bonjour."` en ~80 ms) puis bout-en-bout + `POST http://stt.lab.local/v1/ask` → `{"reply":"Bonjour.","model":"hermes-default"}`. + +### Observations / suites (hors présent incident) + +- ⚠️ **Piège du health-check** : `/health` et `/v1/models` restent verts quand le + serveur est wedgé → un check qui ne fait **pas une vraie génération** ne voit pas la + panne (même angle mort que le crash-loop Qdrant en `activating`, cf. `incidents.md`). +- **Latence cerveau** : post-restart, 30-45 s pour une réponse triviale via STT-server + alors que le llama-server direct est à ~80 ms → le surcoût vient du pipeline du + cerveau (mémoire/embeddings Qdrant `stt-memory` + LiteLLM). À investiguer séparément. +- **Bruit LiteLLM** : `Invalid model name model=qwen3-1.7b-monitor` (profil monitor + Hermes) dans les logs — pré-existant, non lié à cet incident. + +### Prévention (pistes) + +- **Probe applicative** : sonde qui fait une vraie génération `/v1/chat/completions` + (pas seulement `/health`) → alerte si le LLM ne produit plus de tokens. +- **Watchdog** : si LiteLLM accumule des `Timeout` sur `hermes-default`, redémarrer + automatiquement `llama-server` sur gpu-01. +- Réduire le `request_timeout` LiteLLM (600 s est très long → un 502 tardif et opaque). diff --git a/admin/incidents.md b/admin/incidents.md index 4299c0a..8b94ff9 100644 --- a/admin/incidents.md +++ b/admin/incidents.md @@ -1,5 +1,8 @@ # Incidents — Historique et résolutions +> Incidents **inférence LLM / GPU** (llama-server, ROCm gpu-01, chaîne LiteLLM) : +> journal dédié dans [`incidents-llm-gpu.md`](incidents-llm-gpu.md). + ## 2026-06-05 — Qdrant en crash-loop : segment funk-docs corrompu (RÉSOLU 2026-06-17) ### Symptômes @@ -48,6 +51,7 @@ curl -s http://127.0.0.1:6333/collections # doit répondre | Date | Composant | Résumé | Sévérité | |---|---|---|---| +| 2026-06-19 | llama-server (gpu-01) | Inférence gelée (`/health` OK, génération bloquée) → 502 LLM — **résolu** par restart. Détail : [`incidents-llm-gpu.md`](incidents-llm-gpu.md) | Important | | 2026-06-05 | Qdrant | **Résolu 2026-06-17** — crash-loop corrigé (drop `funk-docs`) ; RAG à re-ingérer | Important | | 2026-05-29 | RAID5 / sdb + storage-01 | sdb ATA bus errors sous I/O — migration /srv/data du RAID vers NVMe | Important | | 2026-05-27 | RAID5 / sdd | Disque sdd défaillant (44 secteurs morts) — corruptions multiples en cascade | Critique | diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 08273a1..5abbea5 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.4.2" +version = "0.5.1" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/cli.py b/stt/client/stt/cli.py index 1902d6e..a8c2958 100644 --- a/stt/client/stt/cli.py +++ b/stt/client/stt/cli.py @@ -180,34 +180,49 @@ def _cmd_version() -> int: def _cmd_update() -> int: - """Met à jour STT depuis sa source git (pipx reinstall) — sans désinstall/réinstall manuel.""" - import re + """Met à jour STT depuis sa source git, sans désinstall/réinstall manuel. + + Utilise `pipx upgrade` (non destructif : conserve l'install actuelle si l'upgrade échoue), + contrairement à `pipx reinstall` qui désinstalle d'abord et peut laisser sans rien. + """ + import json import shutil import subprocess pipx = shutil.which("pipx") if not pipx: print("pipx introuvable — mise à jour automatique impossible.") - print("Réinstalle manuellement depuis la source git.") return 1 + + # Source d'installation (pour la commande de secours en cas d'échec) + spec = None + try: + listing = subprocess.run( + [pipx, "list", "--json"], capture_output=True, text=True + ).stdout or "{}" + spec = json.loads(listing)["venvs"]["stt"]["metadata"]["main_package"]["package_or_url"] + except Exception: # noqa: BLE001 + pass + before = _installed_version() if before: print(f"Version installée : {before}") - print("Récupération de la dernière version depuis git (pipx reinstall)…\n") - res = subprocess.run([pipx, "reinstall", "stt"], capture_output=True, text=True) + print("Mise à jour depuis git (pipx upgrade)…\n") + res = subprocess.run([pipx, "upgrade", "stt"], capture_output=True, text=True) out = ((res.stdout or "") + (res.stderr or "")).strip() if out: print(out) - m = re.search(r"installed package stt ([0-9][^\s,]*)", out) - after = m.group(1) if m else None + print() - if after and before and after != before: - print(f"✓ Mis à jour : {before} → {after}") - elif after: - print(f"✓ Déjà à jour ({after}).") - else: - print("✓ Réinstallation terminée — vérifie avec : stt --version") - return res.returncode + if res.returncode != 0 or shutil.which("stt") is None: + print("❌ Échec de la mise à jour — ton install précédente est conservée.") + print(" Réessaie (souvent réseau/SSH), ou en dernier recours :") + print(" uv cache clean") + if spec: + print(f' pipx install --force "{spec}"') + return res.returncode or 1 + print("✓ Mise à jour terminée — vérifie avec : stt --version") + return 0 def _make_client(args): diff --git a/stt/client/stt/hud/README.md b/stt/client/stt/hud/README.md index c77cd9e..9816c45 100644 --- a/stt/client/stt/hud/README.md +++ b/stt/client/stt/hud/README.md @@ -14,6 +14,9 @@ renvoyer les réglages. du personnage si aucune image (voir [`avatars/README.md`](avatars/README.md)). - **Label d'état** : veille / écoute / réflexion / réponse. - **Transcript** : bulles utilisateur / assistant (+ tag du modèle) / erreur. +- **Composer texte** (sous le transcript) : champ + bouton « Envoyer » pour + dialoguer **au clavier** sans micro — Entrée envoie, Maj+Entrée saute une ligne. + Le texte passe par le même cerveau que le vocal (modèle local / avancé). - **Indicateur de connexion** websocket (point + libellé), reconnexion auto. - **Drawer Réglages** (engrenage) : intensité de l'accent (thème), avatar, voix, mot de réveil, mode cerveau (local/avancé), no-think, reset conversation. @@ -29,14 +32,18 @@ Reçus du backend (diffusés à tous les HUD connectés) : | `{"type":"assistant","text":…,"mode":…}` | bulle assistant (+ tag modèle) | | `{"type":"error","text":…}` | bulle d'erreur | -Envoyés au backend à chaque changement de réglage : +Envoyés au backend : ```json {"type":"settings","data":{"brainMode":"local","wakeword":"…","action":"reset"}} +{"type":"text","text":"quelle heure est-il ?"} ``` Le serveur applique `action:reset`, `brainMode` (→ modèle LiteLLM) et `wakeword` -**à chaud** (`_apply_settings` dans `stt/ui/app.py`). Les réglages purement +**à chaud** (`_apply_settings` dans `stt/ui/app.py`). Un message `text` est traité +comme un tour de conversation (`VoiceEngine.respond_text`, hors boucle audio) : le +backend renvoie alors les événements `user` puis `assistant` — le HUD n'ajoute donc +pas la bulle utilisateur lui-même quand il est connecté. Les réglages purement visuels (intensité d'accent, avatar) sont persistés côté navigateur (localStorage). ## Prévisualiser sans backend diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html index b8f2820..6f1affb 100644 --- a/stt/client/stt/hud/index.html +++ b/stt/client/stt/hud/index.html @@ -341,6 +341,58 @@ max-width: 90%; } + /* ============================================================ + COMPOSER — entrée texte (alternative au vocal) + ============================================================ */ + .composer { + display: flex; + gap: 10px; + align-items: flex-end; + width: 100%; + max-width: 860px; + margin: 8px auto 0; + padding: 0 26px 4px; + } + .composer textarea { + flex: 1 1 auto; + resize: none; + min-height: 46px; + max-height: 140px; + padding: 12px 16px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--bg); + color: var(--ink); + font: inherit; + font-size: 15px; + line-height: 1.4; + outline: none; + transition: border-color .2s var(--ease), box-shadow .2s var(--ease); + } + .composer textarea::placeholder { color: var(--ink-soft); } + .composer textarea:focus { + border-color: var(--accent-active); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-active) 22%, transparent); + } + .composer .send { + flex: 0 0 auto; + height: 46px; + padding: 0 18px; + border: none; + border-radius: 16px; + background: var(--accent-active); + color: #fff; + font: inherit; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + transition: filter .15s var(--ease), transform .1s var(--ease); + } + .composer .send:hover { filter: brightness(1.05); } + .composer .send:active { transform: translateY(1px); } + /* ============================================================ PANNEAU RÉGLAGES (drawer + voile) ============================================================ */ @@ -621,6 +673,17 @@
+ + +
+ + +
@@ -880,6 +943,43 @@ function sendSettings() { flashSaved(); } +/* ============================================================================ + COMPOSER — entrée texte (envoie {"type":"text","text":…} au backend) + Le backend renvoie ensuite les événements user + assistant : pas d'écho local + quand on est connecté (sinon double bulle utilisateur). + ============================================================================ */ +const composer = document.getElementById('composer'); +const textInput = document.getElementById('textInput'); + +function autosize(el) { + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 140) + 'px'; +} + +function sendText() { + const text = (textInput.value || '').trim(); + if (!text) return; + const payload = { type: 'text', text }; + if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) { + console.log('[DÉMO] text →', payload); + addBubble('user', text); // écho local seulement hors-ligne / démo + } else { + ws.send(JSON.stringify(payload)); // connecté : le backend renvoie user + assistant + } + textInput.value = ''; + autosize(textInput); + textInput.focus(); +} + +if (composer) { + composer.addEventListener('submit', (e) => { e.preventDefault(); sendText(); }); + textInput.addEventListener('input', () => autosize(textInput)); + textInput.addEventListener('keydown', (e) => { + // Entrée = envoyer ; Maj+Entrée = nouvelle ligne + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendText(); } + }); +} + /* ============================================================================ RÉGLAGES — collecte, persistance (thème en localStorage), UI ============================================================================ */ diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py index 39a0235..c60fa9a 100644 --- a/stt/client/stt/ui/app.py +++ b/stt/client/stt/ui/app.py @@ -77,14 +77,23 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: clients.add(ws) try: await ws.send(json.dumps(last_state, ensure_ascii=False)) - # Le HUD renvoie {"type":"settings","data":{...}} quand on change un réglage. + # Le HUD envoie {"type":"settings","data":{...}} (réglage) ou + # {"type":"text","text":"…"} (message tapé, alternative au vocal). async for raw in ws: try: msg = json.loads(raw) except (ValueError, TypeError): continue - if isinstance(msg, dict) and msg.get("type") == "settings": + if not isinstance(msg, dict): + continue + mtype = msg.get("type") + if mtype == "settings": _apply_settings(client, engine, msg.get("data") or {}, loop) + elif mtype == "text": + txt = (msg.get("text") or "").strip() + if txt and engine is not None: + # responder = appel HTTP bloquant → hors boucle asyncio + loop.run_in_executor(None, engine.respond_text, txt) finally: clients.discard(ws) @@ -130,13 +139,23 @@ def _open_browser(url: str, mode: str) -> None: profile = Path.home() / ".local" / "share" / "stt" / "app-profile" profile.mkdir(parents=True, exist_ok=True) win = ["--kiosk", url] if mode == "kiosk" else [f"--app={url}"] + # --log-level=3 : tait le bruit Chromium (viz/vaapi/RADV) à la source ; + # les flux sont en plus redirigés vers /dev/null (voir _spawn) pour ne pas + # polluer le terminal qui lance `stt`. + quiet = ["--log-level=3"] # Instance autonome (profil + classe dédiés) — pour les navigateurs natifs. native = [ "--class=STT-Funk", f"--user-data-dir={profile}", - "--no-first-run", "--no-default-browser-check", *win, + "--no-first-run", "--no-default-browser-check", *quiet, *win, ] # Flatpak est déjà sandboxé : pas de --user-data-dir (chemin hôte inaccessible). - flatpak = ["--class=STT-Funk", *win] + flatpak = ["--class=STT-Funk", *quiet, *win] + + def _spawn(argv: list[str]) -> None: + # Détaché et muet : la sortie du navigateur ne remonte pas dans `stt`. + subprocess.Popen( + argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) # 1) Binaire natif (rpm/deb) for browser in ( @@ -146,7 +165,7 @@ def _open_browser(url: str, mode: str) -> None: "microsoft-edge", "vivaldi", ): if shutil.which(browser): - subprocess.Popen([browser, *native]) + _spawn([browser, *native]) return # 2) Flatpak (universel — souvent la voie sur Fedora/AlmaLinux) @@ -157,7 +176,7 @@ def _open_browser(url: str, mode: str) -> None: ["flatpak", "info", appid], capture_output=True ).returncode == 0 if installed: - subprocess.Popen(["flatpak", "run", appid, *flatpak]) + _spawn(["flatpak", "run", appid, *flatpak]) return # 3) Repli : onglet dans le navigateur par défaut diff --git a/stt/client/stt/voice/engine.py b/stt/client/stt/voice/engine.py index 2debfa5..3b17886 100644 --- a/stt/client/stt/voice/engine.py +++ b/stt/client/stt/voice/engine.py @@ -13,6 +13,7 @@ from __future__ import annotations import queue import subprocess import tempfile +import threading import time import unicodedata from collections import deque @@ -64,6 +65,8 @@ class VoiceEngine: self.model_label = model_label self._whisper = None self._stop = False + # sérialise un tour (voix ou texte) : pas deux réponses concurrentes + self._respond_lock = threading.Lock() # ── chargement modèle ──────────────────────────────────────────────── def load(self) -> None: @@ -123,22 +126,35 @@ class VoiceEngine: # ── 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"}) + with self._respond_lock: # un seul tour à la fois (voix ⟂ texte) + 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 + mode = self.model_label() if self.model_label else None + self.emit({"type": "assistant", "text": resp, "mode": mode}) + if self.memory: + self.memory.log("assistant", resp) + self.emit({"type": "state", "state": "speaking"}) + self._speak(resp) + + def respond_text(self, text: str) -> None: + """Traite une entrée texte du HUD comme un tour de conversation (sans micro). + + Appelé hors boucle audio (depuis le serveur websocket, via un executor) : le + moteur n'a pas besoin d'être chargé/démarré pour répondre à du texte. + """ + text = (text or "").strip() + if not text: return - mode = self.model_label() if self.model_label else None - self.emit({"type": "assistant", "text": resp, "mode": mode}) - if self.memory: - self.memory.log("assistant", resp) - self.emit({"type": "state", "state": "speaking"}) - self._speak(resp) + self._respond(text) + self.emit({"type": "state", "state": "idle"}) # ── boucle principale (VAD + wake word) ────────────────────────────── def run(self) -> None: