This commit is contained in:
pporcheret
2026-10-30 22:10:03 +01:00
parent 7f26f9b015
commit 90ffa635a0
161 changed files with 19042 additions and 111 deletions
+294
View File
@@ -0,0 +1,294 @@
"""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)
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)