Files
voice-assistant/server/voice_server/assistant.py
T
Denis Defolie d20976fea2 Assistant vocal local ESP32 : étapes 1 à 15, et 15b écrite
État du projet au premier commit :
- firmware ESP32 : micro I2S, lecture anti-gigue, MQTT (état retenu, testament),
  mains libres, domotique de démonstration, chien de garde, présence du serveur ;
- serveur Python : sessions, VAD, faster-whisper, pyannote, identification,
  droits par profil, domotique, mot de réveil, Piper ;
- broker Mosquitto : configuration, comptes et ACL (non activés en développement) ;
- étape 15b écrite, en attente d'essai : agent MQTT pour machines Linux.

Étapes 1 à 15 validées. 203 tests, pyright sans erreur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:41:11 +02:00

130 lines
5.5 KiB
Python

"""Choix de la réponse à une phrase transcrite (étapes 6, 10, 11 et 15b).
D'abord les machines (étape 15b) : une phrase qui nomme une machine (« le PC du
bureau ») la concerne, même si elle contient « éteins ». Puis la domotique
(étape 11) : une action, un appareil, une pièce donnent une commande. Sinon,
quelques règles de conversation dont on connaît d'avance la réponse (heure,
date, salutations) ; à défaut, la phrase est répétée.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from voice_server.home_control import decide, parse_intent
from voice_server.machine_control import Command, MachineDecision
from voice_server.settings import HomeSettings
from voice_server.text_normalize import normalize
DAYS = ("lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche")
MONTHS = ("janvier", "février", "mars", "avril", "mai", "juin", "juillet",
"août", "septembre", "octobre", "novembre", "décembre")
NOTHING_HEARD = "Je n'ai rien entendu."
@dataclass(frozen=True)
class Reply:
"""Ce que l'assistant dit, et la commande domotique à exécuter s'il y en a une."""
text: str
command: Command | None = None
class MachineInterpreter(Protocol):
"""Ce que l'assistant attend des machines (MachineController)."""
def interpret(self, text: str) -> MachineDecision | None: ...
def say_time(now: datetime) -> str:
"""L'heure écrite comme on la dit : Piper lit mal « 14:05 »."""
if now.hour == 0:
hours = "minuit"
elif now.hour == 12:
hours = "midi"
elif now.hour == 1:
hours = "une heure" # écrit « 1 heure », Piper dit « un heure »
else:
hours = f"{now.hour} heures"
return f"Il est {hours} {now.minute}." if now.minute else f"Il est {hours}."
def say_date(now: datetime) -> str:
"""La date en toutes lettres, « premier » pour le 1er du mois."""
day = "premier" if now.day == 1 else str(now.day)
return f"Nous sommes le {DAYS[now.weekday()]} {day} {MONTHS[now.month - 1]} {now.year}."
class Assistant:
"""Associe une réponse à une phrase, par mots-clés.
Les règles sont essayées dans l'ordre : la plus précise d'abord, pour que
« bonjour, quelle heure est-il ? » donne l'heure et non une salutation.
"""
def __init__(self, clock: Callable[[], datetime] = datetime.now, home: HomeSettings | None = None,
machines: MachineInterpreter | None = None) -> None:
"""
Args:
clock: source de l'heure, injectable pour les tests.
home: pièces et cartes (étape 11) ; None : pas de domotique.
machines: machines du réseau (étape 15b) ; None : pas de machines.
"""
self._clock = clock
self._home = home
self._machines = machines
# (mots-clés normalisés, réponse) : une seule occurrence suffit.
self._rules: list[tuple[tuple[str, ...], Callable[[str | None], str]]] = [
# Whisper écrit parfois « Quel heure » : on accepte les deux accords.
(("quelle heure", "quel heure", "l heure", "heure est il"), lambda who: say_time(self._clock())),
(("quel jour", "la date", "quelle date"), lambda who: say_date(self._clock())),
(("comment tu t appelles", "qui es tu", "ton nom"),
lambda who: "Je suis votre assistant vocal, et je fonctionne sans Internet."),
# Étape 10 : l'assistant sait qui parle, il l'appelle par son nom.
(("bonjour", "bonsoir", "salut"),
lambda who: f"Bonjour {who} ! Que puis-je faire pour vous ?" if who
else "Bonjour ! Que puis-je faire pour vous ?"),
(("merci",), lambda who: f"Avec plaisir, {who}." if who else "Avec plaisir."),
# Étape 11 : sans Internet, pas de météo ; mieux vaut le dire que répéter la question.
(("meteo", "quel temps"), lambda who: "Je fonctionne sans Internet : je n'ai pas accès à la météo."),
]
def respond(self, text: str, speaker: str | None = None, board: str = "") -> Reply:
"""Machines, puis domotique, puis conversation.
Args:
text: phrase transcrite.
speaker: nom de la personne reconnue, None si inconnue.
board: carte qui a entendu la phrase : sa pièce est la pièce par défaut.
"""
if self._machines is not None:
decision = self._machines.interpret(text)
if decision is not None:
return Reply(decision.answer, decision.command)
if self._home is not None:
intent = parse_intent(text, list(self._home.rooms))
if intent is not None:
decision = decide(intent, self._home, board)
return Reply(decision.answer, decision.command)
return Reply(self.reply(text, speaker))
def reply(self, text: str, speaker: str | None = None) -> str:
"""Rend la réponse à dire ; à défaut de règle, répète ce qui a été compris.
Args:
text: phrase transcrite.
speaker: nom de la personne reconnue, None si inconnue.
"""
words = normalize(text)
if not words:
return NOTHING_HEARD
padded = f" {words} " # espaces : « salut » ne doit pas reconnaître « salutations »
for keywords, answer in self._rules:
if any(f" {keyword} " in padded for keyword in keywords):
return answer(speaker)
return f"Vous avez dit : {text.strip()}"