481 lines
11 KiB
Python
481 lines
11 KiB
Python
|
|
"""Pont Alfred -> Hermes avec session persistante."""
|
|
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
|
|
# --------------------------------------------------
|
|
# CONFIGURATION
|
|
# --------------------------------------------------
|
|
|
|
DEFAULT_IDENTITY_FILE = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "config"
|
|
/ "identity.md"
|
|
)
|
|
|
|
DEFAULT_SESSION_FILE = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "config"
|
|
/ "hermes_session.json"
|
|
)
|
|
|
|
_REQUEST_LOCK = threading.Lock()
|
|
|
|
_ID_PATTERN = re.compile(
|
|
r"^[A-Za-z0-9_:.\-]{1,128}$"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------
|
|
# IDENTITÉ D'ALFRED
|
|
# --------------------------------------------------
|
|
|
|
def _build_prompt(question: str) -> str:
|
|
"""Send only the real user utterance. Personality is configured in Hermes SOUL.md.
|
|
|
|
The previous identity.md wrapper caused absent/unknown-tool decisions in
|
|
both Spotify and Home Assistant. Never inject it into each user turn.
|
|
"""
|
|
return str(question or '').strip()
|
|
|
|
|
|
def _toolsets_for(question: str) -> str | None:
|
|
"""Conservative domain routing to avoid huge MCP schemas on simple turns.
|
|
|
|
Cross-domain, ambiguous, or generic requests retain Hermes' full toolset.
|
|
Set HERMES_TOOLSET_ROUTING=off for instant rollback without code changes.
|
|
"""
|
|
if os.getenv('HERMES_TOOLSET_ROUTING', 'on').lower() not in ('on','1','true','yes'):
|
|
return None
|
|
q = str(question or '').casefold()
|
|
domains = {
|
|
'spotify': (r'\bspotify\b|\bplaylist\b|\bmusique\b|\bmorceau\b|\bchanson\b|\balbum\b|\bspotify connect\b'),
|
|
'homeassistant': (r'\blumi[eè]res?\b|\blampes?\b|\bvolets?\b|\bdomotique\b|\bthermostat\b|\bchauffage\b|\bprises?\b|\bhome assistant\b|\ballume\b|\b[eé]teins?\b'),
|
|
'face_recognition': (r'\bface_recognition\b|\bidentify_people\b|\breconnaissance faciale\b'), # DONA_V7_RELEASE
|
|
'proxmox': (r'\bproxmox\b|\bcluster proxmox\b'), # DONA_V8_DYNAMIC
|
|
'wazuh': (r'\bwazuh\b'),
|
|
}
|
|
import re as _re
|
|
matching = [domain for domain, pattern in domains.items() if _re.search(pattern, q)]
|
|
# Combined or potentially compound queries keep full access to every MCP.
|
|
if len(matching) != 1:
|
|
return None
|
|
if _re.search(r'\b(et aussi|en m[eê]me temps|puis v[eé]rifie|compare avec|cherche sur|envoie|par mail|gmail|calendrier|agenda|comfyui|document|paperless)\b', q):
|
|
return None
|
|
return {'spotify':'spotify', 'homeassistant':'homeassistant,home_assistant', 'face_recognition':'face_recognition', 'proxmox':'proxmox', 'wazuh':'wazuh'}[matching[0]]
|
|
|
|
|
|
# --------------------------------------------------
|
|
# FICHIER DE SESSION
|
|
# --------------------------------------------------
|
|
|
|
def _session_file() -> Path:
|
|
|
|
return Path(
|
|
os.getenv(
|
|
"HERMES_SESSION_FILE",
|
|
str(DEFAULT_SESSION_FILE)
|
|
)
|
|
).expanduser()
|
|
|
|
|
|
def _read_session(path: Path) -> str | None:
|
|
|
|
if not path.exists():
|
|
return None
|
|
|
|
try:
|
|
obj = json.loads(
|
|
path.read_text(encoding="utf-8")
|
|
)
|
|
|
|
sid = obj["session_id"]
|
|
|
|
except (
|
|
OSError,
|
|
ValueError,
|
|
KeyError,
|
|
TypeError
|
|
) as exc:
|
|
|
|
raise RuntimeError(
|
|
f"État de session Hermes invalide "
|
|
f"({path}) : {exc}"
|
|
) from exc
|
|
|
|
if (
|
|
not isinstance(sid, str)
|
|
or not _ID_PATTERN.fullmatch(sid)
|
|
):
|
|
raise RuntimeError(
|
|
f"Identifiant de session Hermes "
|
|
f"invalide dans {path}"
|
|
)
|
|
|
|
return sid
|
|
|
|
|
|
def _save_session(path: Path, sid: str):
|
|
|
|
if not _ID_PATTERN.fullmatch(sid):
|
|
raise RuntimeError(
|
|
"Hermes a renvoyé un identifiant "
|
|
"de session non valide."
|
|
)
|
|
|
|
path.parent.mkdir(
|
|
parents=True,
|
|
exist_ok=True
|
|
)
|
|
|
|
temp_path = None
|
|
|
|
try:
|
|
fd, temp_name = tempfile.mkstemp(
|
|
prefix=".hermes_session_",
|
|
suffix=".tmp",
|
|
dir=path.parent
|
|
)
|
|
|
|
temp_path = Path(temp_name)
|
|
|
|
with os.fdopen(
|
|
fd,
|
|
"w",
|
|
encoding="utf-8"
|
|
) as handle:
|
|
|
|
json.dump(
|
|
{"session_id": sid},
|
|
handle,
|
|
indent=2
|
|
)
|
|
|
|
handle.write("\n")
|
|
|
|
os.replace(temp_path, path)
|
|
|
|
finally:
|
|
if temp_path is not None:
|
|
temp_path.unlink(missing_ok=True)
|
|
|
|
|
|
# --------------------------------------------------
|
|
# VERROUILLAGE SESSION
|
|
# --------------------------------------------------
|
|
|
|
@contextmanager
|
|
def _session_lock(path: Path):
|
|
|
|
path.parent.mkdir(
|
|
parents=True,
|
|
exist_ok=True
|
|
)
|
|
|
|
lock_path = path.with_name(
|
|
path.name + ".lock"
|
|
)
|
|
|
|
fd = os.open(
|
|
lock_path,
|
|
os.O_CREAT | os.O_RDWR,
|
|
0o600
|
|
)
|
|
|
|
with os.fdopen(fd, "w") as handle:
|
|
|
|
fcntl.flock(
|
|
handle.fileno(),
|
|
fcntl.LOCK_EX
|
|
)
|
|
|
|
try:
|
|
yield
|
|
|
|
finally:
|
|
fcntl.flock(
|
|
handle.fileno(),
|
|
fcntl.LOCK_UN
|
|
)
|
|
|
|
|
|
# --------------------------------------------------
|
|
# CONFIGURATION HERMES
|
|
# --------------------------------------------------
|
|
|
|
def _hermes_binary() -> str:
|
|
|
|
return os.getenv(
|
|
"HERMES_CLI",
|
|
"/root/.local/bin/hermes"
|
|
)
|
|
|
|
|
|
def _hermes_timeout() -> float:
|
|
|
|
return max(
|
|
10.0,
|
|
float(
|
|
os.getenv("HERMES_TIMEOUT", "180")
|
|
)
|
|
)
|
|
|
|
|
|
# --------------------------------------------------
|
|
# ANCIEN MODE SANS SESSION
|
|
# --------------------------------------------------
|
|
|
|
def _oneshot_legacy(prompt: str) -> str:
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
_hermes_binary(),
|
|
"--oneshot",
|
|
prompt
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_hermes_timeout(),
|
|
check=True
|
|
)
|
|
|
|
return result.stdout.strip()
|
|
|
|
except subprocess.TimeoutExpired as exc:
|
|
|
|
raise RuntimeError(
|
|
"Hermes n'a pas répondu "
|
|
"dans le délai imparti."
|
|
) from exc
|
|
|
|
except subprocess.CalledProcessError as exc:
|
|
|
|
details = (
|
|
exc.stderr
|
|
or exc.stdout
|
|
or "aucun détail"
|
|
).strip()
|
|
|
|
raise RuntimeError(
|
|
f"Hermes a échoué "
|
|
f"(code {exc.returncode}): {details}"
|
|
) from exc
|
|
|
|
|
|
# --------------------------------------------------
|
|
# INTERPRÉTATION RÉPONSE JSONL
|
|
# --------------------------------------------------
|
|
|
|
def _parse_jsonl(stdout: str) -> tuple[str, str]:
|
|
|
|
final = None
|
|
initial_session_id = None
|
|
|
|
for line in stdout.splitlines():
|
|
|
|
if not line.strip():
|
|
continue
|
|
|
|
try:
|
|
event = json.loads(line)
|
|
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
if not isinstance(event, dict):
|
|
continue
|
|
|
|
if (
|
|
event.get("type") == "system"
|
|
and event.get("subtype") == "init"
|
|
):
|
|
initial_session_id = event.get(
|
|
"session_id"
|
|
)
|
|
|
|
if event.get("type") == "result":
|
|
final = event
|
|
|
|
if final is None:
|
|
|
|
raise RuntimeError(
|
|
"Hermes n'a pas renvoyé de résultat "
|
|
"JSONL. Vérifie la prise en charge "
|
|
"de --format stream-json."
|
|
)
|
|
|
|
if final.get("exit_code", 0) != 0:
|
|
|
|
raise RuntimeError(
|
|
f"Hermes : "
|
|
f"{final.get('error') or 'échec'}"
|
|
)
|
|
|
|
sid = (
|
|
final.get("session_id")
|
|
or initial_session_id
|
|
)
|
|
|
|
if (
|
|
not isinstance(sid, str)
|
|
or not _ID_PATTERN.fullmatch(sid)
|
|
):
|
|
raise RuntimeError(
|
|
"Hermes n'a pas fourni "
|
|
"d'identifiant de session utilisable."
|
|
)
|
|
|
|
answer = final.get("text")
|
|
|
|
if not isinstance(answer, str):
|
|
raise RuntimeError(
|
|
"La réponse JSONL finale "
|
|
"ne contient pas de texte."
|
|
)
|
|
|
|
return answer.strip(), sid
|
|
|
|
|
|
# --------------------------------------------------
|
|
# APPEL HERMES AVEC REPRISE DE SESSION
|
|
# --------------------------------------------------
|
|
|
|
def _call_hermes(
|
|
prompt: str,
|
|
session_id: str | None
|
|
) -> tuple[str, str]:
|
|
|
|
cmd = [
|
|
_hermes_binary(),
|
|
"chat",
|
|
"--oneshot",
|
|
"--query-file",
|
|
"-",
|
|
"--format",
|
|
"stream-json",
|
|
"--source",
|
|
"cli"
|
|
]
|
|
|
|
route = _toolsets_for(prompt)
|
|
if route and not session_id:
|
|
cmd.extend(["--toolsets", route])
|
|
|
|
if session_id:
|
|
cmd.extend([
|
|
"--resume",
|
|
session_id
|
|
])
|
|
|
|
try:
|
|
process = subprocess.run(
|
|
cmd,
|
|
input=prompt,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_hermes_timeout(),
|
|
check=False
|
|
)
|
|
|
|
except subprocess.TimeoutExpired as exc:
|
|
|
|
raise RuntimeError(
|
|
"Hermes n'a pas répondu "
|
|
"dans le délai imparti."
|
|
) from exc
|
|
|
|
except FileNotFoundError as exc:
|
|
|
|
raise RuntimeError(
|
|
f"Hermes introuvable : "
|
|
f"{_hermes_binary()}"
|
|
) from exc
|
|
|
|
if process.returncode != 0:
|
|
|
|
msg = (
|
|
process.stderr
|
|
or process.stdout
|
|
or "aucun détail"
|
|
).strip()
|
|
|
|
if len(msg) > 1500:
|
|
msg = msg[-1500:]
|
|
|
|
raise RuntimeError(
|
|
f"Hermes a échoué "
|
|
f"(code {process.returncode}) : "
|
|
f"{msg}"
|
|
)
|
|
|
|
return _parse_jsonl(process.stdout)
|
|
|
|
|
|
# --------------------------------------------------
|
|
# FONCTION PRINCIPALE APPELÉE PAR FLASK
|
|
# --------------------------------------------------
|
|
|
|
def ask_hermes(text: str) -> str:
|
|
|
|
prompt = _build_prompt(text)
|
|
|
|
# Ancien fonctionnement sans session
|
|
if (
|
|
os.getenv(
|
|
"HERMES_SESSION_MODE",
|
|
"persistent"
|
|
).lower() == "legacy"
|
|
):
|
|
return _oneshot_legacy(prompt)
|
|
|
|
base_path = _session_file()
|
|
route = _toolsets_for(prompt)
|
|
|
|
if route:
|
|
safe_route = re.sub(
|
|
r"[^a-z0-9]+", "_", route.lower()
|
|
).strip("_")
|
|
path = base_path.with_name(
|
|
f"{base_path.stem}.{safe_route}{base_path.suffix}"
|
|
)
|
|
else:
|
|
path = base_path
|
|
|
|
# Empêche les appels concurrents
|
|
with _REQUEST_LOCK, _session_lock(path):
|
|
|
|
# Recherche la session précédente
|
|
current_sid = _read_session(path)
|
|
|
|
# Envoie la question à Hermes
|
|
answer, new_sid = _call_hermes(
|
|
prompt,
|
|
current_sid
|
|
)
|
|
|
|
# Sauvegarde l'identifiant
|
|
_save_session(
|
|
path,
|
|
new_sid
|
|
)
|
|
|
|
print(
|
|
f"[ALFRED] Session Hermes : "
|
|
f"{new_sid} "
|
|
f"({'reprise' if current_sid else 'création'})",
|
|
flush=True
|
|
)
|
|
|
|
return answer
|