138 lines
5.0 KiB
Python
138 lines
5.0 KiB
Python
"""Dona V4: conservative fast routing for dashboard *requests* (voice or text).
|
||
|
||
No direct shell calls, no side effects on Proxmox/HA/Spotify. Reuses Dona V3
|
||
`build_dashboard()` and its existing read-only providers. Complex queries,
|
||
missing UI sockets and any provider error fall through to Hermes unchanged.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import re
|
||
import threading
|
||
import time
|
||
from typing import Callable
|
||
|
||
_LOG = logging.getLogger('dona.router.v4')
|
||
_LOCK = threading.Lock()
|
||
_EMITTER: Callable | None = None
|
||
_FALSE = {'0', 'false', 'off', 'no', 'disabled'}
|
||
|
||
|
||
def configure_emitter(fn):
|
||
"""Called once by app.py after socketio is constructed."""
|
||
global _EMITTER
|
||
if not callable(fn):
|
||
raise TypeError('Un émetteur Socket.IO callable est nécessaire')
|
||
with _LOCK:
|
||
_EMITTER = fn
|
||
|
||
|
||
def enabled():
|
||
return os.environ.get('DONA_FAST_DASHBOARDS_V4', 'on').strip().lower() not in _FALSE
|
||
|
||
|
||
# Only an explicit presentation request. No guess from vague words such as
|
||
# "proxmox" or "température" alone. These aren't commands to control devices.
|
||
_VIEW_RE = re.compile(
|
||
r'\b(tableaux?\s+(?:de\s+)?bord|tableaux?|dashboards?|vue\s+d.ensemble)\b',
|
||
re.I,
|
||
)
|
||
_COMPLEX_RE = re.compile(
|
||
r'\b(?:analy[sz]\w*|pourquoi|compar\w*|recommand\w*|optimis\w*|'
|
||
r'pr[eé]di\w*|histor\w*|tendance\w*|migr\w*|sauvegard\w*|'
|
||
r'backup\w*|restaur\w*|red[eé]marr\w*|arr[eê]t\w*|'
|
||
r'allum\w*|[eé]tein\w*|cr[eé]\w*|supprim\w*|modifi\w*|'
|
||
r'configure\w*|change\w*|envoie\w*|partage\w*|notifie\w*|'
|
||
r'enregistre\w*|planifi\w*|automati\w*|graphique\s+sur\s+\d+|'
|
||
r'sur\s+\d+\s+(?:jours?|heures?|semaines?|mois|ans)|'
|
||
r'\b(?:ceph|stockage|r[eé]seau)\b)\b', re.I,
|
||
)
|
||
|
||
_DOMAINS = {
|
||
'proxmox': re.compile(r'\bproxmox\b', re.I),
|
||
'spotify': re.compile(r'\bspotify\b', re.I),
|
||
'lights': re.compile(r'\b(lumi[eè]res?|[eé]clairages?|lampes?)\b', re.I),
|
||
'temperatures': re.compile(r'\b(temp[eé]ratures?|thermom[eè]tres?)\b', re.I),
|
||
}
|
||
_OTHER = re.compile(
|
||
r'\b(wazuh|comfyui|paperless|gmail|youtube|courriel|email|'
|
||
r'calendrier|agenda|m[eé]t[eé]o|cam[eé]ra|sonnette|visages?|'
|
||
r'reconnaissance\s+faciale|kanban)\b', re.I,
|
||
)
|
||
|
||
|
||
def route(prompt):
|
||
"""Return a source label or None; deliberate false negatives are safe."""
|
||
if not enabled() or not isinstance(prompt, str) or len(prompt.strip()) > 500:
|
||
return None
|
||
q = prompt.casefold().strip()
|
||
if not _VIEW_RE.search(q) or _COMPLEX_RE.search(q) or _OTHER.search(q):
|
||
return None
|
||
found = [key for key, regex in _DOMAINS.items() if regex.search(q)]
|
||
if len(found) != 1:
|
||
return None
|
||
# Proxmox V3 has an additional explicit standard-query classifier.
|
||
if found[0] == 'proxmox':
|
||
from services.dona_proxmox_direct import is_standard_proxmox
|
||
if not is_standard_proxmox(prompt):
|
||
return None
|
||
return found[0]
|
||
|
||
|
||
class _FallbackToHermes(Exception):
|
||
pass
|
||
|
||
|
||
def _no_hermes(_prompt):
|
||
"""Prohibit an accidental second Hermes invocation in direct mode."""
|
||
raise _FallbackToHermes()
|
||
|
||
|
||
def try_route(prompt):
|
||
"""Return spoken/text confirmation when a live board was emitted, else None.
|
||
|
||
Fallback is intentionally the original ask_hermes path. Session state is
|
||
untouched by direct-only read operations.
|
||
"""
|
||
source = route(prompt)
|
||
if source is None:
|
||
return None
|
||
with _LOCK:
|
||
emitter = _EMITTER
|
||
if emitter is None:
|
||
return None
|
||
start = time.monotonic()
|
||
try:
|
||
from services.dona_dynamic import build_dashboard
|
||
board = build_dashboard(prompt, _no_hermes)
|
||
if not isinstance(board, dict) or board.get('schema') != 'dona.dashboard.v1':
|
||
raise _FallbackToHermes()
|
||
if not isinstance(board.get('cards'), list):
|
||
raise _FallbackToHermes()
|
||
actual = str(board.get('origin', '')).lower()
|
||
allowed = {
|
||
'proxmox': {'proxmox'},
|
||
'spotify': {'spotify'},
|
||
'lights': {'lights', 'homeassistant'},
|
||
'temperatures': {'homeassistant'},
|
||
}
|
||
if actual not in allowed[source]:
|
||
raise _FallbackToHermes()
|
||
# Socket.IO callbacks may raise, in which case normal Hermes fallback.
|
||
emitter({'type': 'dona_dynamic', 'dashboard': board,
|
||
'dona_fast_v4': True})
|
||
except Exception as exc:
|
||
# No prompt, payload, tokens or tool data in logs.
|
||
_LOG.warning('[DONA-V4] route=%s outcome=fallback reason=%s elapsed_ms=%.1f',
|
||
source, type(exc).__name__, (time.monotonic()-start)*1000)
|
||
return None
|
||
_LOG.warning('[DONA-V4] route=%s outcome=direct elapsed_ms=%.1f cards=%d',
|
||
source, (time.monotonic()-start)*1000, len(board['cards']))
|
||
return {
|
||
'proxmox': 'J’affiche le tableau de bord Proxmox.',
|
||
'spotify': 'J’affiche le tableau de bord Spotify.',
|
||
'lights': 'J’affiche le tableau de bord des lumières.',
|
||
'temperatures': 'J’affiche le tableau de bord des températures.',
|
||
}[source]
|