Files
Alfredv2/alfred/app/services/alfred_features.py
T

330 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")