Files
Alfredv2/alfred/app/services/admin_extensions_v2.py
T
2026-10-28 15:38:38 +01:00

155 lines
7.8 KiB
Python

"""Additive health information for Alfred admin console V2.
All checks are read-only. No model download, subprocess of Hermes, or microphone
stream is triggered by the monitoring endpoint.
"""
from __future__ import annotations
import json
import os
import socket
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MAX_WYOMING_INFO = 65536
_WHISPER_CACHE = {'key': None, 'at': 0.0, 'result': None}
def _describe_whisper(host: str, port: int, timeout: float = 1.8) -> dict:
"""Check actual Wyoming info/ASR support; *not* a transcription test."""
start = time.monotonic()
try:
with socket.create_connection((host, port), timeout=timeout) as sock:
sock.settimeout(timeout)
sock.sendall(b'{"type":"describe"}\n')
with sock.makefile('rb') as stream:
header = None
# Other Wyoming events may precede info; never wait indefinitely.
for _ in range(4):
line = stream.readline(4096)
if not line:
break
event = json.loads(line.decode('utf-8'))
if event.get('type') == 'info':
header = event
break
if header is None:
raise ValueError('Réponse info Wyoming absente')
length = int(header.get('data_length', 0))
if length <= 0 or length > MAX_WYOMING_INFO:
raise ValueError('Taille de réponse Wyoming invalide')
payload = stream.read(length)
if len(payload) != length:
raise ValueError('Réponse Wyoming incomplète')
info = json.loads(payload.decode('utf-8'))
asrs = [x for x in info.get('asr', []) if isinstance(x, dict) and x.get('installed')]
if not asrs:
return {'state': 'warning', 'detail': 'Wyoming accessible, aucun moteur ASR installé',
'latency_ms': round((time.monotonic() - start) * 1000)}
asr = asrs[0]
models = [x.get('name') for x in asr.get('models', [])
if isinstance(x, dict) and x.get('installed') and x.get('name')]
model = ', '.join(models[:3]) or 'non indiqué'
name = asr.get('name', 'ASR')
return {'state': 'ok', 'detail': f'{name} · modèle {model} · protocole Wyoming',
'engine': name, 'model': model,
'streaming': bool(asr.get('supports_transcript_streaming')),
'latency_ms': round((time.monotonic() - start) * 1000)}
except (OSError, ValueError, TypeError, UnicodeError, json.JSONDecodeError):
return {'state': 'down', 'detail': f'Pas de réponse ASR Wyoming sur {host}:{port}'}
def _whisper_status(host: str, port: int) -> dict:
key = (host, port)
now = time.monotonic()
if _WHISPER_CACHE['key'] == key and (now - _WHISPER_CACHE['at']) < 10:
return dict(_WHISPER_CACHE['result'])
result = _describe_whisper(host, port)
_WHISPER_CACHE.update(key=key, at=now, result=dict(result))
return result
def _timer_snapshot() -> dict:
"""Read existing timer store without calling its mutating methods."""
installed = (ROOT / 'services' / 'alfred_timers.py').is_file()
store_path = Path(os.environ.get('ALFRED_TIMERS_DB', str(ROOT / 'data' / 'timers.json')))
if not installed:
return {'installed': False, 'state': 'unknown', 'detail': 'Module minuteurs non installé', 'items': []}
if not store_path.is_file():
return {'installed': True, 'state': 'ok', 'detail': 'Module présent, aucun minuteur enregistré', 'items': []}
try:
if store_path.stat().st_size > 1024 * 1024:
raise ValueError('Fichier minuteurs trop volumineux')
data = json.loads(store_path.read_text(encoding='utf-8'))
if not isinstance(data, list):
raise ValueError('Format minuteurs inattendu')
now = time.time()
items = []
for item in data:
if not isinstance(item, dict) or item.get('state') not in ('running', 'paused'):
continue
duration = item.get('duration')
elapsed = max(0, float(item.get('elapsed', 0)))
if item.get('state') == 'running':
elapsed += max(0, now - float(item['started_at']))
remaining = max(0, float(duration) - elapsed) if duration is not None else None
# A timer whose deadline passed is omitted even if its worker has not
# yet persisted the finished state.
if remaining == 0 and item.get('state') == 'running' and duration is not None:
continue
items.append({'label': str(item.get('label', 'Minuteur'))[:80],
'kind': item.get('kind') if item.get('kind') in ('timer', 'stopwatch') else 'timer',
'state': item['state'], 'remaining': round(remaining, 1) if remaining is not None else None,
'elapsed': round(elapsed, 1), 'duration': duration})
return {'installed': True, 'state': 'ok', 'detail': f'{len(items)} en cours ou en pause',
'items': items[:20]}
except (OSError, ValueError, TypeError, KeyError, OverflowError):
return {'installed': True, 'state': 'warning', 'detail': 'Stockage minuteurs illisible', 'items': []}
def extend_health(data: dict, read_config=None) -> dict:
"""Enrich existing _health() JSON. The base implementation is left intact."""
try:
saved = read_config() if callable(read_config) else {}
except (ValueError, OSError):
saved = {}
configured = str(saved.get('STT_ENGINE', os.getenv('STT_ENGINE', 'vosk'))).lower()
runtime = os.getenv('STT_ENGINE', 'vosk').lower()
runtime = 'wyoming' if runtime in ('whisper', 'wyoming_whisper', 'wyoming') else 'vosk'
host = os.getenv('WYOMING_WHISPER_HOST', '192.168.1.13')
try:
port = int(os.getenv('WYOMING_WHISPER_PORT', '10300'))
if not 1 <= port <= 65535:
raise ValueError('Port invalide')
except (ValueError, TypeError):
port = 10300
path = ROOT / 'services' / 'wyoming_stt.py'
installed = path.is_file()
probe = _whisper_status(host, port) if installed else {
'state': 'unknown', 'detail': 'Intégration Wyoming STT non installée'}
if configured != runtime:
state = 'warning'
detail = f'{runtime.upper()} actif ; {configured.upper()} sélectionné (redémarrage nécessaire)'
else:
state = 'ok' if runtime == 'vosk' or (installed and probe['state'] == 'ok') else 'warning'
detail = (f'{runtime.upper()} actif' + (' · retour Vosk si indisponible' if runtime == 'wyoming' else ''))
data['stt'] = {'state': state, 'active_engine': runtime, 'configured_engine': configured,
'detail': detail, 'host': host, 'port': port,
'language': os.getenv('WYOMING_WHISPER_LANGUAGE', 'fr'),
'whisper': probe, 'vosk_fallback': runtime == 'wyoming'}
data.setdefault('services', {})['wyoming_whisper'] = probe
timer = _timer_snapshot()
data['timers'] = timer
data['services']['alfred_timers'] = {'state': timer['state'], 'detail': timer['detail'] + ' (lecture locale)'}
mcps = data.setdefault('mcps', {})
for slug, label in [('MCP_alfred_timers', 'Alfred Timers'), ('MCP_autres', 'Alfred Autres')]:
found = (Path('/opt/hermes') / slug).is_dir()
mcps[slug] = {
'state': 'ok' if found else 'unknown',
'label': label,
'detail': 'Dossier présent · test MCP non exécuté' if found else 'Dossier absent',
}
data['maintenance'] = {'web_restart_enabled': os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() in ('true', '1', 'yes')}
return data