Files
Alfredv2/alfred/app/services/dona_dynamic.py
T
2026-11-02 08:50:05 +01:00

324 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dona V8: trusted data-only dashboard protocol; no arbitrary HTML/JS/code execution.
LLM can suggest *data* and layout from an explicit user request; server validates shape.
Client renders with DOM textContent only. Source tools stay in existing Hermes/MCP.
"""
from __future__ import annotations
import json
import re
import math
import unicodedata
from datetime import datetime, timezone
from urllib.parse import urlsplit
from flask import Blueprint, jsonify, request
MAX_PROMPT = 500
MAX_RESPONSE = 120000
CARD_TYPES = frozenset({'metric','text','list','table','bars','light'})
def is_dynamic_request(text):
q = str(text or '').casefold()
return bool(re.search(r'(tableau de bord|tableau dynamique|graphique|visualise|visualiser|vue synth[eé]tique|affiche.{0,30}sous forme de (tableau|cartes|graphique)|cr[eé]e.{0,30}(un [eé]cran|une interface)|(?:montre|affiche|pr[eé]sente)(?:[ -]moi)? .{0,65}(?:[eé]tat|statistiques|performances|proxmox|wazuh|serveurs?))', q))
def _short(value, limit=200):
if isinstance(value, (dict, list)):
return ''
return str(value if value is not None else '').replace('\x00','').strip()[:limit]
def _numeric(value):
if isinstance(value, bool): return None
if isinstance(value, str): value = value.strip().replace(',', '.')
try: number = float(value)
except (ValueError, TypeError, OverflowError): return None
return number if math.isfinite(number) and -1e10 < number < 1e10 else None
def _table_row(row, columns):
if isinstance(row, list):
return [_short(v, 140) for v in row[:len(columns)]]
if not isinstance(row, dict): return None
def key(v):
return ''.join(c for c in unicodedata.normalize('NFD', str(v).casefold())
if unicodedata.category(c) != 'Mn').strip()
obj = {key(k):v for k,v in row.items()}
aliases = {'nom':('name','id'), 'etat':('state','status'),
'statut':('status','state'), 'noeud':('node',)}
return [_short(next((obj[k] for k in (key(col),)+aliases.get(key(col),()) if k in obj), ''), 140)
for col in columns]
def _repair_brackets(text):
# Répare uniquement les fermetures ]/} confondues, hors chaînes JSON.
stack, output = [], []
quoted = escaped = False
for char in text:
if quoted:
if escaped: escaped = False
elif char == "\\": escaped = True
elif char == '"': quoted = False
elif char == '"': quoted = True
elif char in '{[': stack.append(char)
elif char in '}]':
if not stack: return text
char = '}' if stack.pop() == '{' else ']'
output.append(char)
return ''.join(output)
def normalize_dashboard(raw, *, prompt='', origin='hermes'):
"""Reject unsupported widgets/fields. No actions, URLs, HTML or code accepted."""
if not isinstance(raw, dict):
raise ValueError('Tableau de bord non structuré.')
cards = raw.get('cards')
if not isinstance(cards,list):
raise ValueError('Liste de cartes absente.')
result = []
for c in cards[:16]:
if not isinstance(c,dict):
continue
kind = _short(c.get('type') or c.get('kind'),20)
if kind not in CARD_TYPES or (kind=='light' and origin != 'lights'):
continue
card = {'type':kind,'title':_short(c.get('title') or c.get('label') or '',100)}
if kind == 'light':
card.update(entity_id=_short(c.get('entity_id'),120), state=_short(c.get('state'),24))
if not re.fullmatch(r'light\.[a-z0-9_]+',card['entity_id']):continue
elif kind == 'metric':
card.update(value=_short(c.get('value'),80),unit=_short(c.get('unit'),24),
detail=_short(c.get('detail'),150),status=_short(c.get('status'),20))
elif kind == 'text':
card['text'] = _short(c.get('text'),2200)
elif kind == 'list':
card['items'] = [_short(x,300) for x in (c.get('items') or [])[:32] if not isinstance(x,(dict,list))] if isinstance(c.get('items'),list) else []
elif kind == 'table':
cols = c.get('columns') or []
card['columns'] = [_short(x,50) for x in cols[:6]] if isinstance(cols,list) else []
rows = c.get('rows') or []
card['rows'] = [v for v in (_table_row(r, card['columns']) for r in rows[:35]) if v is not None] if isinstance(rows,list) else []
if not card['columns']: continue
elif kind == 'bars':
items=c.get('items') or []
card['items'] = [{'label':_short(x.get('label'),80),'value':_numeric(x.get('value')),'unit':_short(x.get('unit'),12)} for x in items[:22] if isinstance(x,dict) and _numeric(x.get('value')) is not None] if isinstance(items,list) else []
result.append(card)
if not result:
raise ValueError('Aucune carte exploitable.')
return {
'schema':'dona.dashboard.v1', 'title':_short(raw.get('title') or 'Tableau dynamique',110),
'subtitle':_short(raw.get('subtitle') or '',240), 'prompt':_short(prompt,MAX_PROMPT),
'origin':_short(origin,24), 'generated_at':datetime.now(timezone.utc).isoformat(),
'cards':result,
}
def extract_dashboard(text):
"""Take JSON anywhere in a model response, including fenced JSON."""
if not isinstance(text,str) or len(text)>MAX_RESPONSE:
raise ValueError('Réponse trop volumineuse ou invalide.')
text = text.replace('&#x20;', ' ').replace('&nbsp;', ' ')
decoder=json.JSONDecoder()
for i,char in enumerate(text):
if char != '{':continue
try:
obj,_=decoder.raw_decode(text,i)
except ValueError:
continue
if isinstance(obj,dict) and isinstance(obj.get('cards'),list):
return obj
# Seconde passe : réparation contrôlée des fermetures ] et }.
last = text.rfind('}')
if last >= 0:
for i, c in enumerate(text):
if c != '{' or '"cards"' not in text[i:min(last+1, i+3000)]: continue
candidate = _repair_brackets(text[i:last+1])
try: obj, _ = decoder.raw_decode(candidate)
except ValueError: continue
if isinstance(obj,dict) and isinstance(obj.get('cards'),list): return obj
raise ValueError('Aucune structure JSON exploitable dans la réponse Hermes.')
def _fallback(answer,prompt):
"""Show the actual Hermes answer rather than inventing measurements."""
safe = _short(answer,6500)
if not safe:
safe='Hermes n’a pas fourni de données pour construire ce tableau.'
return normalize_dashboard({
'title':'Résultat · '+_short(prompt,75),
'subtitle':'Affichage textuel : Hermes n’a pas renvoyé de cartes structurées.',
'cards':[{'type':'text','title':'Réponse Hermes','text':safe[:2200]}] +
([{'type':'text','title':'Suite de la réponse','text':safe[2200:4400]}] if len(safe)>2200 else []) +
([{'type':'text','title':'Fin de la réponse','text':safe[4400:6500]}] if len(safe)>4400 else []),
},prompt=prompt,origin='fallback')
def _live_spotify(prompt):
from services import dona_spotify
raw=dona_spotify._call('GET','/me/player')
devices=dona_spotify._call('GET','/me/player/devices').get('devices') or []
pub=dona_spotify._public_state(raw,[{'name':x.get('name',''), 'is_active':bool(x.get('is_active'))} for x in devices if isinstance(x,dict)])
cards=[{'type':'metric','title':'Lecture','value':'En cours' if pub['is_playing'] else 'À l’arrêt'},
{'type':'text','title':'Morceau','text':(pub['title']+' — '+pub['artist']).strip(' —') or 'Aucun morceau actuellement sélectionné.'},
{'type':'list','title':'Appareils Spotify Connect','items':[(x.get('name') or 'Sans nom') + (' · actif' if x.get('is_active') else '') for x in pub['devices']]}]
return normalize_dashboard({'title':'Spotify Connect','subtitle':'Données directes de Spotify · actualisation sans LLM','cards':cards},prompt=prompt,origin='spotify')
def _live_temperatures(prompt):
from services import ha_views
raw=ha_views.get_temperatures()
cards=[{'type':'metric','title':'Capteurs disponibles','value':str(len(raw))},
{'type':'table','title':'Températures Home Assistant','columns':['Capteur','Température'],
'rows':[[x.get('name','Capteur'),str(x.get('value','—'))+' '+str(x.get('unit',''))] for x in raw[:35] if isinstance(x,dict)]}]
return normalize_dashboard({'title':'Températures de la maison','subtitle':'Données directes de Home Assistant','cards':cards},prompt=prompt,origin='homeassistant')
def _live_lights(prompt):
from services import ha_views
states=ha_views.states() or []
cards=[{'type':'metric','title':'Lumières allumées',
'value':str(sum(1 for x in states if isinstance(x,dict) and x.get('entity_id','').startswith('light.') and x.get('state')=='on'))}]
for x in states:
if not isinstance(x,dict):continue
eid=x.get('entity_id','')
if not isinstance(eid,str) or not re.fullmatch(r'light\.[a-z0-9_]+',eid):continue
attrs=x.get('attributes') or {}
cards.append({'type':'light','title':attrs.get('friendly_name') or eid,'entity_id':eid,'state':x.get('state','unknown')})
if len(cards)>=16:break
return normalize_dashboard({'title':'Éclairage de la maison',
'subtitle':'Données Home Assistant en direct · les interrupteurs agissent sur les lumières',
'cards':cards},prompt=prompt,origin='lights')
def choose_live_source(prompt):
q = str(prompt).casefold()
if re.search(r'\bspotify\b|\blecteur musical\b',q) and not re.search(r'\b(et|avec|ainsi que)\b.{0,25}\b(proxmox|wazuh|home assistant|temp[eé]rature)',q):
return 'spotify'
if re.search(r'\blumi[eè]res?\b|\b[eé]clairage\b|\blampes?\b',q) and not re.search(r'\b(proxmox|wazuh|spotify|serveur)\b',q):
return 'lights'
if re.search(r'\btemp[eé]ratures?\b',q) and not re.search(r'\b(proxmox|wazuh|spotify|serveur)\b',q):
return 'temperatures'
return None
def build_dashboard(prompt, ask_hermes):
"""Calls existing MCP through Hermes only when no suitable direct read API exists."""
if not isinstance(prompt,str) or not 2 <= len(prompt.strip()) <= MAX_PROMPT:
raise ValueError('Demande de 2 à 500 caractères requise.')
source=choose_live_source(prompt)
if source=='spotify':return _live_spotify(prompt)
if source=='temperatures':return _live_temperatures(prompt)
if source=='lights':return _live_lights(prompt)
# DONA_DYNAMIC_PROXMOX_V3 — try data-only read-only mode for standard boards.
from services.dona_proxmox_direct import (
is_standard_proxmox, build_standard_dashboard, ProxmoxDirectUnavailable
)
if is_standard_proxmox(prompt):
import logging as _dona_logging
import time as _dona_time
_direct_start = _dona_time.monotonic()
try:
_direct_board = normalize_dashboard(build_standard_dashboard(prompt), prompt=prompt)
_direct_board['origin'] = 'proxmox'
_dona_logging.getLogger('dona.proxmox').warning(
'[DONA-DIRECT] source=proxmox outcome=ok elapsed_ms=%.1f',
(_dona_time.monotonic()-_direct_start)*1000
)
return _direct_board
except ProxmoxDirectUnavailable as _direct_exc:
_dona_reason = str(_direct_exc)
except Exception:
_dona_reason = 'erreur_interne'
_dona_logging.getLogger('dona.proxmox').warning(
'[DONA-DIRECT] source=proxmox outcome=fallback reason=%s elapsed_ms=%.1f',
_dona_reason, (_dona_time.monotonic()-_direct_start)*1000
)
# Existing Hermes/MCP implementation remains the safe fallback.
instruction=(
'Demande originale : '+prompt+'\n\n'
'Interroge d’abord les outils disponibles (MCP) pour obtenir les données réelles. '
'N’invente jamais de serveurs, valeurs, statuts, nombres ou périodes. '
'Si les outils échouent, affiche des cartes texte indiquant les données indisponibles. '
'Réponds UNIQUEMENT par un objet JSON valide, sans balises Markdown ni commentaire. '
'Schéma: {"title":"...","subtitle":"...","cards":[ '
'{"type":"metric","title":"Nœuds actifs","value":"5/5","detail":"..."}, '
'{"type":"list","title":"Nœuds","items":["nom — état"]}, '
'{"type":"table","title":"Machines","columns":["Nom","État"],"rows":[["...","..."]]}, '
'{"type":"bars","title":"CPU (%)","items":[{"label":"nœud","value":42}]}, '
'{"type":"text","title":"Remarques","text":"..."} ]}. '
'N’utilise que les types metric, list, table, bars ou text, uniquement les cartes pertinentes. '
'Les exemples du schéma ci-dessus sont illustratifs, pas des valeurs à reproduire. '
'N’inclus aucun HTML, JavaScript, lien ou action exécutable.'
)
answer=ask_hermes(instruction)
if isinstance(answer,dict):answer=str(answer.get('text') or answer.get('speech') or '')
try:
return normalize_dashboard(extract_dashboard(answer),prompt=prompt)
except ValueError:
return _fallback(answer,prompt)
def _same_origin():
if request.headers.get('X-Alfred-Request')!='1' or not request.is_json:return False
origin=request.headers.get('Origin')
if origin:
p=urlsplit(origin)
return p.scheme in ('http','https') and p.netloc==request.host
return request.headers.get('Sec-Fetch-Site','same-origin') in ('same-origin','none')
def register_dynamic(app, submit):
bp=Blueprint('dona_dynamic_v8',__name__)
@bp.get('/api/dona/dynamic/live')
def live():
source=request.args.get('source','')
try:
if source=='spotify':board=_live_spotify('Tableau Spotify')
elif source=='homeassistant':board=_live_temperatures('Tableau températures')
elif source=='lights':board=_live_lights('Tableau lumières')
elif source=='proxmox':
from services.dona_proxmox_direct import build_standard_dashboard
board=normalize_dashboard(build_standard_dashboard('Tableau de bord Proxmox'), prompt='Tableau de bord Proxmox')
board['origin']='proxmox'
else:return jsonify(ok=False,error='Source invalide.'),400
response=jsonify(ok=True,dashboard=board)
response.headers['Cache-Control']='no-store, private'
return response
except Exception:
app.logger.exception('Dona dynamic live')
return jsonify(ok=False,error='Données momentanément indisponibles.'),503
@bp.post('/api/dona/dynamic/lights/toggle')
def lights_toggle():
if not _same_origin():return jsonify(ok=False,error='Requête non autorisée.'),403
data=request.get_json(silent=True)
eid=data.get('entity_id') if isinstance(data,dict) else None
if not isinstance(eid,str) or not re.fullmatch(r'light\.[a-z0-9_]{1,100}',eid):
return jsonify(ok=False,error='Entité invalide.'),400
try:
from services import ha_views
# Refuse une entité qui n'existe pas dans HA.
if not any(x.get('entity_id')==eid and x.get('state') in ('on','off') for x in (ha_views.states() or []) if isinstance(x,dict)):
return jsonify(ok=False,error='Lumière absente de Home Assistant.'),404
ha_views._request('POST','/api/services/light/toggle',payload={'entity_id':eid})
return jsonify(ok=True)
except Exception:
app.logger.exception('Dona dynamic light action')
return jsonify(ok=False,error='Home Assistant n’a pas exécuté la commande.'),503
@bp.post('/api/dona/dynamic/create')
def create():
if not _same_origin():return jsonify(ok=False,error='Origine non autorisée.'),403
if request.content_length and request.content_length>3000:return jsonify(ok=False,error='Requête trop volumineuse.'),413
body=request.get_json(silent=True)
prompt=body.get('prompt') if isinstance(body,dict) else None
if not isinstance(prompt,str) or not 2<=len(prompt.strip())<=MAX_PROMPT:
return jsonify(ok=False,error='Demande de 2 à 500 caractères requise.'),400
try:queued=submit(prompt.strip())
except Exception:
app.logger.exception('Dona dynamic submit')
return jsonify(ok=False,error='Impossible de créer la vue.'),503
if not queued:return jsonify(ok=False,error='Une autre demande est en cours.'),409
return jsonify(ok=True,queued=True),202
app.register_blueprint(bp)