v2 - STT via Whisper for more precision and if nor available, twist to Vox
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.
@@ -0,0 +1,83 @@
|
||||
"""Illustrations contextuelles Wikipédia déclenchées APRÈS une réponse Hermes.
|
||||
|
||||
Pas de modification des prompts Hermes ou de /api/render. Échec silencieux.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from urllib.parse import urlencode, quote, urlparse
|
||||
import requests
|
||||
|
||||
CACHE = {}
|
||||
LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _norm(s):
|
||||
s = unicodedata.normalize('NFKD', str(s)).casefold()
|
||||
return re.sub(r'[^a-z0-9 ]+', '', ''.join(c for c in s if not unicodedata.combining(c)))
|
||||
|
||||
|
||||
def entity_question(s):
|
||||
text = ' '.join(str(s or '').split())
|
||||
m = re.match(r'^(?:alfred[ ,.:!?]*)?(?:qui\s+(?:est|etait|était|sont)\s+|c.?est\s+qui\s+)([^?!.]{3,95})', text, re.I)
|
||||
if not m:
|
||||
return None
|
||||
entity = m.group(1).strip(' «»\'".,?!')
|
||||
entity = re.sub(r'\s+(?:au juste|exactement|s.il te plait)$', '', entity, flags=re.I)
|
||||
if len(entity.split()) > 7 or len(entity) < 3:
|
||||
return None
|
||||
return entity
|
||||
|
||||
|
||||
def _query_wikipedia(name):
|
||||
# PageImages retourne une illustration publique et l'URL de la page source.
|
||||
params = {'action':'query','generator':'search','gsrsearch':name,'gsrlimit':1,
|
||||
'prop':'pageimages','piprop':'thumbnail','pithumbsize':500,
|
||||
'format':'json','formatversion':'2','redirects':'1'}
|
||||
headers = {'User-Agent':'AlfredAssistant/1.0 (personal home assistant; image source: Wikipedia)'}
|
||||
response = requests.get('https://fr.wikipedia.org/w/api.php', params=params,
|
||||
headers=headers, timeout=4, allow_redirects=False)
|
||||
response.raise_for_status()
|
||||
pages = response.json().get('query', {}).get('pages', [])
|
||||
if not pages:
|
||||
return None
|
||||
page = pages[0]
|
||||
title = str(page.get('title',''))
|
||||
if not title or _norm(name) not in _norm(title) and _norm(title) not in _norm(name):
|
||||
return None
|
||||
url = str(page.get('thumbnail', {}).get('source', ''))
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != 'https' or parsed.hostname not in ('upload.wikimedia.org','commons.wikimedia.org'):
|
||||
return None
|
||||
return {'title': title, 'image_url':url,
|
||||
'source_url':'https://fr.wikipedia.org/wiki/'+quote(title.replace(' ','_')),
|
||||
'credit':'Image : Wikipédia / Wikimedia Commons'}
|
||||
|
||||
|
||||
def image_for_question(name):
|
||||
key = _norm(name)
|
||||
with LOCK:
|
||||
old = CACHE.get(key)
|
||||
if old and time.monotonic()-old[0] < 4*3600:
|
||||
return old[1]
|
||||
try:
|
||||
result = _query_wikipedia(name)
|
||||
except (requests.RequestException, ValueError, KeyError):
|
||||
result = None
|
||||
with LOCK:
|
||||
CACHE[key] = (time.monotonic(), result)
|
||||
return result
|
||||
|
||||
|
||||
def schedule_context_image(question, answer, socketio, still_current):
|
||||
name = entity_question(question)
|
||||
if not name:
|
||||
return
|
||||
def worker():
|
||||
info = image_for_question(name)
|
||||
if info and still_current():
|
||||
info['text'] = str(answer or '')[:500]
|
||||
socketio.emit('alfred_context_image', info)
|
||||
threading.Thread(target=worker, name='alfred-context-image', daemon=True).start()
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Alfred 4+5: serial Hermes background jobs + dynamic contextual cards.
|
||||
|
||||
Designed to be mounted into an existing Flask app, preserving STT/TTS/Hermes.
|
||||
No external dependencies except Flask (which Alfred already uses).
|
||||
Single-process worker: do not run multiple WSGI workers with this module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
LOG = logging.getLogger("alfred.features")
|
||||
_manager = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _utc_now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _expiry(minutes):
|
||||
return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def should_run_in_background(text):
|
||||
"""Conservative opt-in for long requests; ordinary voice Q&A unchanged."""
|
||||
txt = (text or "").lower()
|
||||
patterns = [
|
||||
r"en arri[eè]re.plan", r"en t[aâ]che de fond", r"en asynchrone",
|
||||
r"pr[eé]viens.moi (?:quand|lorsque)", r"avertis.moi (?:quand|lorsque)",
|
||||
r"quand (?:tu auras|c['’]est) fini", r"prends le temps de",
|
||||
r"fais (?:moi )?un rapport", r"analyse (?:les?|mes?|l['’]) (?:alertes?|logs?|journaux|rapports?|fichiers?|donn[eé]es?)",
|
||||
r"fais (?:moi )?une synth[eè]se (?:des?|de mes|du)",
|
||||
]
|
||||
return any(re.search(p, txt) for p in patterns)
|
||||
|
||||
|
||||
def _sanitize_url(url):
|
||||
if not isinstance(url, str) or not url or len(url) > 2048:
|
||||
return None
|
||||
try:
|
||||
u = urlsplit(url)
|
||||
except ValueError:
|
||||
return None
|
||||
# Relative paths and HTTP(S) only; block javascript:, data:, file:, etc.
|
||||
if u.scheme in ("http", "https") and u.netloc:
|
||||
return url
|
||||
if not u.scheme and not u.netloc and url.startswith("/") and not url.startswith("//"):
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def _safe_display(display):
|
||||
if not isinstance(display, dict):
|
||||
return {"type": "text", "text": str(display or "")[:12000]}
|
||||
kind = str(display.get("type", "text"))[:32].lower()
|
||||
if kind not in {"text", "image", "weather", "calendar", "notification"}:
|
||||
kind = "text"
|
||||
obj = {"type": kind}
|
||||
for key in ("text", "title", "description", "temperature", "condition"):
|
||||
if key in display:
|
||||
obj[key] = str(display[key])[:12000 if key == "text" else 300]
|
||||
if kind == "image":
|
||||
obj["url"] = _sanitize_url(display.get("url"))
|
||||
if kind == "calendar":
|
||||
obj["events"] = [
|
||||
{"title": str(e.get("title", "Événement"))[:200], "start": str(e.get("start", ""))[:80]}
|
||||
for e in (display.get("events") or [])[:30] if isinstance(e, dict)
|
||||
] if isinstance(display.get("events"), list) else []
|
||||
return obj
|
||||
|
||||
|
||||
class FeatureManager:
|
||||
def __init__(self, db_path, hermes_call, socketio=None, start_worker=True):
|
||||
self.db_path = str(db_path)
|
||||
self.hermes_call = hermes_call
|
||||
self.socketio = socketio
|
||||
self.guard = threading.RLock()
|
||||
self.pending = queue.Queue(maxsize=12)
|
||||
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._setup_db()
|
||||
# Cannot resume a subprocess / Hermes interaction after an app restart.
|
||||
with self._connect() as db:
|
||||
db.execute("UPDATE tasks SET status='interrupted', updated_at=? WHERE status IN ('queued','running')", (_utc_now(),))
|
||||
db.execute("UPDATE cards SET status='interrupted', body='Interrompue par un redémarrage.' WHERE status IN ('queued','running')")
|
||||
if start_worker:
|
||||
self.thread = threading.Thread(target=self._work_loop, name="alfred-hermes-worker", daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _connect(self):
|
||||
db = sqlite3.connect(self.db_path, timeout=10)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA busy_timeout=10000")
|
||||
return db
|
||||
|
||||
def _setup_db(self):
|
||||
with self._connect() as db:
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("""CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL, source TEXT NOT NULL, prompt TEXT NOT NULL,
|
||||
speech TEXT DEFAULT '', display_json TEXT DEFAULT '{}', error TEXT DEFAULT ''
|
||||
)""")
|
||||
db.execute("""CREATE TABLE IF NOT EXISTS cards (
|
||||
id TEXT PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '', status TEXT DEFAULT '',
|
||||
display_json TEXT DEFAULT '{}', task_id TEXT,
|
||||
updated_at TEXT NOT NULL, expires_at TEXT
|
||||
)""")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS cards_updated ON cards(updated_at)")
|
||||
try:
|
||||
os.chmod(self.db_path, 0o600)
|
||||
except OSError:
|
||||
LOG.warning("Unable to restrict database file permissions")
|
||||
|
||||
def _emit(self, event, payload):
|
||||
if self.socketio:
|
||||
try:
|
||||
self.socketio.emit(event, payload)
|
||||
except Exception:
|
||||
LOG.exception("Websocket emission failed")
|
||||
|
||||
def _task(self, task_id):
|
||||
with self._connect() as db:
|
||||
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["display"] = json.loads(d.pop("display_json") or "{}")
|
||||
return d
|
||||
|
||||
def list_tasks(self, limit=30):
|
||||
with self._connect() as db:
|
||||
rows = db.execute("SELECT id FROM tasks ORDER BY created_at DESC LIMIT ?", (min(max(int(limit), 1), 100),)).fetchall()
|
||||
return [self._task(r["id"]) for r in rows]
|
||||
|
||||
def list_cards(self, limit=25):
|
||||
with self.guard, self._connect() as db:
|
||||
db.execute("DELETE FROM cards WHERE expires_at IS NOT NULL AND expires_at < ?", (_utc_now(),))
|
||||
rows = db.execute("SELECT * FROM cards ORDER BY updated_at DESC LIMIT ?", (min(max(int(limit), 1), 50),)).fetchall()
|
||||
cards = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
d["display"] = json.loads(d.pop("display_json") or "{}")
|
||||
cards.append(d)
|
||||
return cards
|
||||
|
||||
def upsert_card(self, card_id, *, kind, title, body="", status="", display=None, task_id=None, ttl=60):
|
||||
try:
|
||||
ttl = int(ttl)
|
||||
except (TypeError, ValueError):
|
||||
ttl = 30
|
||||
expires = _expiry(max(1, min(ttl, 60 * 24))) if ttl > 0 else None
|
||||
display = _safe_display(display or {"type": "text", "text": body})
|
||||
with self.guard, self._connect() as db:
|
||||
db.execute("""INSERT INTO cards(id, kind, title, body, status, display_json, task_id, updated_at, expires_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
|
||||
kind=excluded.kind, title=excluded.title, body=excluded.body,
|
||||
status=excluded.status, display_json=excluded.display_json,
|
||||
task_id=excluded.task_id, updated_at=excluded.updated_at,
|
||||
expires_at=excluded.expires_at""", (
|
||||
str(card_id)[:120], str(kind)[:32], str(title)[:180], str(body)[:12000],
|
||||
str(status)[:32], json.dumps(display, ensure_ascii=False), task_id, _utc_now(), expires
|
||||
))
|
||||
self._emit("alfred_cards_changed", {"at": _utc_now()})
|
||||
|
||||
def publish_display(self, display):
|
||||
d = _safe_display(display)
|
||||
kind = d["type"]
|
||||
if kind == "text" and not d.get("text", "").strip():
|
||||
return
|
||||
# Weather/calendar replace the previous contextual card. Others are transient.
|
||||
card_id = f"context-{kind}" if kind in {"weather", "calendar"} else f"context-{uuid.uuid4().hex[:12]}"
|
||||
title = d.get("title") or {"weather": "Météo", "calendar": "Agenda", "image": "Image", "notification": "Notification"}.get(kind, "Information")
|
||||
self.upsert_card(card_id, kind=kind, title=title, body=d.get("text", ""), display=d,
|
||||
ttl=20 if kind == "weather" else (60 if kind == "calendar" else 15))
|
||||
|
||||
def submit(self, prompt, source="web"):
|
||||
prompt = str(prompt or "").strip()
|
||||
if not prompt or len(prompt) > 8000:
|
||||
raise ValueError("La demande doit contenir entre 1 et 8 000 caractères.")
|
||||
source = str(source)[:32]
|
||||
task_id = uuid.uuid4().hex
|
||||
now = _utc_now()
|
||||
with self.guard, self._connect() as db:
|
||||
active = db.execute("SELECT COUNT(*) FROM tasks WHERE status IN ('queued','running')").fetchone()[0]
|
||||
if active >= 8 or self.pending.full():
|
||||
raise ValueError("La file Hermes est pleine (8 tâches maximum).")
|
||||
db.execute("INSERT INTO tasks(id,created_at,updated_at,status,source,prompt) VALUES (?,?,?,?,?,?)",
|
||||
(task_id, now, now, "queued", source, prompt))
|
||||
self.upsert_card("task-" + task_id, kind="task", title=prompt[:100], body="En attente d’Hermes…",
|
||||
status="queued", task_id=task_id, ttl=0)
|
||||
self.pending.put_nowait(task_id)
|
||||
self._emit("alfred_task", {"id": task_id, "status": "queued"})
|
||||
return self._task(task_id)
|
||||
|
||||
def _mark(self, task_id, status, speech="", display=None, error=""):
|
||||
now = _utc_now()
|
||||
with self.guard, self._connect() as db:
|
||||
db.execute("UPDATE tasks SET status=?, updated_at=?, speech=?, display_json=?, error=? WHERE id=?",
|
||||
(status, now, speech[:12000], json.dumps(display or {}, ensure_ascii=False), error[:1200], task_id))
|
||||
task = self._task(task_id)
|
||||
display = display or {}
|
||||
body = {
|
||||
"running": "Hermes traite la demande…",
|
||||
"succeeded": speech or display.get("text", "Tâche terminée."),
|
||||
"failed": "Échec : " + error[:900],
|
||||
}.get(status, status)
|
||||
self.upsert_card("task-" + task_id, kind="task", title=task["prompt"][:100],
|
||||
body=body, status=status, display=display or {"type": "text", "text": body},
|
||||
task_id=task_id, ttl=0)
|
||||
self._emit("alfred_task", {"id": task_id, "status": status})
|
||||
|
||||
def run_one(self, task_id):
|
||||
self._mark(task_id, "running")
|
||||
try:
|
||||
# This executes serially in one worker, respecting Hermes' session semantics.
|
||||
answer = self.hermes_call(self._task(task_id)["prompt"])
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")[:12000]
|
||||
display = _safe_display(answer.get("display") or {"type": "text", "text": speech})
|
||||
else:
|
||||
speech = str(answer or "")[:12000]
|
||||
display = {"type": "text", "text": speech}
|
||||
self._mark(task_id, "succeeded", speech=speech, display=display)
|
||||
except Exception as exc:
|
||||
LOG.exception("Hermes task failed: %s", task_id)
|
||||
self._mark(task_id, "failed", error=f"{type(exc).__name__}: {exc}")
|
||||
|
||||
def _work_loop(self):
|
||||
while True:
|
||||
task_id = self.pending.get()
|
||||
try:
|
||||
self.run_one(task_id)
|
||||
except Exception:
|
||||
LOG.exception("Unexpected task runner error")
|
||||
finally:
|
||||
self.pending.task_done()
|
||||
|
||||
|
||||
def _local_writer_allowed():
|
||||
# Web UI read-only; explicit write endpoints enabled only from the same host.
|
||||
# Avoid trusting X-Forwarded-For, which clients can spoof unless configured.
|
||||
return request.remote_addr in ("127.0.0.1", "::1")
|
||||
|
||||
|
||||
def init_app(app, ask_hermes, socketio=None, db_path=None):
|
||||
global _manager
|
||||
with _lock:
|
||||
if _manager is None:
|
||||
path = db_path or os.getenv("ALFRED_FEATURES_DB") or str(Path(app.root_path) / "data" / "alfred_features.sqlite3")
|
||||
_manager = FeatureManager(path, ask_hermes, socketio)
|
||||
|
||||
@app.before_request
|
||||
def _capture_render():
|
||||
if request.path == "/api/render" and request.method == "POST":
|
||||
data = request.get_json(silent=True)
|
||||
if isinstance(data, dict):
|
||||
try:
|
||||
_manager.publish_display(data)
|
||||
except Exception:
|
||||
LOG.exception("Unable to save display card")
|
||||
|
||||
@app.get("/api/alfred/tasks")
|
||||
def _alfred_list_tasks():
|
||||
return jsonify({"tasks": _manager.list_tasks()})
|
||||
|
||||
@app.get("/api/alfred/tasks/<task_id>")
|
||||
def _alfred_task_detail(task_id):
|
||||
task = _manager._task(task_id)
|
||||
return (jsonify(task), 200) if task else (jsonify({"error": "Tâche introuvable"}), 404)
|
||||
|
||||
@app.post("/api/alfred/tasks")
|
||||
def _alfred_start_task():
|
||||
if not _local_writer_allowed():
|
||||
return jsonify({"error": "Écriture réservée à localhost; utilisez la voix d’Alfred."}), 403
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
task = _manager.submit(data.get("prompt", ""), "api-local")
|
||||
return jsonify(task), 202
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@app.get("/api/alfred/cards")
|
||||
def _alfred_list_cards():
|
||||
return jsonify({"cards": _manager.list_cards()})
|
||||
|
||||
@app.post("/api/alfred/cards")
|
||||
def _alfred_add_card():
|
||||
if not _local_writer_allowed():
|
||||
return jsonify({"error": "Écriture réservée à localhost"}), 403
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Payload invalide"}), 400
|
||||
title = str(data.get("title") or "Information")[:180]
|
||||
kind = str(data.get("kind") or "notification")[:32]
|
||||
_manager.upsert_card("manual-" + uuid.uuid4().hex[:12], kind=kind, title=title,
|
||||
body=str(data.get("body") or "")[:12000],
|
||||
display=data.get("display"), ttl=int(data.get("ttl", 30)))
|
||||
return jsonify({"ok": True}), 201
|
||||
|
||||
return _manager
|
||||
|
||||
|
||||
def submit(prompt, source="voice"):
|
||||
if _manager is None:
|
||||
raise RuntimeError("Les fonctionnalités Alfred ne sont pas initialisées")
|
||||
return _manager.submit(prompt, source)
|
||||
|
||||
|
||||
def publish_voice_display(display):
|
||||
"""Nonblocking mirror; main renderer and TTS remain authoritative."""
|
||||
if _manager is None:
|
||||
return
|
||||
try:
|
||||
_manager.publish_display(display)
|
||||
except Exception:
|
||||
LOG.exception("Could not publish conversational display card")
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Minuteurs + chronomètres indépendants d'Hermes ; stockage JSON durable.
|
||||
|
||||
Le temps est exprimé à partir de l'horloge UTC, pour survivre à un redémarrage
|
||||
(et continuer à avancer pendant l'arrêt de Flask). Les durées sont limitées
|
||||
pour éviter les mauvaises interprétations des demandes vocales.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_PATH = ROOT / 'data' / 'timers.json'
|
||||
MAX_SECONDS = 30 * 24 * 3600
|
||||
MAX_ACTIVE = 20
|
||||
|
||||
|
||||
class TimerError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class TimerStore:
|
||||
def __init__(self, path: Path, clock=None, on_change=None, on_finished=None):
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self.on_change = on_change or (lambda snapshot: None)
|
||||
self.on_finished = on_finished or (lambda timer: None)
|
||||
self.lock = threading.RLock()
|
||||
self.items: dict[str, dict] = {}
|
||||
self._read()
|
||||
|
||||
def _read(self):
|
||||
if not self.path.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding='utf-8'))
|
||||
if not isinstance(data, list):
|
||||
raise ValueError('Mauvais format JSON')
|
||||
for item in data:
|
||||
if (isinstance(item, dict) and isinstance(item.get('id'), str)
|
||||
and item.get('kind') in ('timer', 'stopwatch')
|
||||
and item.get('state') in ('running', 'paused', 'finished', 'cancelled')):
|
||||
self.items[item['id']] = item
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
raise RuntimeError(f'Impossible de charger {self.path} : {exc}') from exc
|
||||
|
||||
def _persist(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_name(self.path.name + '.tmp.' + secrets.token_hex(3))
|
||||
try:
|
||||
with tmp.open('w', encoding='utf-8') as out:
|
||||
json.dump(list(self.items.values()), out, ensure_ascii=False, indent=2)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, self.path)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _duration(number, *, allow_zero=False):
|
||||
if isinstance(number, bool) or not isinstance(number, (int, float)):
|
||||
raise TimerError('Durée numérique attendue en secondes.')
|
||||
if number < (0 if allow_zero else 1) or number > MAX_SECONDS:
|
||||
raise TimerError('Durée autorisée : de 1 seconde à 30 jours.')
|
||||
return float(number)
|
||||
|
||||
@staticmethod
|
||||
def _label(label, kind):
|
||||
label = ' '.join(str(label or '').split()).strip()
|
||||
if not label:
|
||||
label = 'Minuteur' if kind == 'timer' else 'Chronomètre'
|
||||
if len(label) > 80 or any(ord(x) < 32 for x in label):
|
||||
raise TimerError('Nom trop long ou invalide (80 caractères maximum).')
|
||||
return label
|
||||
|
||||
def _view(self, item, now):
|
||||
duration = item.get('duration')
|
||||
elapsed = float(item.get('elapsed', 0))
|
||||
if item['state'] == 'running':
|
||||
elapsed += max(0.0, now - float(item['started_at']))
|
||||
remaining = max(0.0, duration - elapsed) if duration is not None else None
|
||||
result = {k: item[k] for k in ('id', 'label', 'kind', 'state', 'created_at')}
|
||||
result.update(duration=duration, elapsed=round(elapsed, 2),
|
||||
remaining=round(remaining, 2) if remaining is not None else None)
|
||||
return result
|
||||
|
||||
def snapshot(self, *, include_finished=False):
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
self._expire_locked(now)
|
||||
items = [self._view(x, now) for x in self.items.values()
|
||||
if include_finished or x['state'] in ('running', 'paused')]
|
||||
return sorted(items, key=lambda x: (x['state'] != 'running', x['created_at']))
|
||||
|
||||
def _expire_locked(self, now):
|
||||
finished = []
|
||||
for item in self.items.values():
|
||||
if item['state'] != 'running' or item.get('duration') is None:
|
||||
continue
|
||||
elapsed = item['elapsed'] + max(0.0, now - item['started_at'])
|
||||
if elapsed + 0.005 >= item['duration']:
|
||||
item['elapsed'] = item['duration']
|
||||
item['started_at'] = None
|
||||
item['state'] = 'finished'
|
||||
item['finished_at'] = now
|
||||
finished.append(item.copy())
|
||||
if finished:
|
||||
self._persist()
|
||||
self.on_change(self._unexpired_snapshot(now))
|
||||
for item in finished:
|
||||
self.on_finished(item)
|
||||
|
||||
def _unexpired_snapshot(self, now):
|
||||
return sorted([self._view(x, now) for x in self.items.values()
|
||||
if x['state'] in ('running', 'paused')],
|
||||
key=lambda x: x['created_at'])
|
||||
|
||||
def start(self, kind, seconds=None, label=None):
|
||||
if kind not in ('timer', 'stopwatch'):
|
||||
raise TimerError('Type attendu : timer ou stopwatch.')
|
||||
if kind == 'timer' and seconds is None:
|
||||
raise TimerError('Il faut préciser la durée du minuteur.')
|
||||
if seconds is not None:
|
||||
seconds = self._duration(seconds)
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
self._expire_locked(now)
|
||||
if sum(1 for x in self.items.values() if x['state'] in ('running', 'paused')) >= MAX_ACTIVE:
|
||||
raise TimerError('Maximum de 20 minuteurs ou chronomètres en cours.')
|
||||
ident = secrets.token_hex(5)
|
||||
item = {'id': ident, 'label': self._label(label, kind), 'kind': kind,
|
||||
'duration': seconds, 'elapsed': 0.0,
|
||||
'started_at': now, 'created_at': now, 'state': 'running'}
|
||||
self.items[ident] = item
|
||||
# Conserver au plus 80 éléments historiques.
|
||||
old = sorted((x for x in self.items.values() if x['state'] in ('finished', 'cancelled')),
|
||||
key=lambda x: x['created_at'])
|
||||
for x in old[:-80]:
|
||||
self.items.pop(x['id'], None)
|
||||
self._persist()
|
||||
self.on_change(self._unexpired_snapshot(now))
|
||||
return self._view(item, now)
|
||||
|
||||
def _resolve(self, ident):
|
||||
if not isinstance(ident, str):
|
||||
raise TimerError('Identifiant attendu.')
|
||||
ident = ident.strip()
|
||||
if ident in self.items:
|
||||
return self.items[ident]
|
||||
candidates = [x for x in self.items.values()
|
||||
if x['state'] in ('running', 'paused') and
|
||||
(x['label'].casefold() == ident.casefold() or x['id'].startswith(ident))]
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if not candidates:
|
||||
raise TimerError('Minuteur ou chronomètre introuvable.')
|
||||
raise TimerError('Plusieurs minuteurs portent ce nom : préciser l’identifiant.')
|
||||
|
||||
def action(self, action, ident, seconds=None):
|
||||
if action not in ('pause', 'resume', 'cancel', 'add_time'):
|
||||
raise TimerError('Action inconnue.')
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
self._expire_locked(now)
|
||||
item = self._resolve(ident)
|
||||
if item['state'] in ('cancelled', 'finished'):
|
||||
raise TimerError('Ce minuteur est déjà terminé.')
|
||||
if action == 'pause':
|
||||
if item['state'] != 'running':
|
||||
raise TimerError('Déjà en pause.')
|
||||
item['elapsed'] += max(0, now - item['started_at'])
|
||||
item['started_at'] = None
|
||||
item['state'] = 'paused'
|
||||
elif action == 'resume':
|
||||
if item['state'] != 'paused':
|
||||
raise TimerError('Ce minuteur n’est pas en pause.')
|
||||
item['started_at'] = now
|
||||
item['state'] = 'running'
|
||||
elif action == 'cancel':
|
||||
if item['state'] == 'running':
|
||||
item['elapsed'] += max(0, now - item['started_at'])
|
||||
item['started_at'] = None
|
||||
item['state'] = 'cancelled'
|
||||
else:
|
||||
if item['duration'] is None:
|
||||
raise TimerError('Chronomètre libre : aucune durée maximale à prolonger.')
|
||||
amount = self._duration(seconds)
|
||||
item['duration'] = self._duration(item['duration'] + amount)
|
||||
self._persist()
|
||||
self.on_change(self._unexpired_snapshot(now))
|
||||
return self._view(item, now)
|
||||
|
||||
def cancel_all(self):
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
affected = []
|
||||
for item in self.items.values():
|
||||
if item['state'] not in ('running', 'paused'):
|
||||
continue
|
||||
if item['state'] == 'running':
|
||||
item['elapsed'] += max(0, now - item['started_at'])
|
||||
item['started_at'] = None
|
||||
item['state'] = 'cancelled'
|
||||
affected.append(self._view(item, now))
|
||||
if affected:
|
||||
self._persist()
|
||||
self.on_change(self._unexpired_snapshot(now))
|
||||
return affected
|
||||
|
||||
def tick(self):
|
||||
with self.lock:
|
||||
self._expire_locked(self.clock())
|
||||
|
||||
def run(self, stop_event=None, interval=0.5):
|
||||
stop_event = stop_event or threading.Event()
|
||||
while not stop_event.wait(interval):
|
||||
try:
|
||||
self.tick()
|
||||
except Exception as exc:
|
||||
print('[ALFRED TIMERS] erreur boucle :', repr(exc), flush=True)
|
||||
|
||||
|
||||
def register(app, socketio, speak_async, audio_settings):
|
||||
def changed(items):
|
||||
socketio.emit('alfred_timers_changed', {'items': items})
|
||||
|
||||
def finished(item):
|
||||
payload = {'id': item['id'], 'label': item['label'], 'kind': item['kind']}
|
||||
socketio.emit('alfred_timer_finished', payload)
|
||||
if not audio_settings.speaker_muted():
|
||||
msg = f"Le minuteur {item['label']} est terminé." if item['kind'] == 'timer' else f"Le chronomètre {item['label']} est terminé."
|
||||
try:
|
||||
speak_async(msg)
|
||||
except Exception as exc:
|
||||
print('[ALFRED TIMERS] alerte vocale :', repr(exc), flush=True)
|
||||
|
||||
store = TimerStore(Path(os.getenv('ALFRED_TIMERS_DB', str(DEFAULT_PATH))),
|
||||
on_change=changed, on_finished=finished)
|
||||
bp = Blueprint('alfred_timers', __name__)
|
||||
|
||||
def safe_post():
|
||||
# MCP fonctionne uniquement depuis la boucle locale (aucun accès LAN).
|
||||
if request.remote_addr in ('127.0.0.1', '::1') and request.headers.get('X-Alfred-MCP-Local') == 'timers':
|
||||
return True
|
||||
origin = request.headers.get('Origin')
|
||||
if request.headers.get('X-Alfred-Request') != '1':
|
||||
return False
|
||||
fetch_site = request.headers.get('Sec-Fetch-Site', '').lower()
|
||||
if fetch_site == 'cross-site':
|
||||
return False
|
||||
if not origin:
|
||||
# Chromium peut omettre Origin sur certaines requêtes même origine.
|
||||
return fetch_site == 'same-origin'
|
||||
p = urlsplit(origin)
|
||||
return p.scheme in ('http','https') and p.netloc.lower() == request.host.lower()
|
||||
|
||||
@bp.get('/api/timers')
|
||||
def list_timers():
|
||||
return jsonify({'items': store.snapshot(), 'server_time': store.clock()})
|
||||
|
||||
@bp.post('/api/timers/action')
|
||||
def timers_action():
|
||||
if not safe_post():
|
||||
return jsonify({'error': 'Requête non autorisée (origine ou en-tête MCP invalide).'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'Objet JSON attendu.'}), 400
|
||||
command = data.get('action')
|
||||
try:
|
||||
if command == 'create_timer':
|
||||
item = store.start('timer', data.get('seconds'), data.get('label'))
|
||||
elif command == 'create_stopwatch':
|
||||
item = store.start('stopwatch', data.get('seconds'), data.get('label'))
|
||||
elif command == 'cancel_all':
|
||||
cancelled = store.cancel_all()
|
||||
return jsonify({'ok': True, 'cancelled': cancelled, 'items': store.snapshot()})
|
||||
else:
|
||||
item = store.action(command, data.get('id'), data.get('seconds'))
|
||||
return jsonify({'ok': True, 'item': item, 'items': store.snapshot()})
|
||||
except TimerError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except OSError as exc:
|
||||
print('[ALFRED TIMERS] écriture échouée:', repr(exc), flush=True)
|
||||
return jsonify({'error': 'Stockage des minuteurs indisponible.'}), 503
|
||||
|
||||
app.register_blueprint(bp)
|
||||
threading.Thread(target=store.run, name='alfred-timers', daemon=True).start()
|
||||
return store
|
||||
@@ -302,6 +302,12 @@ def listen_once(stop_event: Optional[threading.Event] = None,
|
||||
|
||||
model_path = os.getenv("VOSK_MODEL_PATH", "")
|
||||
sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000"))
|
||||
# ALFRED_WYOMING_WHISPER_STT_V1: Vosk segments the utterance and
|
||||
# provides UI partials. Wyoming Whisper replaces only its final transcript.
|
||||
stt_engine = os.getenv("STT_ENGINE", "vosk").strip().lower()
|
||||
wyoming_enabled = stt_engine in ("wyoming", "wyoming_whisper", "whisper")
|
||||
wyoming_audio = bytearray()
|
||||
wyoming_max_bytes = max(16000, int(os.getenv("STT_MAX_AUDIO_SECONDS", "30")) * sample_rate * 2)
|
||||
if not model_path or not os.path.exists(model_path):
|
||||
return STTResult(error=f"Modèle Vosk introuvable: {model_path}")
|
||||
try:
|
||||
@@ -311,6 +317,9 @@ def listen_once(stop_event: Optional[threading.Event] = None,
|
||||
return STTResult(error=f"Init Vosk: {exc}")
|
||||
|
||||
def process(data: bytes) -> Optional[str]:
|
||||
# ALFRED_WYOMING_WHISPER_STT_V1: retain the exact PCM heard by Vosk.
|
||||
if wyoming_enabled and len(wyoming_audio) < wyoming_max_bytes:
|
||||
wyoming_audio.extend(data[:wyoming_max_bytes - len(wyoming_audio)])
|
||||
if on_level and data:
|
||||
try:
|
||||
a = np.frombuffer(data, dtype=np.int16).astype(np.float32)
|
||||
@@ -321,6 +330,25 @@ def listen_once(stop_event: Optional[threading.Event] = None,
|
||||
if rec.AcceptWaveform(data):
|
||||
text = (json.loads(rec.Result() or "{}").get("text") or "").strip()
|
||||
if text:
|
||||
if wyoming_enabled and wyoming_audio:
|
||||
# ALFRED_WYOMING_WHISPER_STT_V1: remote final STT, local fallback.
|
||||
try:
|
||||
from services.wyoming_stt import transcribe_pcm
|
||||
whisper_text = transcribe_pcm(
|
||||
bytes(wyoming_audio),
|
||||
host=os.getenv("WYOMING_WHISPER_HOST", "192.168.1.13"),
|
||||
port=int(os.getenv("WYOMING_WHISPER_PORT", "10300")),
|
||||
language=os.getenv("WYOMING_WHISPER_LANGUAGE", "fr"),
|
||||
sample_rate=sample_rate,
|
||||
connect_timeout=float(os.getenv("WYOMING_WHISPER_CONNECT_TIMEOUT", "3")),
|
||||
response_timeout=float(os.getenv("WYOMING_WHISPER_TIMEOUT", "20")),
|
||||
)
|
||||
if whisper_text:
|
||||
print(f"[ALFRED STT] Whisper final : {whisper_text!r} (Vosk : {text!r})", flush=True)
|
||||
return whisper_text
|
||||
print("[ALFRED STT] Whisper réponse vide, secours Vosk", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED STT] Whisper indisponible, secours Vosk : {exc}", flush=True)
|
||||
return text
|
||||
elif on_partial:
|
||||
partial = json.loads(rec.PartialResult() or "{}").get("partial", "")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Minimal Wyoming TCP ASR client for Alfred.
|
||||
|
||||
No external Wyoming Python dependency. Transport: PCM signed 16-bit LE mono,
|
||||
16 kHz by default, following the official transcribe/audio-start/chunk/stop flow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class WyomingSTTError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _event(kind: str, data: Optional[dict] = None, payload: bytes = b"") -> bytes:
|
||||
header = {"type": kind}
|
||||
if data is not None:
|
||||
header["data"] = data
|
||||
if payload:
|
||||
header["payload_length"] = len(payload)
|
||||
return (json.dumps(header, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8") + payload
|
||||
|
||||
|
||||
def _read_event(stream) -> tuple[str, dict]:
|
||||
header_bytes = stream.readline(65537)
|
||||
if not header_bytes:
|
||||
raise WyomingSTTError("Connexion Wyoming fermée sans transcription")
|
||||
if len(header_bytes) > 65536 or not header_bytes.endswith(b"\n"):
|
||||
raise WyomingSTTError("En-tête Wyoming invalide ou trop long")
|
||||
try:
|
||||
header = json.loads(header_bytes)
|
||||
if not isinstance(header, dict):
|
||||
raise ValueError("en-tête non objet")
|
||||
data_length = int(header.get("data_length", 0))
|
||||
payload_length = int(header.get("payload_length", 0))
|
||||
if not 0 <= data_length <= 65536 or not 0 <= payload_length <= 1_048_576:
|
||||
raise ValueError("taille d'événement excessive")
|
||||
data = header.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("données non objet")
|
||||
if data_length:
|
||||
extra_data = stream.read(data_length)
|
||||
if len(extra_data) != data_length:
|
||||
raise WyomingSTTError("Données Wyoming incomplètes")
|
||||
more = json.loads(extra_data)
|
||||
if not isinstance(more, dict):
|
||||
raise ValueError("données additionnelles non objet")
|
||||
data.update(more)
|
||||
if payload_length:
|
||||
payload = stream.read(payload_length)
|
||||
if len(payload) != payload_length:
|
||||
raise WyomingSTTError("Charge utile Wyoming incomplète")
|
||||
return str(header["type"]), data
|
||||
except (KeyError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise WyomingSTTError(f"Message Wyoming incorrect : {exc}") from exc
|
||||
|
||||
|
||||
def transcribe_pcm(
|
||||
pcm: bytes,
|
||||
*,
|
||||
host: str = "192.168.1.13",
|
||||
port: int = 10300,
|
||||
language: str = "fr",
|
||||
sample_rate: int = 16000,
|
||||
connect_timeout: float = 3.0,
|
||||
response_timeout: float = 20.0,
|
||||
) -> str:
|
||||
"""Send recorded S16LE/mono PCM to Wyoming ASR and return the transcript."""
|
||||
if not pcm or len(pcm) % 2:
|
||||
raise WyomingSTTError("Audio PCM vide ou mal aligné")
|
||||
if sample_rate <= 0:
|
||||
raise WyomingSTTError("Fréquence PCM invalide")
|
||||
if not 0 < response_timeout <= 300:
|
||||
raise WyomingSTTError("Délai Wyoming invalide")
|
||||
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=connect_timeout) as sock:
|
||||
sock.settimeout(response_timeout)
|
||||
sock.sendall(_event("transcribe", {"language": language}))
|
||||
audio_meta = {"rate": int(sample_rate), "width": 2, "channels": 1}
|
||||
sock.sendall(_event("audio-start", audio_meta))
|
||||
for offset in range(0, len(pcm), 16000):
|
||||
chunk = pcm[offset : offset + 16000]
|
||||
sock.sendall(_event("audio-chunk", audio_meta, chunk))
|
||||
sock.sendall(_event("audio-stop"))
|
||||
with sock.makefile("rb") as stream:
|
||||
# Some servers send informational events before the transcript.
|
||||
for _ in range(16):
|
||||
kind, data = _read_event(stream)
|
||||
if kind == "transcript":
|
||||
return str(data.get("text") or "").strip()
|
||||
if kind == "error":
|
||||
raise WyomingSTTError(str(data.get("text") or data.get("message") or data))
|
||||
raise WyomingSTTError("Aucune transcription après 16 événements Wyoming")
|
||||
except (OSError, TimeoutError) as exc:
|
||||
raise WyomingSTTError(f"Serveur Whisper {host}:{port} indisponible : {exc}") from exc
|
||||
Reference in New Issue
Block a user