mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
d20976fea2
É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>
130 lines
5.5 KiB
Python
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()}"
|