correction agent

This commit is contained in:
Denis Defolie
2026-10-06 18:16:06 +02:00
parent 3051d6abbe
commit ec88de1b3c
15 changed files with 207 additions and 47 deletions
+86 -22
View File
@@ -61,6 +61,8 @@ profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé
| Périphériques I2S | I2S0 = micro (RX), I2S1 = ampli (TX) | duplex séparé, pas de conflit d'horloges |
| Broches | voir `firmware/include/config.h` | aucune broche de strapping (0, 2, 5, 12, 15) sur l'audio |
| Broker de dev : conteneur `mosquitto_services-mosquitto-1`, compose `~/srv/docker-compose.mosquitto.yml`, config montée `~/srv/mosquitto/config/mosquitto.conf` ; **IP du PC = 192.168.1.104** (et non .0.104) | 2026-09-29 | constaté sur la machine |
| **Broker = Raspberry Pi 5, 192.168.1.200**, comptes exigés (anonyme refusé) ; un seul réglage : `mqtt.host` de `server/config.yaml`, relu par le Makefile (`MQTT_HOST`) | 2026-10-06 | décision utilisateur (« broker sur pi5 ») ; le conteneur local du PC de dev (192.168.1.104, anonyme) tourne encore mais n'est plus utilisé |
| Carte : Wi-Fi et broker en **NVS**, réglés par le portail `VoiceAssist-Config` (http://192.168.4.1, bouton PTT tenu 3 s au démarrage) ; `secrets.h` n'est plus qu'un repli si la NVS est vide (`WifiConfig`, ajouté par l'utilisateur) | 2026-10-06 | changer de broker = portail, pas recompilation |
| MQTT : état JSON **retenu** + testament `{"status":"offline"}` retenu QoS 1, keepalive 10 s | 2026-09-29 | `offline` en 15 s max après coupure ; serveur informé même s'il démarre après l'ESP32 |
| Wi-Fi : `setSleep(false)` | 2026-09-29 | le modem endormi ajoute 100 ms+ de latence : incompatible avec le flux audio |
| Partitions `huge_app.csv` (3 Mo d'application, pas d'OTA) | 2026-09-29 | Wi-Fi + MQTT = 74 % de la partition par défaut |
@@ -129,6 +131,7 @@ profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé
| Niveau complet seulement si **score ≥ 0,6** (`full_min_score`), sinon rétrogradé en standard | 2026-09-30 | la voix n'est pas une preuve : une identification de justesse ne doit pas tout ouvrir |
| Règles par appareil/action ; **action absente = niveau complet** | 2026-09-30 | on ne permet jamais par oubli |
| Actions sensibles (`confirm: [door.open]`) : question, puis « oui »/« non » **de la même personne**, sur la même carte, en 15 s ; toute autre phrase abandonne la demande | 2026-09-30 | garde-fou pour la porte et pour les agents Linux (15b) |
| Confirmation : acceptée si la voix est reconnue comme le demandeur, **ou** si elle est sous le seuil mais que le demandeur reste le **profil le plus proche** avec `access.confirm_min_score` (**0,25**) ; voix reconnue comme quelqu'un d'autre : refusée | 2026-10-06 | mesuré : 6 « oui » seuls de Denis (~0,7 s de parole) à 0,29-0,47, 5 sous le seuil 0,45 ; autres profils face à Denis ≤ 0,12 (profils entiers, surtout voix de synthèse : à revoir avec de vraies personnes) |
| Droits appliqués au **dernier locuteur** de la session | 2026-09-30 | c'est lui qui attend la réponse (cohérent avec l'étape 10) |
| Broker : `allow_anonymous false` + `password_file` + `acl_file` ; comptes `voice-server` (voice/# et home/# en lecture-écriture) et **un par carte, nom = DEVICE_ID** (`pattern ... voice/%u/...`) | 2026-09-30 | micro inaudible et commandes impossibles pour le reste du réseau ; une carte ne peut ni écouter ni usurper une autre (vérifié sur broker jetable) |
| Outils `make mqtt-*` : identifiants du serveur relus dans `server/.env` (`-include`), recettes en `@` | 2026-09-30 | pas de compte supplémentaire ; mot de passe jamais affiché par make ; mot de passe hexadécimal (`#`, `$` cassent make) |
@@ -144,7 +147,7 @@ profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé
| « Allume » = **Wake-on-LAN** envoyé par le serveur (paquet magique UDP port 9, `broadcast` configurable), si agent hors ligne et MAC connue | 2026-09-30 | machine éteinte = pas d'agent |
| Phrase qui **nomme une machine** traitée avant la domotique ; noms de plusieurs mots (« pc du bureau ») | 2026-09-30 | « éteins le PC du bureau » ≠ « éteins la lumière du bureau » (bureau est une pièce) |
| Droits : `access.rules.machine` (wake/lock standard, shutdown/reboot complet), `confirm: [machine.shutdown, machine.reboot]` ; `MachineCommand` porte ses phrases (`said`, `asked`) | 2026-09-30 | réutilise étape 14 sans changer `DeviceCommand` ; `CommandRouter` aiguille vers Home/MachineController |
| Service systemd **root** (`/opt/voice-agent`, `/etc/voice-agent/agent.yaml` 0600), `Restart=always` ; `agent/` autonome (`make -C agent install`) | 2026-09-30 | poweroff/reboot exigent root ; il suffit de copier le dossier sur la machine |
| Service systemd **root** (`/opt/voice-agent`, `/etc/voice-assistant/agent.yaml` 0600), `Restart=always` ; `agent/` autonome (`make -C agent install`) | 2026-09-30 | poweroff/reboot exigent root ; il suffit de copier le dossier sur la machine |
| `dry_run` dans agent.yaml ; `make agent-run` crée `agent/agent.yaml` en mode essai | 2026-09-30 | tester « éteins le PC » sur le PC de développement sans l'éteindre |
| **TLS non activé** (option de l'étape 15) ; **numéros de séquence** reportés à l'étape 16 (§3.4 du prompt : « phase 16 ») | 2026-09-30 | comptes + ACL ferment l'accès au micro ; TLS = certificats + ~40 Ko de RAM ESP32, à décider par l'utilisateur |
| `make run` = **mode assistant** ; `make run-echo` = étape 5 | 2026-09-29 | une file + un fil de travail : les sessions sont traitées une à une, hors du fil réseau |
@@ -212,8 +215,9 @@ mosquitto/passwd comptes hachés (make mqtt-user), NON version
server/tests/test_mqtt_link.py présence, refus du broker, journal fichier [étape 15]
server/voice_server/machine_control.py MachineController, phrases, Wake-on-LAN [étape 15b]
server/voice_server/command_router.py CommandRouter : domotique / machines [étape 15b]
firmware/src/WifiConfig.* Wi-Fi + broker en NVS, portail AP/HTTP (utilisateur) [2026-10-06]
agent/voice_agent.py agent Linux autonome (liste blanche, dry_run) [étape 15b]
agent/agent.yaml.example modèle de /etc/voice-agent/agent.yaml [étape 15b]
agent/agent.yaml.example modèle de /etc/voice-assistant/agent.yaml [étape 15b]
agent/voice-agent.service, agent/Makefile service systemd, make -C agent install [étape 15b]
server/tests/test_machine_control.py, test_voice_agent.py [étape 15b]
server/ squelette Python (modules à partir de l'étape 3)
@@ -230,29 +234,58 @@ TLS (option de l'étape 15, non activée).
1. Lire ce fichier, puis `README.md`.
2. `make help` liste toutes les cibles.
3. Vérifier l'étape en cours dans le tableau du §2, et **ne rien faire au-delà**.
4. Contrôle rapide de l'environnement : `make test` (98 tests), `make lint`, `make fw-check`.
4. Contrôle rapide de l'environnement : `make test` (203 tests), `make lint`, `make fw-check`.
### État au 2026-09-29 au soir (fin de session)
### État au 2026-09-30 au soir (fin de session)
- (état du 2026-09-29 au soir, dépassé : l'étape 9 a été validée le 2026-09-30, voir le journal.)
- Étape 9 alors écrite et testée hors ligne. Étapes 1 à 8 validées.
- Tout était vert à l'arrêt : 98 tests, pyright 0 erreur, `make fw-check` SUCCESS (RAM 36,7 %,
Flash 31,3 %), `pip check` propre. Aucun serveur lancé.
- `server/.venv` contient les extras `speech` et `diarization` (torch CPU) ; `server/models/`
contient whisper `small`, la voix Piper et le pipeline pyannote (accès HF réglé, `HF_TOKEN` dans
`server/.env` — **secret de l'utilisateur, ne jamais le modifier ni l'afficher**).
- **Aucun profil vocal** : `server/profiles/` n'existe pas encore (mes essais étaient dans le scratchpad).
- Firmware inchangé depuis l'étape 5 (déjà sur la carte). Broker : conteneur Docker de l'utilisateur, 192.168.1.104.
- **Étapes 1 à 15 validées.** Étape **15b écrite**, vérifiée hors carte (broker jetable), **pas encore
essayée par l'utilisateur**. Ne pas commencer l'étape 16 avant sa validation explicite.
- Vérifié à l'arrêt : 203 tests, pyright 0 erreur (tools/ et agent/ inclus), `make fw-check`
SUCCESS 0 avertissement (RAM 36,7 %, Flash 31,5 %). Git : `main`, 1 commit, arbre propre.
Aucun serveur, agent ni conteneur de test lancé (`voice-acl-test`, `voice-agent-test` supprimés).
- Broker : **Raspberry Pi 5, 192.168.1.200, comptes exigés** (depuis le 2026-10-06). Le serveur s'y
connecte (`voice-server`, `server/.env`). Le conteneur local (192.168.1.104, anonyme) n'est plus utilisé.
- Firmware : la version **étape 13** est sur la carte (essais mains libres réussis) ; téléversement de
la version **étape 15** (chien de garde, présence serveur) **non confirmé**. Les deux fonctionnent
avec le serveur actuel, qui publie sa présence.
- `server/.venv` : extras `speech`, `diarization` (torch CPU), `wakeword` ; `server/models/` complet ;
`HF_TOKEN` dans `server/.env` — **secret de l'utilisateur, ne jamais le modifier ni l'afficher**.
Ne jamais toucher `firmware/include/secrets.h` (utiliser `make fw-check`).
- 6 profils vocaux : Denis (complet), Gilles, Jessica, Pierre, Siwis, Tom (standard). Phrases de
test au téléphone : `recordings/voix-test/` (`make say`, `WAKE="Hey Mycroft"` = voix anglaise).
- Mot de réveil retenu par l'utilisateur : **« Hey Mycroft »** (prononcé à l'anglaise).
### Reprise de l'étape 9 (actions de l'utilisateur)
### Reprise de l'étape 15b (actions de l'utilisateur)
1. `make run`, puis 5 questions variées de 1 à 4 s, seul.
2. Dans un autre terminal, sans arrêter le serveur : `make enroll NAME=Denis`.
3. Nouvelles questions → le journal doit afficher `SPEAKER_00 = Denis (0.7x)`.
4. Une autre voix (personne ou haut-parleur) → `Inconnu (…, proche de Denis)`.
5. `make diarize-demo` → votre voix reconnue, Piper « Inconnu ».
6. Analyser le journal collé, puis demander la validation de l'étape 9 avant l'étape 10
(association locuteur + texte : `{"speaker": "Denis", "text": "..."}`).
1. Terminal 1 : `make agent-run` (crée `agent/agent.yaml` en **mode essai**, `dry_run: true` ; vérifier
`id: pc-bureau`, `mqtt.host: 192.168.1.200` et le compte MQTT `pc-bureau`).
2. Terminal 2 : `make run` → `agent pc-bureau : en ligne, actions lock, reboot, shutdown`.
3. « Hey Mycroft, verrouille le PC du bureau » → « Je verrouille le PC du bureau. » ; agent :
`essai : loginctl lock-sessions (non execute, dry_run)`.
4. « Hey Mycroft, éteins le PC du bureau » → « Confirmez-vous… ? » → « Hey Mycroft, oui » → agent :
`essai : systemctl poweroff`.
5. « Hey Mycroft, allume le PC du bureau » → « …est déjà en marche. »
6. Ctrl-C sur l'agent → serveur : `agent pc-bureau : hors ligne` ; « éteins le PC du bureau » →
« …ne répond pas ».
7. Analyser les journaux collés, puis demander la validation de l'étape 15b avant l'étape 16.
### Points ouverts (ni oubliés, ni décidés)
- `machines.actions.*.words` : **un mot par entrée** (comparaison mot à mot) ; « lance la sauvegarde »
ne peut pas être reconnu tel quel. Proposé à l'utilisateur (2026-09-30) : accepter des expressions
de plusieurs mots. Pas de réponse encore. La négation n'est pas gérée (« n'éteins pas » = éteindre ;
la confirmation limite le risque).
- Test instable (rare, ~2 échecs sur 15 lancements de la suite, jamais seul) :
`test_diarization.py::test_embeddings_exist_for_a_short_question` (synthèse Piper aléatoire, existait
avant le 2026-10-06). Cause exacte non recherchée.
- « le PC » seul n'est pas un nom de machine (`names` : « pc du bureau »…) : à ajouter si l'utilisateur
le veut, ambigu avec plusieurs machines.
- Seuils du mot de réveil : réglage reporté (outil prêt : « mot presque reconnu »).
- Seuil d'identification 0,45 à recalibrer avec de vraies personnes.
- Whisper : hallucination sur 0,8 s de parole (« Je vous remercie de votre soutien… ») ; un « Ouvre »
perdu une fois en tête de commande.
- Étape 16 : diarisation en parallèle de Whisper, `start_ms` ~214 ms (fenêtre TCP lwIP ?), numéros de
séquence (§3.4 du prompt) ; TLS (option de l'étape 15) si l'utilisateur le demande.
---
@@ -269,7 +302,8 @@ Reste à valider **avec le matériel** : câblage, niveau du micro, intelligibil
## 7. Git
Dépôt initialisé le 2026-09-30 (branche `main`, aucun dépôt distant). Identité **locale au dépôt** :
Dépôt initialisé le 2026-09-30 (branche `main`). Dépôt distant ajouté par l'utilisateur :
`origin` = https://github.com/deunix-educ/voice-assistant.git (commits et push : par l'utilisateur, ou à sa demande). Identité **locale au dépôt** :
Denis Defolie <denis.defolie@gmail.com>. Commits et push seulement à la demande de l'utilisateur.
Vérifié avant le premier commit : `secrets.h`, `.env`, `mosquitto/passwd`, `agent/agent.yaml`,
profils vocaux, enregistrements (wav/mp3), modèles, journaux et environnements virtuels sont ignorés.
@@ -766,3 +800,33 @@ profils vocaux, enregistrements (wav/mp3), modèles, journaux et environnements
« ne répond pas ». 202 tests, pyright 0 erreur (agent/ inclus). En attente de l'essai réel.
- **2026-09-30** — **Git initialisé** à la demande de l'utilisateur, avant l'essai de l'étape 15b :
premier commit (108 fichiers) sur `main` ; contrôle des fichiers sensibles fait (§7).
- **2026-10-06** — Reprise sur le PC de dev. Entre-temps, 3 commits de l'utilisateur (`WifiConfig` :
portail de configuration et NVS ; README agent ; agent installé sous `/etc/voice-assistant`) et un
dépôt distant GitHub. Symptôme : « `make run` ne donne plus de réponse ». Cause : `mqtt.host` passé à
192.168.1.200 (Pi 5, comptes exigés), mais **la carte n'est sur aucun broker** : absente de .200
(aucun `voice/esp32-01/state`), `offline` sur .104. Serveur sain (connecté, agent `pc-bureau` vu en
ligne, `dry_run`). À 15:50, serveur éjecté toutes les 2 s : un autre client `voice-server` sur .200
(second serveur, arrêté depuis). Décision : broker sur le Pi 5. Fait : `MQTT_HOST` du Makefile relu
dans `config.yaml`, modèles (`agent.yaml.example`, `secrets.h.example`, README) en .200, restes de
`/etc/voice-agent` alignés sur `/etc/voice-assistant` (dont le défaut `--config` de l'agent).
**Reste à faire par l'utilisateur** : compte `esp32-01` sur le Pi, puis portail de la carte
(broker 192.168.1.200, utilisateur `esp32-01`, mot de passe). Étape 15b toujours en attente d'essai.
- **2026-10-06** — Carte reconnectée, chaîne complète rétablie sur le Pi 5. Réseau réel : point d'accès
**`RPI5-AP`** du Pi (NetworkManager, réseau **10.42.0.0/24**, Pi = **10.42.0.1** ; le Pi est aussi
192.168.1.200 en Ethernet, même broker). Carte : broker `10.42.0.1`, IP 10.42.0.73, signal −53 dBm.
Trois causes successives, lues dans le moniteur série (**921600 bauds**) : (1) session envoyée en
binaire sur le port série = MQTT non connecté (repli de l'étape 4) ; (2) `Reason: 211` : l'AP était
en **WPA1**, le core ESP32 3.x exige **WPA2** par défaut (`_minSecurity`) → AP passé en
`proto rsn`, CCMP, **canal 6** (était 13) ; (3) `Reason: 15 4WAY_HANDSHAKE_TIMEOUT` : mot de passe
Wi-Fi enregistré dans la carte faux, prouvé par `AP-STA-POSSIBLE-PSK-MISMATCH` dans le journal
`wpa_supplicant` du Pi → ressaisi dans le portail. Piège du portail `WifiConfig` : champs de mot de
passe toujours vides, un champ laissé vide efface la valeur enregistrée (correction proposée à
l'utilisateur, pas de réponse). Étape 15b toujours en attente d'essai et de validation.
- **2026-10-06** — Premiers essais réels de l'étape 15b (agent `pc-bureau` en `dry_run`, broker du Pi) :
« verrouille le PC du bureau » exécuté deux fois (agent : `essai : loginctl lock-sessions`).
(1) Mots anglais non reconnus (« Locke », « Reboot », « Shut down » → « Que dois-je faire… ») :
ajoutés à `machines.actions.*.words` (lock/locke/locker/loque, reboot/reboote/rebooter,
shutdown/shut). (2) Confirmation refusée : « OUI » de Denis à 0,35 = « voix inconnue » → règle du
profil le plus proche (`confirm_min_score: 0.25`, `AccessPolicy.resolve(..., closest, score)`, le
pipeline transmet `Identification.closest`). 214 tests, pyright 0 erreur. Serveur à relancer
(config.yaml lu au démarrage). Étape 15b : essais en cours, pas encore validée.
+3 -1
View File
@@ -22,7 +22,9 @@ SPEAKER ?=
SAY_OUT ?= recordings/voix-test/phrase.wav
WAKE ?=
WAKE_VOICE ?= en_GB-alan-medium
MQTT_HOST ?= 192.168.1.104
# Adresse du broker : UN seul réglage, mqtt.host de server/config.yaml, relu ici pour les
# cibles mqtt-* (surcharge ponctuelle : make mqtt-watch MQTT_HOST=...).
MQTT_HOST ?= $(shell awk '/^mqtt:/{m=1;next} m && $$1=="host:"{print $$2; exit}' server/config.yaml)
MSG ?= {"cmd":"ping"}
MOSQ_DIR ?= $(HOME)/srv/mosquitto
MOSQ_COMPOSE ?= $(HOME)/srv/docker-compose.mosquitto.yml
+2 -2
View File
@@ -202,7 +202,7 @@ exposed.
# 1. Copy the agent/ directory from the Raspberry Pi
scp -r /home/rpi5/voice-assistant/agent/ user@desktop-pc:~/voice-agent-install/
# 2. Install the service (creates /opt/voice-agent/, /etc/voice-agent/, systemd unit)
# 2. Install the service (creates /opt/voice-agent/, /etc/voice-assistant/, systemd unit)
cd ~/voice-agent-install
sudo make install
@@ -214,7 +214,7 @@ make mosquitto-docker # or mosquitto-config — reloads the broker
# 4. Configure the agent on the target machine
sudo nano /etc/voice-assistant/agent.yaml
# id: pc-bureau ← name used in MQTT topics
# mqtt.host: 192.168.1.104 ← broker IP address
# mqtt.host: 192.168.1.200 ← broker IP address
# mqtt.username: pc-bureau ← same value as id (empty if anonymous broker)
# mqtt.password: <password> ← generated in step 3 (empty if anonymous broker)
# dry_run: false ← true to test without executing anything
+2 -2
View File
@@ -203,7 +203,7 @@ configuration — aucun shell exposé.
# 1. Copier le répertoire agent/ (depuis le Raspberry Pi)
scp -r /home/rpi5/voice-assistant/agent/ user@pc-bureau:~/voice-agent/
# 2. Installer le service (crée /opt/voice-agent/, /etc/voice-agent/, unité systemd)
# 2. Installer le service (crée /opt/voice-agent/, /etc/voice-assistant/, unité systemd)
cd ~/voice-agent
sudo make install
@@ -215,7 +215,7 @@ make mosquitto-docker # ou mosquitto-config — recharge le broker
# 4. Configurer l'agent sur la machine cible
sudo nano /etc/voice-assistant/agent.yaml
# id: pc-bureau ← nom dans les topics MQTT
# mqtt.host: 192.168.1.104 ← adresse IP du broker
# mqtt.host: 192.168.1.200 ← adresse IP du broker
# mqtt.username: pc-bureau ← même valeur que id (vide si broker anonyme)
# mqtt.password: <mot-de-passe> ← généré à l'étape 3 (vide si broker anonyme)
# dry_run: false ← true pour tester sans rien exécuter
+1 -1
View File
@@ -40,4 +40,4 @@ uninstall: ## Retire le service et le programme (garde la configuration)
sudo rm -rf $(PREFIX)
log: ## Journal de l'agent installé (Ctrl-C pour quitter)
journalctl -u voice-agent -f
sudo journalctl -u voice-agent -f
+1 -1
View File
@@ -12,7 +12,7 @@
id: pc-bureau
mqtt:
host: 192.168.1.104 # adresse IP du broker
host: 192.168.1.200 # adresse IP du broker (Raspberry Pi 5)
port: 1883
username: "" # compte MQTT (étape 15), nom = id ; vide : broker anonyme
password: ""
+3 -3
View File
@@ -3,7 +3,7 @@
L'agent se connecte au broker MQTT et attend des NOMS d'action sur
agent/<id>/command. Il n'exécute que les actions de SON fichier de configuration
(/etc/voice-agent/agent.yaml), chacune étant une commande fixe, sans shell et
(/etc/voice-assistant/agent.yaml), chacune étant une commande fixe, sans shell et
sans argument venu du réseau : un message forgé ne peut rien lancer d'autre.
agent/<id>/state {"status":"online","actions":[...]} retenu ; testament « offline »
@@ -16,7 +16,7 @@ jamais exécutée à son retour.
Autonome : il ne dépend que de paho-mqtt et PyYAML, pas du serveur.
Exemples :
python3 voice_agent.py --config /etc/voice-agent/agent.yaml
python3 voice_agent.py --config /etc/voice-assistant/agent.yaml
make agent-run # sur le PC de développement, en mode essai (dry_run)
"""
@@ -252,7 +252,7 @@ class VoiceAgent:
def main(argv: list[str]) -> int:
"""Lance l'agent jusqu'à Ctrl-C ou SIGTERM (arrêt du service)."""
parser = argparse.ArgumentParser(description="agent de l'assistant vocal pour une machine Linux")
parser.add_argument("--config", type=Path, default=Path("/etc/voice-agent/agent.yaml"))
parser.add_argument("--config", type=Path, default=Path("/etc/voice-assistant/agent.yaml"))
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s")
try:
+1 -1
View File
@@ -22,7 +22,7 @@ static constexpr const char* WIFI_PASSWORD = "mon-mot-de-passe";
/// Adresse IP du broker Mosquitto (Raspberry Pi ou PC). Une adresse IP plutôt
/// qu'un nom : pas de résolution DNS, donc pas d'attente supplémentaire.
/// Sur Linux : `ip -4 addr` sur la machine du broker.
static constexpr const char* MQTT_HOST = "192.168.1.104";
static constexpr const char* MQTT_HOST = "192.168.1.200";
static constexpr uint16_t MQTT_PORT = 1883;
/// Compte MQTT de la carte (étape 15), créé par « make mqtt-user NAME=esp32-01 ».
+12 -5
View File
@@ -11,7 +11,7 @@ audio:
# étape 4 : broker MQTT. Le serveur écoute TOUTES les cartes : voice/+/...
mqtt:
host: localhost # le broker tourne sur la même machine que le serveur
host: 10.42.0.1 # broker du Raspberry Pi 5 (comptes exigés : server/.env) ; relu par le Makefile
port: 1883
keepalive: 30
client_id: voice-server # unique sur le broker
@@ -119,9 +119,14 @@ access:
machine: {wake: standard, lock: standard, shutdown: complet, reboot: complet} # étape 15b
confirm: [door.open, machine.shutdown, machine.reboot] # « Confirmez-vous ? » puis « oui »
confirm_timeout_s: 15
# Un « oui » seul dure ~0,7 s : trop court pour atteindre speaker.similarity_threshold
# (mesuré : Denis 0,29 à 0,47 ; les autres profils face à Denis : 0,12 au plus).
# La confirmation est donc acceptée si le demandeur reste le profil le PLUS PROCHE,
# avec au moins ce score. Une voix reconnue comme quelqu'un d'autre est refusée.
confirm_min_score: 0.25
# étape 15b : machines Linux du réseau. Chacune fait tourner un agent (agent/voice_agent.py)
# qui n'exécute QUE les actions de SA liste (/etc/voice-agent/agent.yaml) ; ici, on dit
# qui n'exécute QUE les actions de SA liste (/etc/voice-assistant/agent.yaml) ; ici, on dit
# seulement comment on les appelle. « allume » = Wake-on-LAN, envoyé par le serveur
# (la machine éteinte n'a pas d'agent). Nouvelle action : l'ajouter à l'agent, ici
# (mots et phrases), puis dans access.rules.machine (sinon : niveau complet exigé).
@@ -129,10 +134,12 @@ machines:
topic_prefix: agent # agent/<machine>/state (retenu), command, result
broadcast: 255.255.255.255 # Wake-on-LAN : diffusion sur le réseau local
actions: # mots sans accents ; « does » et « verb » : phrases dites
# Un mot par entrée, tel que Whisper l'écrit : « lock » devient souvent « Locke »,
# « shutdown » devient « Shut down » (d'où « shut »).
wake: {words: [allume, allumer, allumez, demarre, demarrer, reveille, reveiller], does: "J'allume", verb: allumer}
shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez], does: "J'éteins", verb: éteindre}
reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer], does: "Je redémarre", verb: redémarrer}
lock: {words: [verrouille, verrouiller, verrouillez], does: "Je verrouille", verb: verrouiller}
shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez, shutdown, shut], does: "J'éteins", verb: éteindre}
reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer, reboot, reboote, rebooter], does: "Je redémarre", verb: redémarrer}
lock: {words: [verrouille, verrouiller, verrouillez, lock, locke, locker, loque], does: "Je verrouille", verb: verrouiller}
list: # identifiant de l'agent → comment on l'appelle
pc-bureau:
# Plusieurs mots : « bureau » seul est une pièce. « p c » : Whisper écrit parfois « P.C. ».
+23
View File
@@ -105,6 +105,29 @@ def test_someone_else_cannot_confirm(policy: AccessPolicy) -> None:
assert resolution.answer.startswith("Seul Denis")
def test_short_yes_is_accepted_when_the_requester_stays_the_closest_voice(policy: AccessPolicy) -> None:
"""Essai réel du 2026-10-06 : « OUI » de Denis à 0,35, sous le seuil d'identification."""
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "OUI", None, closest="Denis", score=0.35)
assert resolution is not None and resolution.command == DOOR_OPEN
@pytest.mark.parametrize(
("speaker", "closest", "score"),
[
(None, "Denis", 0.12), # trop loin de Denis : n'importe qui
(None, "Jessica", 0.40), # plus proche de quelqu'un d'autre
(None, None, None), # pas d'empreinte du tout
("Jessica", "Jessica", 0.8), # reconnue comme quelqu'un d'autre
],
)
def test_short_yes_from_someone_else_is_refused(policy: AccessPolicy, speaker: str | None,
closest: str | None, score: float | None) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "oui", speaker, closest=closest, score=score)
assert resolution is not None and resolution.command is None
def test_confirmation_expires(policy: AccessPolicy, clock: Clock) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
clock.now += 16
+4
View File
@@ -57,6 +57,10 @@ def with_mac(mac: str = MAC) -> MachinesSettings:
("Éteins le P.C. du bureau.", "shutdown"),
("Tu peux redémarrer l'ordinateur du bureau ?", "reboot"),
("Verrouille le PC du bureau", "lock"),
# Mots anglais, tels que Whisper les a écrits à l'essai réel du 2026-10-06.
("Locke le PC du bureau", "lock"),
("Reboot le PC du bureau", "reboot"),
("Shut down le PC du bureau", "shutdown"),
("Allume le PC du bureau", "wake"),
("Le PC du bureau", None),
],
+32 -1
View File
@@ -131,7 +131,7 @@ def session(duration_s: float = 1.5, peak_dbfs: float = -12.0) -> SessionResult:
def pipeline(stt: FakeStt, tts: FakeTts, player: FakePlayer, vad: FakeVad | None = None,
diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | None = None,
diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | ShortYesIdentifier | None = None,
publisher: FakePublisher | None = None, assistant: EchoAssistant | None = None,
controller: FakeController | None = None, policy: AccessPolicy | None = None) -> VoicePipeline:
ticks = iter([10.0, 11.2]) # début, puis réponse prête 1,2 s plus tard
@@ -324,3 +324,34 @@ def test_sensitive_command_waits_for_yes() -> None:
controller=controller, policy=policy).handle(session())
assert second is not None and second.command == door
assert [call[1] for call in controller.calls] == [door]
class ShortYesIdentifier:
"""Un « oui » de 0,7 s : sous le seuil, mais Denis reste le profil le plus proche (ou non)."""
def __init__(self, closest: str, score: float) -> None:
self._verdict = Identification(name=None, score=score, closest=closest)
def identify(self, embedding: np.ndarray) -> Identification:
return self._verdict
@pytest.mark.parametrize(("closest", "score", "executed"), [("Denis", 0.35, True), ("Jessica", 0.35, False),
("Denis", 0.10, False)])
def test_short_yes_confirms_only_from_the_closest_voice(closest: str, score: float, executed: bool) -> None:
"""Essai réel du 2026-10-06 : « OUI » de Denis à 0,35 refusé comme « voix inconnue »."""
events: list[str] = []
controller = FakeController(events)
policy = AccessPolicy(ACCESS, HOME)
door = DeviceCommand("open", "door", "salon")
pipeline(FakeStt("ouvre la porte", words=DENIS_WORDS), FakeTts(), FakePlayer(events),
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=EchoAssistant(door),
controller=controller, policy=policy).handle(session())
second = pipeline(FakeStt("oui", words=(Word(" oui", 0.0, 0.1),)), FakeTts(), FakePlayer(events),
diarizer=FakeDiarizer(), identifier=ShortYesIdentifier(closest, score),
assistant=EchoAssistant(), controller=controller, policy=policy).handle(session())
assert second is not None
assert [call[1] for call in controller.calls] == ([door] if executed else [])
assert second.answer == ("J'ouvre la porte du salon." if executed else "Seul Denis peut confirmer. J'annule.")
+27 -4
View File
@@ -142,8 +142,15 @@ class AccessPolicy:
if speaker is not None:
self._pending[board] = _Pending(command, speaker, self._clock() + self._access.confirm_timeout_s)
def resolve(self, board: str, text: str, speaker: str | None) -> Resolution | None:
"""Réponse à une confirmation en attente ; None : pas de confirmation, requête ordinaire."""
def resolve(self, board: str, text: str, speaker: str | None, closest: str | None = None,
score: float | None = None) -> Resolution | None:
"""Réponse à une confirmation en attente ; None : pas de confirmation, requête ordinaire.
Args:
speaker: personne reconnue, None si la voix n'atteint pas le seuil d'identification.
closest: profil le plus proche, même sous le seuil.
score: similarité avec ce profil.
"""
pending = self._pending.pop(board, None)
if pending is None or self._clock() > pending.expires:
return None
@@ -152,14 +159,30 @@ class AccessPolicy:
said_no = any(word in NO for word in words)
if not (said_yes or said_no):
return None # autre chose : la demande en attente est abandonnée, on traite la nouvelle
if speaker != pending.speaker:
if speaker != pending.speaker and not self._probably(pending.speaker, speaker, closest, score):
logger.warning("%s : confirmation de %s refusee, donnee par %s", board, pending.speaker,
speaker or "une voix inconnue")
speaker or f"une voix inconnue (proche de {closest}, {self._shown(score)})")
return Resolution(f"Seul {pending.speaker} peut confirmer. J'annule.", None)
if speaker != pending.speaker:
logger.info("%s : confirmation de %s acceptee a %s : voix trop courte pour le seuil, mais "
"profil le plus proche", board, pending.speaker, self._shown(score))
if said_no:
return Resolution("D'accord, j'annule.", None)
return Resolution(self._announce(pending.command), pending.command)
def _probably(self, expected: str, speaker: str | None, closest: str | None, score: float | None) -> bool:
"""Voix non reconnue, mais la personne attendue reste la plus proche : assez pour un « oui ».
Un « oui » dure moins d'une seconde, trop peu pour une empreinte nette. La demande,
elle, a été reconnue au seuil normal quelques secondes plus tôt, sur la même carte.
"""
return (speaker is None and closest == expected and score is not None
and score >= self._access.confirm_min_score)
@staticmethod
def _shown(score: float | None) -> str:
return "?" if score is None else f"{score:.2f}"
def _request(self, command: Command) -> str:
"""« ouvrir la porte du salon », « éteindre le PC du bureau »."""
return command.asked if isinstance(command, MachineCommand) else request(command, self._home)
+2
View File
@@ -136,6 +136,7 @@ class AccessSettings:
rules: dict[str, dict[str, str]] # appareil → action → niveau minimal
confirm: tuple[str, ...] # « door.open » : demande « oui » avant d'exécuter
confirm_timeout_s: float
confirm_min_score: float = 0.25 # « oui » trop court pour le seuil : accepté si le demandeur reste le plus proche
@dataclass(frozen=True)
@@ -333,6 +334,7 @@ def load_settings(path: Path) -> Settings:
for device, actions in dict(access["rules"]).items()},
confirm=tuple(str(item) for item in access.get("confirm") or ()),
confirm_timeout_s=float(access["confirm_timeout_s"]),
confirm_min_score=float(access.get("confirm_min_score", 0.25)),
),
machines=_machines(data),
recordings_dir=(base / str(storage["recordings_dir"])).resolve(),
+8 -4
View File
@@ -57,7 +57,8 @@ class Policy(Protocol):
def ask(self, board: str, command: Command, speaker: str | None) -> None: ...
def resolve(self, board: str, text: str, speaker: str | None) -> Resolution | None: ...
def resolve(self, board: str, text: str, speaker: str | None, closest: str | None = None,
score: float | None = None) -> Resolution | None: ...
class Transcriber(Protocol):
@@ -191,7 +192,9 @@ class VoicePipeline:
last = utterances[-1] if utterances else None
speaker = last.speaker if last is not None and last.speaker != UNKNOWN else None
score = last.score if last is not None else None
reply, refused = self._decide(result.device, transcript.text, speaker, score)
verdict = verdicts.get(last.label) if last is not None else None
closest = verdict.closest if verdict is not None else None
reply, refused = self._decide(result.device, transcript.text, speaker, score, closest)
answer = reply.text
if reply.command is not None and self._controller is not None:
# La commande part avant la réponse parlée : la lumière s'allume pendant que la carte le dit.
@@ -209,13 +212,14 @@ class VoicePipeline:
stt_s=transcript.elapsed_s, tts_s=speech.elapsed_s, ready_s=ready_s,
answer_s=speech.audio_s)
def _decide(self, board: str, text: str, speaker: str | None, score: float | None) -> tuple[Reply, bool]:
def _decide(self, board: str, text: str, speaker: str | None, score: float | None,
closest: str | None = None) -> tuple[Reply, bool]:
"""Réponse et commande autorisée ; True si une commande a été refusée ou mise en attente."""
if not text:
return Reply(NOTHING_HEARD), False
if self._policy is not None:
# Une confirmation attendue passe avant tout : « oui » n'est pas une requête.
resolution = self._policy.resolve(board, text, speaker)
resolution = self._policy.resolve(board, text, speaker, closest, score)
if resolution is not None:
return Reply(resolution.answer, resolution.command), resolution.command is None
reply = self._assistant.respond(text, speaker, board)