"""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/") 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")