[REX] Moteur de décision LLM local déclenché via `conversation.process` (hors Assist)

Bonjour,

Je partage ici un retour d’expérience technique autour d’une architecture Home Assistant un peu atypique, volontairement hors pipeline Assist / Wyoming.
Ce n’est pas une recommandation, mais un REX destiné à discussion.
Comme c’etait un peu délicat d’en faire un article je le poste ici.

Objectif

Tester une approche où :

  • Home Assistant reste seul maître des actions
  • un LLM local est utilisé uniquement comme moteur de décision
  • aucune action n’est déclenchée directement par le LLM
  • aucune intégration Assist / STT / TTS n’est utilisée

:backhand_index_pointing_right: Le LLM ne parle pas, n’écoute pas directement, n’agit pas.
:backhand_index_pointing_right: Il renvoie uniquement un JSON contractuel.


Architecture (vue d’ensemble)

Principe général :

  1. Une phrase est injectée dans Home Assistant via l’événement conversation.process
    (tests faits sans micro, via Outils développeur → Événements)
  2. Une automation filtre les phrases commençant par un mot-clé (Nestor)
  3. La phrase nettoyée est envoyée via rest_command à un service Python local
  4. Ce service appelle un LLM local (Ollama)
  5. Le LLM renvoie strictement un JSON normalisé
  6. Home Assistant valide, mappe… ou ignore

Schéma simplifié (joint) :
(schéma volontairement hors pipeline Assist)


le REX se fait uniquement sur la parie encadré en gris, je ferais la partie « voix » plus tard, la ce qui m’intéresse c’est mettre en place un moteur de décision qui comprends un langage « humain » que ce soit le résultat d’un script, une saisie de texte, un speech to text …qui déclenche quelque chose de cadré (ou un refus) que je peux traiter via HA…


Rôle exact du LLM

Le LLM :

  • reçoit une phrase + un contexte synthétique HA
  • propose une intention structurée
  • ne connaît :
    • ni les entités
    • ni les area_id
    • ni les services HA

Exemple de sortie attendue :

{
  "intent": "turn_on_lights",
  "confidence": 0.82,
  "actions": [
    {
      "action": "turn_on_lights",
      "parameters": { "area": "kitchen" }
    }
  ],
  "memory": []
}

Si le JSON est invalide, incomplet ou ambigu → aucune action.


Côté Home Assistant (très résumé)

  • Trigger : conversation.process
  • Filtrage : regex sur le mot-clé
  • Appel : rest_command vers API locale
  • Décision finale :
    • whitelist d’intentions
    • seuil de confiance
    • mapping local zones → entités
  • Action : script HA classique (ou rien)
    je mets l’automation, les rest command et le script a la fin du post pour les plus curieux

Tout le contrôle reste côté HA.


Implémentation technique (factuelle)

  • LLM runtime : Ollama
  • Modèle : instruct généraliste (pas de fine-tuning) petite taille (Llama 3:7b)
  • Service décision : Python + FastAPI + uvicorn
  • Machine : PC dédié (Windows + WSL), GPU RTX 3090
  • Docker : non
  • Assist / Wyoming / STT / TTS : non utilisés
  • Tests : uniquement via conversation.process

Temps de mise au point (ordre de grandeur) : ~1 journée, principalement pour :

  • fiabiliser le JSON
  • cadrer le prompt
  • gérer les timeouts et échecs propres

Pourquoi cette approche (très brièvement)

  • éviter qu’un LLM déclenche directement des actions
  • garder une traçabilité totale
  • pouvoir désactiver le système en 1 ligne
  • accepter l’échec comme comportement normal

Ce n’est pas une alternative à Assist.
C’est une expérimentation autour de la séparation stricte décision / action.


Limites / avertissements

  • approche hors des sentiers battus HA
  • non adaptée aux débutants (codage python, connaissance réseau, connaissance fonctionnement HA (entrées, gestion des variables…)
  • nécessite discipline et garde-fous
  • en aucun cas un “assistant vocal clé en main”

Questions ouvertes

  • Voyez-vous des risques évidents que je n’aurais pas identifiés ?
  • Des améliorations possibles côté HA (validation, mapping, guardrails) ? (notament pour gerer un mapping d’entité dans une zone et les donner séparément dans le JSON, (concretement une façon de faire pour que HA sache quoi faire (si le jason renvoit { area =« kitchen » , action = " turn_on_lights"} ou { area =« bedroom » , action = " turn_on_lights"} il gere switch.lumiere_cuisine ou switch.lumiere_chambre, genre table de correspondance)
  • Intérêt ou non de comparer cette approche avec l’intégration Ollama HA ?

Merci d’avance pour vos retours.
Golthar

###Annexes

automation nestor_ecoute

alias: Nestor – écoute vocale
description: Déclenchement principal via conversation.process
mode: single

trigger:
  - platform: event
    event_type: conversation.process

condition:
  - condition: template
    value_template: >
      {{ trigger.event.data.text | lower | regex_match('^nestor\\b') }}

action:
  - variables:
      user_input: >
        {{ trigger.event.data.text
           | regex_replace('(?i)^nestor\\s*', '')
           | trim }}

  - service: script.nestor_router_decision_area_mapping
    data:
      user_input: "{{ user_input }}"

script as service pour traiter la réponse

alias: Nestor – router décision (mapping area)
mode: single

variables:
  area_map:
    kitchen: switch.interrupteur_cuisine
    living_room: switch.interrupteur_salon
    parent_s_bedroom: switch.interrupteur_chambre_parents
    pierre_s_bedroom: switch.interrupteur_chambre_pierre

sequence:
  - action: rest_command.nestor_decide
    data:
      user_input: "{{ user_input }}"
    response_variable: nestor_decision

  - variables:
      has_action: >
        {{ nestor_decision is defined
           and nestor_decision.content is defined
           and nestor_decision.content.actions is defined
           and nestor_decision.content.actions | length > 0 }}

      action_name: >
        {{ nestor_decision.content.actions[0].action
           if has_action else '' }}

      area: >
        {{ nestor_decision.content.actions[0].parameters.area
           if has_action
              and 'parameters' in nestor_decision.content.actions[0]
              and 'area' in nestor_decision.content.actions[0].parameters
           else '' }}

      target_entity: >
        {{ area_map.get(area) if area in area_map else none }}

  - choose:
      - conditions:
          - condition: template
            value_template: >
              {{ has_action
                 and action_name == 'turn_on_lights'
                 and target_entity is not none }}
        sequence:
          - service: switch.turn_on
            target:
              entity_id: "{{ target_entity }}"

rest_command dans configuration.yaml

rest_command:
  nestor_decide:
    url: "http://[IP_LOCALE]:8001/decide"
        method: POST
        timeout: 60
        headers:
          Content-Type: application/json
        payload: >-
          {
            "meta": {
              "source": "home_assistant",
              "version": "nestor-maison-v0",
              "timestamp": "{{ now().isoformat() }}"
            },
            "user_input": "{{ user_input }}",
            "context": {
              "presence": {
                "home": {{ is_state('person.jacques', 'home') | lower }},
                "persons": ["jacques"]
              },
              "sleep": {
                "awake": true
              },
              "time": {
                "local": "{{ now().strftime('%H:%M') }}",
                "period": "evening"
              },
              "home_state": {
                "lights_on": 0,
                "covers_open": 0,
                "robot_allowed": true
              }
            }
          }

si le sujet intéresse je mettrais l’archi python sur github (mais c’est tres basique…)
le test :
1 on déclenche l’évènement


2 une automatisation écoute et envoie via restcommand a l’API python

3 le LLM sur une autre machine locale qui répond a l’automatisation en utilisant son json

4 le json est interprété par l’automatisation en effectuant l’action : que la lumière soit !

2 « J'aime »