debut illustration

This commit is contained in:
pporcheret
2026-11-02 08:50:05 +01:00
parent e2340807aa
commit c2a4f7eb26
44 changed files with 6856 additions and 19 deletions
+29
View File
@@ -209,6 +209,31 @@ def build_dashboard(prompt, ask_hermes):
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. '
@@ -251,6 +276,10 @@ def register_dynamic(app, submit):
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'
+248
View File
@@ -0,0 +1,248 @@
"""Dona CLI streaming and timing bridge (Python standard library only).
Does not access Hermes sessions or modify MCP config. Retains the terminal
JSONL result envelope for the existing _parse_jsonl implementation.
"""
from __future__ import annotations
import json
import logging
import os
import signal
import subprocess
import threading
import time
import uuid
_LOG = logging.getLogger("dona.hermes.performance")
_EMITTER = None
_EMITTER_LOCK = threading.Lock()
_FALSE = {"0", "false", "no", "off", "disabled"}
def configure_emitter(emitter):
"""Register Flask-SocketIO event emitter once, after socketio is created."""
global _EMITTER
with _EMITTER_LOCK:
_EMITTER = emitter
def _enabled(name: str, default: str = "on") -> bool:
return os.environ.get(name, default).strip().lower() not in _FALSE
def _publish(msg):
if not _enabled("DONA_HERMES_STREAM_UI", "on"):
return
with _EMITTER_LOCK:
fn = _EMITTER
if fn is not None:
try:
fn(msg)
except Exception:
# A browser disconnect must never corrupt an Hermes turn/session.
_LOG.debug("Dona UI emitter failed", exc_info=True)
def run_jsonl_command(cmd, prompt, timeout):
"""Compatibility replacement for subprocess.run used only by _call_hermes.
Publishes deltas/tool names while keeping exact final JSONL parsing behavior.
Fallback to the historical subprocess.run with DONA_HERMES_STREAM_CLI=off.
"""
if not _enabled("DONA_HERMES_STREAM_CLI", "on"):
return subprocess.run(
cmd, input=prompt, capture_output=True, text=True,
timeout=timeout, check=False,
)
request_id = uuid.uuid4().hex[:12]
start = time.monotonic()
first_text_ms = None
first_event_ms = None
first_tool_ms = None
last_tool_done_ms = None
active_tools = []
finished_tools = 0
# Report only name/timing/outcome. Never log MCP arguments, outputs or prompts.
tool_count = 0
text_fragments = 0
emitted_characters = 0
pending_text = ""
last_emit = start
final_seen = False
# Keep only records the existing parser consumes, rather than potentially
# huge tool inputs/outputs and all intermediate assistant deltas.
output_records = []
stderr_records = []
deadline_hit = threading.Event()
proc = None
timer = None
_publish({"type": "start", "id": request_id})
def expire():
deadline_hit.set()
if proc is not None and proc.poll() is None:
try:
# subprocess in its own session: children of this CLI turn
# cannot linger after an actual timeout.
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except OSError:
pass
def collect_stderr():
try:
data = proc.stderr.read()
if data:
stderr_records.append(data)
except Exception:
pass
try:
proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, bufsize=1,
start_new_session=True,
)
timer = threading.Timer(float(timeout), expire)
timer.daemon = True
timer.start()
stderr_thread = threading.Thread(target=collect_stderr, daemon=True)
stderr_thread.start()
try:
proc.stdin.write(prompt)
except BrokenPipeError:
pass
finally:
try:
proc.stdin.close()
except OSError:
pass
for line in proc.stdout:
try:
event = json.loads(line)
except (ValueError, TypeError):
# Preserve unrecognized lines to aid existing error diagnostics.
continue
if not isinstance(event, dict):
continue
kind = event.get("type")
now = time.monotonic()
if first_event_ms is None:
first_event_ms = round((now - start) * 1000, 1)
if kind == "system" and event.get("subtype") == "init":
output_records.append(line)
elif kind == "text":
delta = event.get("text")
if isinstance(delta, str) and delta:
text_fragments += 1
if first_text_ms is None:
first_text_ms = round((now - start) * 1000, 1)
_publish({"type": "first_text", "id": request_id,
"first_text_ms": first_text_ms})
# Emit batched, rather than one websocket event/token.
pending_text += delta
if (now - last_emit) >= .12 or len(pending_text) >= 240:
_publish({"type": "delta", "id": request_id,
"text": pending_text})
emitted_characters += len(pending_text)
pending_text = ""
last_emit = now
elif kind == "tool_use":
tool_count += 1
if first_tool_ms is None:
first_tool_ms = round((now - start) * 1000, 1)
name = str(event.get("name") or "outil")[:90]
# Events may use either a tool_call_id, id or no ID at all.
tool_id = event.get("tool_call_id") or event.get("id")
active_tools.append({"name": name, "id": tool_id,
"start": now, "sequence": tool_count})
_LOG.warning("[DONA-TOOL] id=%s index=%s name=%s phase=start at_ms=%.1f",
request_id, tool_count, name, (now-start)*1000)
# Do not send tool arguments, user tokens, or raw tool outputs.
_publish({"type": "tool", "id": request_id, "name": name})
elif kind == "tool_result":
name = str(event.get("name") or "outil")[:90]
tool_id = event.get("tool_call_id") or event.get("tool_use_id") or event.get("id")
matched = None
# Prefer a matching tool ID; fallback to matching name, then oldest.
if tool_id is not None:
matched = next((i for i, t in enumerate(active_tools)
if t["id"] == tool_id), None)
if matched is None:
matched = next((i for i, t in enumerate(active_tools)
if t["name"] == name), None)
if matched is None and active_tools:
matched = 0
record = active_tools.pop(matched) if matched is not None else None
if name == "outil" and record is not None:
name = record["name"]
measured_ms = round((now-record["start"])*1000, 1) if record else None
reported_ms = event.get("duration_ms")
if isinstance(reported_ms, bool) or not isinstance(reported_ms, (float, int)):
reported_ms = None
failed = bool(event.get("is_error"))
last_tool_done_ms = round((now-start)*1000, 1)
finished_tools += 1
_LOG.warning("[DONA-TOOL] id=%s index=%s name=%s phase=end at_ms=%.1f elapsed_ms=%s server_ms=%s error=%s",
request_id, record["sequence"] if record else "?", name,
last_tool_done_ms, measured_ms, reported_ms, failed)
_publish({"type": "tool_done", "id": request_id,
"name": name,
"duration_ms": reported_ms if reported_ms is not None else measured_ms,
"error": failed})
elif kind == "result":
final_seen = True
output_records.append(line)
rc = proc.wait()
stderr_thread.join(timeout=1.0)
total_ms = round((time.monotonic() - start) * 1000, 1)
if deadline_hit.is_set():
_publish({"type": "error", "id": request_id, "reason": "timeout"})
raise subprocess.TimeoutExpired(cmd, timeout, output="".join(output_records),
stderr="".join(stderr_records))
if pending_text:
_publish({"type": "delta", "id": request_id, "text": pending_text})
emitted_characters += len(pending_text)
_publish({"type": "end", "id": request_id,
"total_ms": total_ms, "first_text_ms": first_text_ms,
"ok": rc == 0 and final_seen})
_LOG.warning(
"[DONA-PERF] hermes_cli id=%s total_ms=%s first_event_ms=%s "
"first_tool_ms=%s last_tool_done_ms=%s first_text_ms=%s "
"tools=%s tool_results=%s unfinished_tools=%s "
"fragments=%s ui_chars=%s exit=%s final=%s",
request_id, total_ms, first_event_ms, first_tool_ms,
last_tool_done_ms, first_text_ms, tool_count, finished_tools,
len(active_tools), text_fragments, emitted_characters, rc, final_seen,
)
# Keep final result + init events exactly as _parse_jsonl expects.
return subprocess.CompletedProcess(
cmd, rc, "".join(output_records), "".join(stderr_records)
)
except Exception:
if not deadline_hit.is_set():
_publish({"type": "error", "id": request_id, "reason": "process_error"})
raise
finally:
if timer:
timer.cancel()
if proc is not None:
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except OSError:
pass
for pipe in (proc.stdin, proc.stdout, proc.stderr):
try:
pipe.close()
except (OSError, ValueError):
pass
+234
View File
@@ -0,0 +1,234 @@
"""Dona V3 — read-only, optional Proxmox dashboard renderer.
No model, no MCP mutation, no writes to Proxmox. A failure must be handled by
build_dashboard's existing Hermes fallback. Server-side credentials only.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import json
import os
from pathlib import Path
import re
import ssl
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit, urlunsplit
from urllib.request import Request, urlopen
OFF = {'0', 'false', 'off', 'no', 'disabled'}
ENV_PATH = Path('/opt/hermes/MCP_proxmox/.env')
MAX_DATA = 3 * 1024 * 1024
class ProxmoxDirectUnavailable(Exception):
"""Only short, non-sensitive public reason codes should be logged."""
def enabled():
return os.environ.get('DONA_PROXMOX_DIRECT', 'on').strip().lower() not in OFF
def is_standard_proxmox(prompt):
"""Fail conservative. Let Hermes handle analyses, commands and mixed topics."""
if not enabled() or not isinstance(prompt, str):
return False
p = prompt.casefold().strip()
if not (re.search(r'\bproxmox\b', p) and re.search(r'\b(tableau|dashboard|vue d.ensemble|synth[eè]se)\b', p)):
return False
if re.search(r'\b(wazuh|spotify|home\s*assistant|comfyui|paperless|gmail|youtube|courriel)\b', p):
return False
if re.search(r'\b(analy[sz]\w*|pourquoi|compar\w*|recommand\w*|optimis\w*|pr[eé]di\w*|histor\w*|tendance\w*|\bavant\b|\bapr[eè]s\b|migr\w*|sauvegard\w*|backup\w*|restaur\w*|red[eé]marr\w*|arr[eê]t\w*|allum\w*|cr[eé]\w*|supprim\w*|modifi\w*|ceph|stockage|r[eé]seau|sur\s+\d+\s+(jours|heures)|d[eé]taill\w*)\b', p):
return False
return True
def _read_env_file(path):
env = {}
try:
if not path.is_file() or path.stat().st_size > 65536:
return env
for line in path.read_text(encoding='utf-8').splitlines():
line = line.strip()
if line.startswith('export '):
line = line[7:].strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, val = line.split('=', 1)
key = key.strip()
if not re.fullmatch(r'[A-Z][A-Z0-9_]*', key):
continue
val = val.strip()
if val and val[0] in ("'", '"') and val[-1:] == val[0]:
val = val[1:-1]
else:
val = val.split(' #', 1)[0].strip()
env[key] = val
except (OSError, UnicodeError):
return {}
return env
def _config():
env = {}
path = os.environ.get('DONA_PROXMOX_ENV_FILE')
if path:
env.update(_read_env_file(Path(path).expanduser()))
else:
env.update(_read_env_file(ENV_PATH))
env.update(os.environ) # Live service settings override .env values.
def get(*names):
return next((str(env[n]).strip() for n in names if env.get(n)), '')
url = get('DONA_PROXMOX_URL', 'DONA_PROXMOX_API_URL', 'PROXMOX_URL',
'PROXMOX_API_URL', 'PROXMOX_HOST', 'PVE_HOST', 'PVE_URL', 'PVE_API_URL')
tid = get('DONA_PROXMOX_TOKEN_ID', 'PROXMOX_TOKEN_ID', 'PROXMOX_API_TOKEN_ID',
'PVE_TOKEN_ID', 'PVE_API_TOKEN_ID')
secret = get('DONA_PROXMOX_TOKEN_SECRET', 'PROXMOX_TOKEN_SECRET',
'PROXMOX_API_TOKEN_SECRET', 'PROXMOX_TOKEN_VALUE',
'PROXMOX_API_TOKEN', 'PVE_TOKEN_SECRET', 'PVE_API_TOKEN_SECRET')
if not tid:
user = get('PROXMOX_USER', 'PROXMOX_USERNAME', 'PVE_USER')
name = get('PROXMOX_TOKEN_NAME', 'PROXMOX_TOKEN', 'PVE_TOKEN_NAME')
if user and name:
tid = user + '!' + name
if secret.startswith('PVEAPIToken='):
secret = secret.split('=', 1)[1]
# Combined 'user@realm!name=secret' format is accepted if provided as one value.
if not tid and '=' in secret and '!' in secret:
tid, secret = secret.split('=', 1)
if not url or not tid or not secret:
raise ProxmoxDirectUnavailable('configuration_absente')
if not re.fullmatch(r'[^\s!=]+@[^\s!=]+![^\s!=]+', tid):
raise ProxmoxDirectUnavailable('token_id_invalide')
if any(c in secret for c in '\r\n'):
raise ProxmoxDirectUnavailable('token_invalide')
if '://' not in url:
url = 'https://' + url
parsed = urlsplit(url)
if parsed.scheme not in ('https', 'http') or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ProxmoxDirectUnavailable('url_invalide')
if parsed.scheme == 'http' and get('DONA_PROXMOX_ALLOW_HTTP') not in ('1', 'true', 'yes'):
raise ProxmoxDirectUnavailable('http_non_autorise')
try:
port = parsed.port
except ValueError as exc:
raise ProxmoxDirectUnavailable('port_invalide') from exc
netloc = parsed.netloc if port is not None else parsed.netloc + ':8006'
path = parsed.path.rstrip('/')
if path not in ('', '/api2/json'):
raise ProxmoxDirectUnavailable('chemin_url_invalide')
base = urlunsplit((parsed.scheme, netloc, '/api2/json', '', ''))
verify = get('DONA_PROXMOX_VERIFY_SSL', 'PROXMOX_VERIFY_SSL', 'PVE_VERIFY_SSL').lower() not in OFF
ca_file = get('DONA_PROXMOX_CA_FILE')
timeout_str = get('DONA_PROXMOX_TIMEOUT') or '3'
try:
timeout = float(timeout_str)
except ValueError as exc:
raise ProxmoxDirectUnavailable('timeout_invalide') from exc
timeout = max(1.0, min(timeout, 10.0))
return base, f'PVEAPIToken={tid}={secret}', verify, ca_file, timeout
def _fetch(base, token, verify, ca_file, timeout, endpoint):
if verify:
try:
ctx = ssl.create_default_context(cafile=ca_file or None)
except (OSError, ssl.SSLError) as exc:
raise ProxmoxDirectUnavailable('certificat_invalide') from exc
else:
ctx = ssl._create_unverified_context() # Only when explicitly selected in config.
req = Request(base + endpoint, headers={'Authorization': token, 'Accept': 'application/json'}, method='GET')
try:
with urlopen(req, timeout=timeout, context=ctx) as r:
raw = r.read(MAX_DATA + 1)
if len(raw) > MAX_DATA:
raise ProxmoxDirectUnavailable('reponse_trop_volumineuse')
data = json.loads(raw)
if not isinstance(data, dict) or not isinstance(data.get('data'), list):
raise ProxmoxDirectUnavailable('schema_inattendu')
return data['data']
except HTTPError as exc:
raise ProxmoxDirectUnavailable(f'http_{exc.code}') from None
except (URLError, TimeoutError, ssl.SSLError, OSError):
raise ProxmoxDirectUnavailable('connexion_impossible') from None
except (ValueError, UnicodeError):
raise ProxmoxDirectUnavailable('json_invalide') from None
def _percent(value):
try:
number = float(value)
except (ValueError, TypeError):
return None
return round(max(0, min(100, number * 100)), 1)
def _text(value, limit=80):
return str(value if value is not None else '—').replace('\r', ' ').replace('\n', ' ')[:limit]
def _mk_dashboard(prompt, cluster, nodes, resources):
if not isinstance(cluster, list) or not isinstance(nodes, list) or not isinstance(resources, list):
raise ProxmoxDirectUnavailable('schema_inattendu')
nodes = [n for n in nodes if isinstance(n, dict) and n.get('node')]
resources = [r for r in resources if isinstance(r, dict) and r.get('type') in ('qemu', 'lxc')]
if not nodes:
raise ProxmoxDirectUnavailable('aucun_noeud_visible')
cards = []
count_up = sum(str(n.get('status', '')).lower() == 'online' for n in nodes)
cards.append({'type': 'metric', 'title': 'Nœuds actifs', 'value': f'{count_up}/{len(nodes)}'})
qemu = [r for r in resources if r['type'] == 'qemu' and not r.get('template')]
lxc = [r for r in resources if r['type'] == 'lxc' and not r.get('template')]
cards.append({'type': 'metric', 'title': 'VM actives', 'value': f"{sum(r.get('status') == 'running' for r in qemu)}/{len(qemu)}"})
cards.append({'type': 'metric', 'title': 'Conteneurs LXC actifs', 'value': f"{sum(r.get('status') == 'running' for r in lxc)}/{len(lxc)}"})
c = next((x for x in cluster if isinstance(x, dict) and x.get('type') == 'cluster'), None)
if c is not None and c.get('quorate') is not None:
cards.append({'type': 'metric', 'title': 'Quorum', 'value': 'Oui' if str(c['quorate']) in ('1', 'True', 'true') else 'Non'})
status_nodes = [f"{_text(n['node'])} — {_text(n.get('status', 'inconnu'))}" for n in sorted(nodes, key=lambda n: str(n['node']))][:24]
cards.append({'type': 'list', 'title': 'État des nœuds', 'items': status_nodes or ['Aucun nœud visible']})
cpus = [(_text(n['node']), _percent(n.get('cpu'))) for n in nodes]
bars = [{'label': name, 'value': value, 'unit': '%'} for name, value in cpus if value is not None]
if bars:
cards.append({'type': 'bars', 'title': 'CPU par nœud', 'items': bars[:16]})
workloads = sorted(qemu + lxc, key=lambda r: (str(r.get('node', '')), str(r.get('name', '')), str(r.get('vmid', ''))))
rows = [[_text(r.get('vmid')), _text(r.get('name') or f"{r.get('type')} {r.get('vmid', '')}"),
'VM' if r['type'] == 'qemu' else 'LXC', _text(r.get('node')), _text(r.get('status', 'inconnu'))]
for r in workloads[:20]]
if rows:
cards.append({'type': 'table', 'title': 'VM et conteneurs (20 premiers au maximum)',
'columns': ['ID', 'Nom', 'Type', 'Nœud', 'État'], 'rows': rows})
if len(workloads) > 20:
cards.append({'type': 'text', 'title': 'Affichage',
'text': f"{len(workloads)} VM/conteneurs visibles ; 20 affichés dans le tableau."})
return {'title': 'Tableau de bord Proxmox', 'subtitle': 'Données directes de l’API Proxmox',
'cards': cards}
def build_standard_dashboard(prompt):
if not enabled():
raise ProxmoxDirectUnavailable('desactive')
base, token, verify, ca_file, timeout = _config()
endpoints = ['/cluster/status', '/nodes', '/cluster/resources?type=vm']
with ThreadPoolExecutor(max_workers=3, thread_name_prefix='dona-proxmox-ro') as ex:
results = list(ex.map(lambda e: _fetch(base, token, verify, ca_file, timeout, e), endpoints))
return _mk_dashboard(prompt, *results)
def diagnostic():
start = time.monotonic()
try:
board = build_standard_dashboard('Tableau de bord Proxmox')
except ProxmoxDirectUnavailable as exc:
print('Proxmox direct : INDISPONIBLE (' + str(exc) + ')')
print('Dona continuera à utiliser Hermes/MCP pour les tableaux Proxmox.')
return 1
print('Proxmox direct : OK')
print('Cartes construites :', len(board['cards']))
print('Temps de lecture et assemblage : %.0f ms' % ((time.monotonic()-start)*1000))
return 0
if __name__ == '__main__':
raise SystemExit(diagnostic())
@@ -0,0 +1,137 @@
"""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]
@@ -0,0 +1,317 @@
"""Dona Visual Context v1.0: structured, read-only contextual cards.
No action is executed from a visual card. This module never invokes Home Assistant,
Hermes, Spotify or ComfyUI. It only renders events / image references supplied by
those systems, or tries a public Wikimedia Commons illustration for recipes.
"""
from __future__ import annotations
import copy
import functools
import json
import os
import re
import threading
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
VERSION = "1.0.0"
EVENT = "dona_visual_card"
MAX_TITLE = 110
ALLOWED_TYPES = {"action_state", "recipe", "media", "weather", "calendar", "info"}
ALLOWED_STATUSES = {"confirmed", "reported", "pending", "error", "informational"}
ALLOWED_ICONS = {"lightbulb-on", "lightbulb-off", "power", "thermometer", "lock", "unlock", "blinds-open", "blinds-closed", "music", "chef-hat", "cloud-sun", "calendar", "info", "check", "alert"}
RESPONSE_EVENTS = {"assistant_response", "assistant_message", "assistant_reply", "ai_response", "hermes_response", "response", "chat_response", "message", "dona_response"}
WEB_IMAGE_ENABLED = os.environ.get("DONA_VISUAL_WEB_IMAGES", "1") == "1"
_cache: dict[str, tuple[float, dict[str, str]]] = {}
_cache_lock = threading.Lock()
_request_limits: dict[str, tuple[float, int]] = {}
_request_lock = threading.Lock()
def _short(value: Any, limit: int = 140) -> str:
return str(value or "").strip()[:limit]
def _safe_web_url(url: Any) -> str | None:
"""Only accept public HTTPS Wikimedia/Commons URLs or local absolute URLs.
ComfyUI must publish its image through an existing same-origin endpoint.
"""
if not isinstance(url, str) or len(url) > 2048:
return None
if url.startswith("/") and not url.startswith("//") and "\\" not in url:
return url
p = urllib.parse.urlsplit(url)
host = (p.hostname or "").lower()
if p.scheme == "https" and (host == "upload.wikimedia.org" or host == "commons.wikimedia.org"):
return url
return None
def validate_card(card: Any) -> dict[str, Any]:
"""Return a safe copy; reject HTML and Javascript-defined cards."""
if not isinstance(card, dict):
raise ValueError("card must be an object")
if card.get("schema") != "dona.visual.v1":
raise ValueError("schema must be dona.visual.v1")
kind = card.get("type")
if kind not in ALLOWED_TYPES:
raise ValueError("unknown card type")
state = card.get("status", "informational")
if state not in ALLOWED_STATUSES:
raise ValueError("unknown status")
title = _short(card.get("title"), MAX_TITLE)
if not title:
raise ValueError("title required")
icon = card.get("icon", "info")
if icon not in ALLOWED_ICONS:
icon = "info"
result = {
"schema": "dona.visual.v1", "id": _short(card.get("id") or uuid4().hex, 64),
"type": kind, "status": state, "title": title,
"subtitle": _short(card.get("subtitle"), 220), "icon": icon,
"timestamp": _short(card.get("timestamp") or datetime.now(timezone.utc).isoformat(), 40),
"source": _short(card.get("source") or "dona", 30),
}
# Only whitelisted primitive fields are forwarded. No HTML, CSS or script.
if isinstance(card.get("details"), list):
result["details"] = [_short(v, 180) for v in card["details"][:12] if isinstance(v, (str, int, float))]
if kind == "recipe":
result["ingredients"] = [_short(v, 180) for v in (card.get("ingredients") or [])[:20] if isinstance(v, str)] if isinstance(card.get("ingredients"), list) else []
result["steps"] = [_short(v, 400) for v in (card.get("steps") or [])[:15] if isinstance(v, str)] if isinstance(card.get("steps"), list) else []
result["duration"] = _short(card.get("duration"), 60)
image = card.get("image")
if isinstance(image, dict):
provider = image.get("source", "none")
if provider in {"web", "comfyui", "local", "none"}:
img = {"source": provider, "alt": _short(image.get("alt") or title, 140)}
if provider == "web":
img["query"] = _short(image.get("query") or title, 80)
else:
img_url = _safe_web_url(image.get("url"))
if img_url:
img["url"] = img_url
result["image"] = img
return result
def make_action_card(entity_id: str, action: str, *, success: bool | None = None, label: str | None = None) -> dict[str, Any]:
"""Confirmed only when caller verified a successful tool result/state readback."""
domain = str(entity_id).split(".", 1)[0]
if not re.fullmatch(r"[a-z_]+\.[a-z0-9_]+", entity_id):
raise ValueError("invalid entity_id")
on = action in {"turn_on", "on", "open", "unlock"}
if action not in {"turn_on", "on", "turn_off", "off", "open", "close", "unlock", "lock"}:
raise ValueError("unsupported action")
status = "confirmed" if success is True else ("error" if success is False else "reported")
verb = ("allumée" if on else "éteinte") if domain in {"light", "switch"} else ("activé" if on else "désactivé")
if domain == "cover": verb = "ouvert" if on else "fermé"
if domain == "lock": verb = "déverrouillée" if on else "verrouillée"
target = label or entity_id.split(".", 1)[1].replace("_", " ").capitalize()
card = {
"schema": "dona.visual.v1", "type": "action_state", "title": target,
"subtitle": f"{verb.capitalize()} · {'confirmé' if status == 'confirmed' else 'échec' if status == 'error' else 'annoncé, non vérifié'}",
"status": status, "icon": (
("lightbulb-on" if on else "lightbulb-off") if domain == "light" else
("blinds-open" if on else "blinds-closed") if domain == "cover" else
("unlock" if on else "lock") if domain == "lock" else "power"
),
"source": "home_assistant"
}
return validate_card(card)
def make_recipe_card(title: str, *, ingredients: list[str] | None = None, steps: list[str] | None = None,
duration: str = "", image_source: str = "web", image_url: str | None = None) -> dict[str, Any]:
return validate_card({
"schema": "dona.visual.v1", "type": "recipe", "status": "informational",
"title": title, "subtitle": "Recette illustrée", "icon": "chef-hat", "source": "hermes",
"ingredients": ingredients or [], "steps": steps or [], "duration": duration,
"image": {"source": image_source, "query": title, "url": image_url, "alt": title},
})
def _recipe_sections(text: str) -> tuple[list[str], list[str]]:
"""Extract only ingredients/steps explicitly present in the assistant text."""
ingredients: list[str] = []
steps: list[str] = []
section = None
for raw in text.splitlines()[:110]:
line = raw.strip()
clean_heading = re.sub(r"^[#*\s]+", "", line).strip(" :*-").casefold()
if re.fullmatch(r"(?:ingrédients|ingredients)(?:\s*\([^)]*\))?", clean_heading):
section = "ingredients"
continue
if re.fullmatch(r"(?:préparation|étapes|etapes|instructions|recette)", clean_heading):
section = "steps"
continue
if line.startswith("#") and section:
section = None
continue
if section is None or not line:
continue
matched = re.match(r"^(?:[-*•]\s+|\d{1,2}[.)]\s+)(.+)$", line)
if matched:
value = matched.group(1).strip()
if section == "ingredients" and len(ingredients) < 20:
ingredients.append(value[:180])
if section == "steps" and len(steps) < 15:
steps.append(value[:400])
return ingredients, steps
def extract_card(event: str, payload: Any) -> dict[str, Any] | None:
"""Non-intrusive optional auto-adapter for known outgoing Socket.IO messages."""
if event == EVENT:
return None
if isinstance(payload, dict):
# The integration contract is the preferred and fully reliable path.
explicit = payload.get("visual_card") or payload.get("dona_visual_card")
if isinstance(explicit, dict):
try:
return validate_card(explicit)
except ValueError:
return None
# Confirmed HA tool event. Never infer success from a user instruction.
if payload.get("source") in {"home_assistant", "ha"} and "success" in payload:
try:
return make_action_card(payload["entity_id"], payload["action"], success=payload["success"], label=payload.get("friendly_name"))
except (KeyError, ValueError, TypeError):
return None
if event not in RESPONSE_EVENTS:
return None
# Limited best-effort textual illustration. These are NEVER confirmed actions.
text = payload if isinstance(payload, str) else (
next((payload[k] for k in ("text", "reply", "response", "answer", "content") if isinstance(payload.get(k), str)), "")
if isinstance(payload, dict) else ""
)
if not text or len(text) > 12000:
return None
# Only extract an actual recipe headline from the assistant response.
match = re.search(r"(?:recette\s+(?:de\s+|d['’]\s*)|préparons\s+(?:une?\s+))((?:tarte|gâteau|quiche|salade|soupe|crêpes?|pâtes?)\s+(?:aux?\s+|à\s+la?\s+|de\s+)[^\n.!?:]{2,55})", text, flags=re.I)
if match:
ingredients, steps = _recipe_sections(text)
return make_recipe_card(match.group(1).strip(), ingredients=ingredients, steps=steps)
# Confirmed-sounding text remains marked "reported" (not verified by HA).
match = re.search(r"\b(?:j['’]ai\s+|c['’]est\s+)(éteint|allumé)\s+(?:la\s+|les\s+|le\s+)(lumière|lampes?|éclairage)\s+(?:du|de la|des|dans le)\s+([a-zÀ-ÿ\- ]{2,45})", text, re.I)
if match:
room = match.group(3).strip().split("\n")[0].strip(" .!?,")
if len(room) > 30: room = room[:30]
return validate_card({"schema": "dona.visual.v1", "type": "action_state", "status": "reported",
"title": room.capitalize(), "subtitle": "Lumière annoncée " + ("éteinte" if match.group(1).lower() == "éteint" else "allumée") + " · non vérifié",
"icon": "lightbulb-off" if match.group(1).lower() == "éteint" else "lightbulb-on", "source": "hermes"})
return None
def lookup_commons(query: str) -> dict[str, str] | None:
"""Server searches only commons.wikimedia.org; no arbitrary URL fetches/SSRF."""
query = _short(query, 80)
if not query or len(query) < 3 or not WEB_IMAGE_ENABLED:
return None
# Common French food queries are easier to illustrate in Commons English search.
search_query = {"tarte aux pommes": "apple tart", "gâteau au chocolat": "chocolate cake"}.get(query.casefold(), query)
key = query.casefold()
now = time.time()
with _cache_lock:
found = _cache.get(key)
if found and found[0] > now:
return copy.deepcopy(found[1]) if found[1] else None
# Image search is opportunistic; no action depends on a network success.
params = urllib.parse.urlencode({"action": "query", "generator": "search", "gsrsearch": f"filetype:bitmap {search_query}",
"gsrnamespace": "6", "gsrlimit": "8", "prop": "imageinfo", "iiprop": "url|extmetadata",
"iiurlwidth": "900", "format": "json", "formatversion": "2"})
req = urllib.request.Request("https://commons.wikimedia.org/w/api.php?" + params,
headers={"User-Agent": "DonaVisuals/1.0 (local voice assistant; contact: none)", "Accept": "application/json"})
result = None
try:
with urllib.request.urlopen(req, timeout=4) as response:
raw = response.read(250_000)
pages = json.loads(raw).get("query", {}).get("pages", [])
for page in pages:
info = (page.get("imageinfo") or [{}])[0]
url = _safe_web_url(info.get("thumburl") or info.get("url"))
if not url:
continue
metadata = info.get("extmetadata") or {}
# Simple attribution link; no raw HTML from metadata rendered.
source_url = _safe_web_url(info.get("descriptionurl"))
result = {"url": url, "source_url": source_url or "https://commons.wikimedia.org", "attribution": "Wikimedia Commons — consulter la licence de l’image"}
break
except (OSError, ValueError, KeyError, TypeError):
pass
with _cache_lock:
if len(_cache) > 180: _cache.clear()
_cache[key] = (now + (3600 * 12 if result else 600), result or {})
return result
def register_dona_visuals(app: Any, socketio: Any = None) -> None:
"""Register read-only endpoints and optional Socket.IO emit bridge."""
if getattr(app, "_dona_visuals_registered", False):
return
from flask import jsonify, request
app._dona_visuals_registered = True
@app.get("/api/dona-visuals/status")
def dona_visuals_status():
return jsonify({"status": "ok", "version": VERSION, "event": EVENT, "web_images": WEB_IMAGE_ENABLED, "socket_bridge": socketio is not None})
@app.get("/api/dona-visuals/image")
def dona_visuals_image():
query = request.args.get("q", "")
if not isinstance(query, str) or len(query) > 80 or len(query) < 3:
return jsonify({"error": "Invalid image query"}), 400
# Short burst limit, bounded memory, no persistent client identifiers.
remote = _short(request.remote_addr or "unknown", 48)
now = time.monotonic()
with _request_lock:
if len(_request_limits) > 500:
_request_limits.clear()
start, count = _request_limits.get(remote, (now, 0))
if now - start > 60:
start, count = now, 0
_request_limits[remote] = (start, count + 1)
limited = count >= 20
if limited:
return jsonify({"error": "Too many image requests"}), 429
image = lookup_commons(query)
return jsonify(image or {"url": None})
@app.get("/api/dona-visuals/demo/<kind>")
def dona_visuals_demo(kind):
if kind == "light":
return jsonify(make_action_card("light.salon", "turn_off", success=True, label="Salon"))
if kind == "recipe":
return jsonify(make_recipe_card("Tarte aux pommes", ingredients=["4 pommes", "1 pâte feuilletée", "40 g de sucre", "20 g de beurre"],
steps=["Préchauffer le four à 180 °C", "Étaler la pâte et disposer les pommes", "Cuire environ 35 minutes"], duration="45 min"))
return jsonify({"error": "Unknown demo"}), 404
if socketio is None or getattr(socketio, "_dona_visuals_wrapped", False):
return
original_emit = socketio.emit
@functools.wraps(original_emit)
def emit_with_visual(event, *args, **kwargs):
result = original_emit(event, *args, **kwargs)
try:
payload = args[0] if args else kwargs.get("data")
card = extract_card(event, payload)
if card:
opts = {k: kwargs[k] for k in ("to", "room", "namespace", "skip_sid") if k in kwargs}
original_emit(EVENT, card, **opts)
except Exception as exc: # NEVER affect existing Hermes/HA event delivery
app.logger.warning("[DONA-VISUAL] Visual adapter ignored exception: %s", exc)
return result
socketio.emit = emit_with_visual
socketio._dona_visuals_wrapped = True
def publish_visual(socketio: Any, card: dict[str, Any], *, to: str | None = None) -> None:
"""Call from verified business events, after the action completed successfully."""
kwargs = {"to": to} if to else {}
socketio.emit(EVENT, validate_card(card), **kwargs)
+10 -7
View File
@@ -1,5 +1,6 @@
"""Pont Alfred -> Hermes avec session persistante."""
from services.dona_hermes_stream import run_jsonl_command as _dona_run_jsonl_command # DONA_HERMES_PERF_V1
import fcntl
import json
@@ -379,13 +380,9 @@ def _call_hermes(
])
try:
process = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=False
# DONA_HERMES_PERF_V1: conserver la session, transmettre les deltas en temps réel
process = _dona_run_jsonl_command(
cmd, prompt, _hermes_timeout()
)
except subprocess.TimeoutExpired as exc:
@@ -428,6 +425,12 @@ def _call_hermes(
def ask_hermes(text: str) -> str:
# DONA_UNIFIED_ROUTING_V4 — tentative de tableau direct, sinon Hermes intact
from services.dona_unified_router_v4 import try_route as _dona_v4_try_route
_dona_v4_quick = _dona_v4_try_route(text)
if _dona_v4_quick is not None:
return _dona_v4_quick
prompt = _build_prompt(text)
# Ancien fonctionnement sans session