"""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 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, (int,float)) and -1e10 < value < 1e10: return float(value) return None 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'] = [[_short(v,140) for v in row[:len(card['columns'])]] for row in rows[:35] if isinstance(row,list)] 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'))} 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.') 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 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)