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 | | 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 | | 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 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 | | 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 | | 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 | | 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 | | 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 | | 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) | | 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) | | 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) | | 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) | | 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 | | « 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) | | 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 | | 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 | | `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 | | **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 | | `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/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/machine_control.py MachineController, phrases, Wake-on-LAN [étape 15b]
server/voice_server/command_router.py CommandRouter : domotique / machines [é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/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] 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/tests/test_machine_control.py, test_voice_agent.py [étape 15b]
server/ squelette Python (modules à partir de l'étape 3) 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`. 1. Lire ce fichier, puis `README.md`.
2. `make help` liste toutes les cibles. 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à**. 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.) - **Étapes 1 à 15 validées.** Étape **15b écrite**, vérifiée hors carte (broker jetable), **pas encore
- Étape 9 alors écrite et testée hors ligne. Étapes 1 à 8 validées. essayée par l'utilisateur**. Ne pas commencer l'étape 16 avant sa validation explicite.
- Tout était vert à l'arrêt : 98 tests, pyright 0 erreur, `make fw-check` SUCCESS (RAM 36,7 %, - Vérifié à l'arrêt : 203 tests, pyright 0 erreur (tools/ et agent/ inclus), `make fw-check`
Flash 31,3 %), `pip check` propre. Aucun serveur lancé. SUCCESS 0 avertissement (RAM 36,7 %, Flash 31,5 %). Git : `main`, 1 commit, arbre propre.
- `server/.venv` contient les extras `speech` et `diarization` (torch CPU) ; `server/models/` Aucun serveur, agent ni conteneur de test lancé (`voice-acl-test`, `voice-agent-test` supprimés).
contient whisper `small`, la voix Piper et le pipeline pyannote (accès HF réglé, `HF_TOKEN` dans - Broker : **Raspberry Pi 5, 192.168.1.200, comptes exigés** (depuis le 2026-10-06). Le serveur s'y
`server/.env` — **secret de l'utilisateur, ne jamais le modifier ni l'afficher**). connecte (`voice-server`, `server/.env`). Le conteneur local (192.168.1.104, anonyme) n'est plus utilisé.
- **Aucun profil vocal** : `server/profiles/` n'existe pas encore (mes essais étaient dans le scratchpad). - Firmware : la version **étape 13** est sur la carte (essais mains libres réussis) ; téléversement de
- Firmware inchangé depuis l'étape 5 (déjà sur la carte). Broker : conteneur Docker de l'utilisateur, 192.168.1.104. 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. 1. Terminal 1 : `make agent-run` (crée `agent/agent.yaml` en **mode essai**, `dry_run: true` ; vérifier
2. Dans un autre terminal, sans arrêter le serveur : `make enroll NAME=Denis`. `id: pc-bureau`, `mqtt.host: 192.168.1.200` et le compte MQTT `pc-bureau`).
3. Nouvelles questions → le journal doit afficher `SPEAKER_00 = Denis (0.7x)`. 2. Terminal 2 : `make run` → `agent pc-bureau : en ligne, actions lock, reboot, shutdown`.
4. Une autre voix (personne ou haut-parleur) → `Inconnu (…, proche de Denis)`. 3. « Hey Mycroft, verrouille le PC du bureau » → « Je verrouille le PC du bureau. » ; agent :
5. `make diarize-demo` → votre voix reconnue, Piper « Inconnu ». `essai : loginctl lock-sessions (non execute, dry_run)`.
6. Analyser le journal collé, puis demander la validation de l'étape 9 avant l'étape 10 4. « Hey Mycroft, éteins le PC du bureau » → « Confirmez-vous… ? » → « Hey Mycroft, oui » → agent :
(association locuteur + texte : `{"speaker": "Denis", "text": "..."}`). `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 ## 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. 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`, 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. 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. « 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 : - **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). 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 SAY_OUT ?= recordings/voix-test/phrase.wav
WAKE ?= WAKE ?=
WAKE_VOICE ?= en_GB-alan-medium 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"} MSG ?= {"cmd":"ping"}
MOSQ_DIR ?= $(HOME)/srv/mosquitto MOSQ_DIR ?= $(HOME)/srv/mosquitto
MOSQ_COMPOSE ?= $(HOME)/srv/docker-compose.mosquitto.yml 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 # 1. Copy the agent/ directory from the Raspberry Pi
scp -r /home/rpi5/voice-assistant/agent/ user@desktop-pc:~/voice-agent-install/ 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 cd ~/voice-agent-install
sudo make 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 # 4. Configure the agent on the target machine
sudo nano /etc/voice-assistant/agent.yaml sudo nano /etc/voice-assistant/agent.yaml
# id: pc-bureau ← name used in MQTT topics # 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.username: pc-bureau ← same value as id (empty if anonymous broker)
# mqtt.password: <password> ← generated in step 3 (empty if anonymous broker) # mqtt.password: <password> ← generated in step 3 (empty if anonymous broker)
# dry_run: false ← true to test without executing anything # 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) # 1. Copier le répertoire agent/ (depuis le Raspberry Pi)
scp -r /home/rpi5/voice-assistant/agent/ user@pc-bureau:~/voice-agent/ 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 cd ~/voice-agent
sudo make install sudo make install
@@ -215,7 +215,7 @@ make mosquitto-docker # ou mosquitto-config — recharge le broker
# 4. Configurer l'agent sur la machine cible # 4. Configurer l'agent sur la machine cible
sudo nano /etc/voice-assistant/agent.yaml sudo nano /etc/voice-assistant/agent.yaml
# id: pc-bureau ← nom dans les topics MQTT # 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.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) # 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 # 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) sudo rm -rf $(PREFIX)
log: ## Journal de l'agent installé (Ctrl-C pour quitter) 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 id: pc-bureau
mqtt: mqtt:
host: 192.168.1.104 # adresse IP du broker host: 192.168.1.200 # adresse IP du broker (Raspberry Pi 5)
port: 1883 port: 1883
username: "" # compte MQTT (étape 15), nom = id ; vide : broker anonyme username: "" # compte MQTT (étape 15), nom = id ; vide : broker anonyme
password: "" password: ""
+3 -3
View File
@@ -3,7 +3,7 @@
L'agent se connecte au broker MQTT et attend des NOMS d'action sur 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 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. sans argument venu du réseau : un message forgé ne peut rien lancer d'autre.
agent/<id>/state {"status":"online","actions":[...]} retenu ; testament « offline » 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. Autonome : il ne dépend que de paho-mqtt et PyYAML, pas du serveur.
Exemples : 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) 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: def main(argv: list[str]) -> int:
"""Lance l'agent jusqu'à Ctrl-C ou SIGTERM (arrêt du service).""" """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 = 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) args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s")
try: 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 /// 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. /// qu'un nom : pas de résolution DNS, donc pas d'attente supplémentaire.
/// Sur Linux : `ip -4 addr` sur la machine du broker. /// 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; static constexpr uint16_t MQTT_PORT = 1883;
/// Compte MQTT de la carte (étape 15), créé par « make mqtt-user NAME=esp32-01 ». /// 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/+/... # étape 4 : broker MQTT. Le serveur écoute TOUTES les cartes : voice/+/...
mqtt: 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 port: 1883
keepalive: 30 keepalive: 30
client_id: voice-server # unique sur le broker client_id: voice-server # unique sur le broker
@@ -119,9 +119,14 @@ access:
machine: {wake: standard, lock: standard, shutdown: complet, reboot: complet} # étape 15b machine: {wake: standard, lock: standard, shutdown: complet, reboot: complet} # étape 15b
confirm: [door.open, machine.shutdown, machine.reboot] # « Confirmez-vous ? » puis « oui » confirm: [door.open, machine.shutdown, machine.reboot] # « Confirmez-vous ? » puis « oui »
confirm_timeout_s: 15 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) # é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 # 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 # (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é). # (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 topic_prefix: agent # agent/<machine>/state (retenu), command, result
broadcast: 255.255.255.255 # Wake-on-LAN : diffusion sur le réseau local broadcast: 255.255.255.255 # Wake-on-LAN : diffusion sur le réseau local
actions: # mots sans accents ; « does » et « verb » : phrases dites 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} 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} shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez, shutdown, shut], does: "J'éteins", verb: éteindre}
reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer], does: "Je redémarre", verb: redémarrer} reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer, reboot, reboote, rebooter], does: "Je redémarre", verb: redémarrer}
lock: {words: [verrouille, verrouiller, verrouillez], does: "Je verrouille", verb: verrouiller} lock: {words: [verrouille, verrouiller, verrouillez, lock, locke, locker, loque], does: "Je verrouille", verb: verrouiller}
list: # identifiant de l'agent → comment on l'appelle list: # identifiant de l'agent → comment on l'appelle
pc-bureau: pc-bureau:
# Plusieurs mots : « bureau » seul est une pièce. « p c » : Whisper écrit parfois « P.C. ». # 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") 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: def test_confirmation_expires(policy: AccessPolicy, clock: Clock) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis") policy.ask("esp32-01", DOOR_OPEN, "Denis")
clock.now += 16 clock.now += 16
+4
View File
@@ -57,6 +57,10 @@ def with_mac(mac: str = MAC) -> MachinesSettings:
("Éteins le P.C. du bureau.", "shutdown"), ("Éteins le P.C. du bureau.", "shutdown"),
("Tu peux redémarrer l'ordinateur du bureau ?", "reboot"), ("Tu peux redémarrer l'ordinateur du bureau ?", "reboot"),
("Verrouille le PC du bureau", "lock"), ("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"), ("Allume le PC du bureau", "wake"),
("Le PC du bureau", None), ("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, 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, publisher: FakePublisher | None = None, assistant: EchoAssistant | None = None,
controller: FakeController | None = None, policy: AccessPolicy | None = None) -> VoicePipeline: 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 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()) controller=controller, policy=policy).handle(session())
assert second is not None and second.command == door assert second is not None and second.command == door
assert [call[1] for call in controller.calls] == [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: if speaker is not None:
self._pending[board] = _Pending(command, speaker, self._clock() + self._access.confirm_timeout_s) 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: def resolve(self, board: str, text: str, speaker: str | None, closest: str | None = None,
"""Réponse à une confirmation en attente ; None : pas de confirmation, requête ordinaire.""" 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) pending = self._pending.pop(board, None)
if pending is None or self._clock() > pending.expires: if pending is None or self._clock() > pending.expires:
return None return None
@@ -152,14 +159,30 @@ class AccessPolicy:
said_no = any(word in NO for word in words) said_no = any(word in NO for word in words)
if not (said_yes or said_no): if not (said_yes or said_no):
return None # autre chose : la demande en attente est abandonnée, on traite la nouvelle 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, 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) 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: if said_no:
return Resolution("D'accord, j'annule.", None) return Resolution("D'accord, j'annule.", None)
return Resolution(self._announce(pending.command), pending.command) 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: def _request(self, command: Command) -> str:
"""« ouvrir la porte du salon », « éteindre le PC du bureau ».""" """« ouvrir la porte du salon », « éteindre le PC du bureau »."""
return command.asked if isinstance(command, MachineCommand) else request(command, self._home) 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 rules: dict[str, dict[str, str]] # appareil → action → niveau minimal
confirm: tuple[str, ...] # « door.open » : demande « oui » avant d'exécuter confirm: tuple[str, ...] # « door.open » : demande « oui » avant d'exécuter
confirm_timeout_s: float 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) @dataclass(frozen=True)
@@ -333,6 +334,7 @@ def load_settings(path: Path) -> Settings:
for device, actions in dict(access["rules"]).items()}, for device, actions in dict(access["rules"]).items()},
confirm=tuple(str(item) for item in access.get("confirm") or ()), confirm=tuple(str(item) for item in access.get("confirm") or ()),
confirm_timeout_s=float(access["confirm_timeout_s"]), confirm_timeout_s=float(access["confirm_timeout_s"]),
confirm_min_score=float(access.get("confirm_min_score", 0.25)),
), ),
machines=_machines(data), machines=_machines(data),
recordings_dir=(base / str(storage["recordings_dir"])).resolve(), 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 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): class Transcriber(Protocol):
@@ -191,7 +192,9 @@ class VoicePipeline:
last = utterances[-1] if utterances else None last = utterances[-1] if utterances else None
speaker = last.speaker if last is not None and last.speaker != UNKNOWN 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 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 answer = reply.text
if reply.command is not None and self._controller is not None: 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. # 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, stt_s=transcript.elapsed_s, tts_s=speech.elapsed_s, ready_s=ready_s,
answer_s=speech.audio_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.""" """Réponse et commande autorisée ; True si une commande a été refusée ou mise en attente."""
if not text: if not text:
return Reply(NOTHING_HEARD), False return Reply(NOTHING_HEARD), False
if self._policy is not None: if self._policy is not None:
# Une confirmation attendue passe avant tout : « oui » n'est pas une requête. # 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: if resolution is not None:
return Reply(resolution.answer, resolution.command), resolution.command is None return Reply(resolution.answer, resolution.command), resolution.command is None
reply = self._assistant.respond(text, speaker, board) reply = self._assistant.respond(text, speaker, board)