155 lines
7.8 KiB
Python
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
|