v2.1 - Small improvement
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user