v4
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,66 @@
|
||||
"""Dona: heure/date du système, sans appel LLM. Fuseau Europe/Paris par défaut."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
DAYS = ('lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche')
|
||||
MONTHS = ('janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet',
|
||||
'août', 'septembre', 'octobre', 'novembre', 'décembre')
|
||||
|
||||
|
||||
def _normalized(text: str) -> str:
|
||||
text = unicodedata.normalize('NFKD', str(text or '').lower())
|
||||
text = ''.join(c for c in text if not unicodedata.combining(c))
|
||||
text = re.sub(r'[^a-z0-9]+', ' ', text).strip()
|
||||
return re.sub(r'^(?:dona|alfred)\s+', '', text)
|
||||
|
||||
|
||||
def intent(text: str) -> str | None:
|
||||
"""Renvoie 'time', 'date', 'both' ou None; pas de faux positif trajet/durée."""
|
||||
s = _normalized(text)
|
||||
if not s or len(s) > 140:
|
||||
return None
|
||||
# Ne pas répondre avec l'heure de Paris à une question sur une autre ville.
|
||||
if re.search(r'\b(?:heure|est il|est elle)\s+(?:a|en|au|aux|dans)\s+[a-z]', s):
|
||||
return None
|
||||
# Ne pas renvoyer la date actuelle à une question concernant une date future/passée.
|
||||
if re.search(r'\bdate\s+(?:du|de|pour|a)\s+(?:\d|demain|hier)', s):
|
||||
return None
|
||||
t = (
|
||||
bool(re.search(r'\b(?:quelle?|quel)\s+heure\s+(?:est\s+il|il\s+est|est\s+elle)\b', s))
|
||||
or bool(re.search(r'\bil\s+est\s+(?:quelle?|quel)\s+heure\b', s))
|
||||
or bool(re.search(r'\b(?:donne|dis|dites|affiche|montre)\s+(?:moi\s+)?(?:l\s+)?heure\b', s))
|
||||
or bool(re.search(r'\b(?:heure\s+actuelle|heure\s+maintenant|il\s+est\s+quelle\s+heure)\b', s))
|
||||
)
|
||||
d = (
|
||||
bool(re.search(r'\b(?:quelle?|quel)\s+(?:est\s+)?(?:la\s+)?date\s+(?:d\s+aujourd\s+hui|aujourd\s+hui|du\s+jour)?\b', s))
|
||||
or bool(re.search(r'\b(?:on\s+est|nous\s+sommes|sommes\s+nous)\s+(?:quel|quelle|le\s+combien)\s+(?:jour|date)?\b', s))
|
||||
or bool(re.search(r'\b(?:quel\s+jour\s+(?:est\s+on|sommes\s+nous|on\s+est)|quelle\s+date\s+sommes\s+nous)\b', s))
|
||||
or bool(re.search(r'\b(?:donne|dis|affiche|montre)\s+(?:moi\s+)?(?:la\s+)?date\s+(?:du\s+jour)?\b', s))
|
||||
)
|
||||
if t and d:
|
||||
return 'both'
|
||||
if t:
|
||||
return 'time'
|
||||
if d:
|
||||
return 'date'
|
||||
return None
|
||||
|
||||
|
||||
def answer(text: str, *, now: datetime | None = None) -> str | None:
|
||||
which = intent(text)
|
||||
if which is None:
|
||||
return None
|
||||
if now is None:
|
||||
try:
|
||||
tz = ZoneInfo(os.getenv('DONA_TIMEZONE') or os.getenv('ALFRED_TIMEZONE') or 'Europe/Paris')
|
||||
except (ZoneInfoNotFoundError, ValueError, KeyError):
|
||||
tz = ZoneInfo('Europe/Paris')
|
||||
now = datetime.now(tz)
|
||||
hour = f'Il est {now.hour} heures {now.minute:02d}.'
|
||||
day = f"Aujourd'hui, nous sommes le {DAYS[now.weekday()]} {now.day} {MONTHS[now.month-1]} {now.year}."
|
||||
return (hour + ' ' + day) if which == 'both' else hour if which == 'time' else day
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Dona: reuse the existing, data-only dashboard renderer for Hermes replies.
|
||||
|
||||
This module never executes model-supplied HTML, JavaScript or instructions. Only
|
||||
`dona_dynamic.normalize_dashboard` can build cards; invalid content remains text.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from services import dona_dynamic
|
||||
|
||||
|
||||
MAX_JSON = dona_dynamic.MAX_RESPONSE
|
||||
|
||||
|
||||
def is_monitor_request(prompt: str) -> bool:
|
||||
"""Route clearly operational Proxmox/Wazuh checks to existing dashboard API."""
|
||||
q = str(prompt or '').casefold()
|
||||
if not re.search(r'\b(proxmox|wazuh|cluster|clusters)\b', q):
|
||||
return False
|
||||
return bool(re.search(
|
||||
r'\b(v[eé]rifi(?:e|es|er|ez)|contr[oô]le(?:r|s|z)?|surveille(?:r|s|z)?|'
|
||||
r'montre|affiche|tableau|[eé]tat|statut|status|ressources?|'
|
||||
r'performances?|noeuds?|nœuds?|serveurs?|alertes?|supervision|'
|
||||
r'fonctionn(?:e|ement)|disponibilit[eé])\b', q))
|
||||
|
||||
|
||||
def _candidate_text(answer: Any, display: Any) -> str | None:
|
||||
if isinstance(display, dict):
|
||||
if isinstance(display.get('cards'), list):
|
||||
# Raw dashboard object should not be converted via repr().
|
||||
import json
|
||||
return json.dumps(display, ensure_ascii=False)
|
||||
if display.get('type') in (None, 'text'):
|
||||
raw = display.get('text')
|
||||
if isinstance(raw, str) and '"cards"' in raw:
|
||||
return raw
|
||||
if isinstance(answer, str) and '"cards"' in answer:
|
||||
return answer
|
||||
if isinstance(answer, dict):
|
||||
if isinstance(answer.get('cards'), list):
|
||||
import json
|
||||
return json.dumps(answer, ensure_ascii=False)
|
||||
for key in ('text', 'speech'):
|
||||
raw = answer.get(key)
|
||||
if isinstance(raw, str) and '"cards"' in raw:
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
def _clean_line_continuations(text: str) -> str:
|
||||
"""Repair two common harmless Hermes JSON formatting artefacts only.
|
||||
|
||||
Trailing backslash + real newline (copied JSON), and invalid ``\\~``
|
||||
escaping in approximate values. Other malformed content is left untouched.
|
||||
"""
|
||||
text = re.sub(r'\\[ \t]*\r?\n', '\n', text)
|
||||
return text.replace('\\~', '~')
|
||||
|
||||
|
||||
def as_board(answer: Any, display: Any, prompt: str) -> dict | None:
|
||||
"""Return a server-validated dashboard, or None for an ordinary answer."""
|
||||
raw = _candidate_text(answer, display)
|
||||
if not raw or len(raw) > MAX_JSON:
|
||||
return None
|
||||
try:
|
||||
extracted = dona_dynamic.extract_dashboard(_clean_line_continuations(raw))
|
||||
return dona_dynamic.normalize_dashboard(extracted, prompt=prompt, origin='hermes')
|
||||
except (ValueError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def convert(answer: Any, prompt: str, display: Any, speech: str):
|
||||
"""Convert ONLY actual card JSON. Preserve ordinary answers and images."""
|
||||
if isinstance(display, dict) and display.get('type') not in (None, 'text'):
|
||||
return display, speech
|
||||
board = as_board(answer, display, prompt)
|
||||
if board is None:
|
||||
return display, speech
|
||||
return {'type': 'dona_dynamic', 'dashboard': board}, 'Voici le tableau de bord : ' + board['title']
|
||||
@@ -91,6 +91,58 @@ def stop_playback():
|
||||
pass
|
||||
|
||||
|
||||
# DONA_WAVES_V1 — volume réel du WAV Wyoming Piper (50 ms, hors micro).
|
||||
from array import array as _dona_array
|
||||
import sys as _dona_sys
|
||||
_dona_level_callback = None
|
||||
|
||||
def dona_set_level_callback(callback):
|
||||
global _dona_level_callback
|
||||
_dona_level_callback = callback
|
||||
|
||||
def _dona_emit_level(value):
|
||||
if _dona_level_callback is not None:
|
||||
try:
|
||||
_dona_level_callback(max(0.0, min(1.0, float(value))))
|
||||
except Exception:
|
||||
pass # Une erreur côté interface ne doit jamais interrompre Piper.
|
||||
|
||||
def _dona_waves_stream_envelope(wav_path, proc):
|
||||
"""Échantillonne le PCM pendant aplay, sans enregistrer ni exposer le son."""
|
||||
try:
|
||||
with wave.open(wav_path, 'rb') as stream:
|
||||
if stream.getsampwidth() != 2:
|
||||
return # Autre format : état « speaking » en secours côté navigateur.
|
||||
rate=stream.getframerate()
|
||||
if rate <= 0:
|
||||
return
|
||||
frames=max(1,int(rate * .05))
|
||||
started=time.monotonic()
|
||||
index=0
|
||||
while proc.poll() is None:
|
||||
raw=stream.readframes(frames)
|
||||
if not raw:
|
||||
break
|
||||
samples=_dona_array('h')
|
||||
samples.frombytes(raw)
|
||||
if _dona_sys.byteorder=='big':
|
||||
samples.byteswap()
|
||||
stride=max(1,len(samples)//420)
|
||||
window=samples[::stride]
|
||||
rms=(sum(s*s for s in window)/max(1,len(window)))**.5
|
||||
desired=started+index*frames/rate
|
||||
remaining=desired-time.monotonic()
|
||||
if remaining>0:
|
||||
time.sleep(min(remaining,.07))
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
_dona_emit_level(min(1.0,rms/6500))
|
||||
index+=1
|
||||
except Exception as exc:
|
||||
print('[DONA WAVES] analyse PCM impossible :',type(exc).__name__,flush=True)
|
||||
finally:
|
||||
_dona_emit_level(0)
|
||||
|
||||
def speak(text):
|
||||
if not text or not text.strip():
|
||||
return
|
||||
@@ -123,6 +175,9 @@ def speak(text):
|
||||
_playback_proc = proc
|
||||
if _playback_cancel.is_set() and proc.poll() is None:
|
||||
proc.terminate()
|
||||
# DONA_WAVES_V1 : animation par l'amplitude du vrai son joué.
|
||||
threading.Thread(target=_dona_waves_stream_envelope,
|
||||
args=(wav_path,proc),daemon=True).start()
|
||||
try:
|
||||
result = proc.wait(timeout=120)
|
||||
except subprocess.TimeoutExpired:
|
||||
|
||||
Reference in New Issue
Block a user