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