295 lines
15 KiB
Python
295 lines
15 KiB
Python
"""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(' ', ' ').replace(' ', ' ')
|
||
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)
|
||
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')
|
||
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)
|