debut illustration
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user