330 lines
14 KiB
Python
330 lines
14 KiB
Python
"""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")
|