mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
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>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""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()}"
|
||||
Reference in New Issue
Block a user