Files
rpa_vision_v3/core/grounding/infigui_server.py
Dom 5ea4960e65
Some checks failed
tests / Lint (ruff + black) (push) Successful in 1m50s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
backup: snapshot post-démo GHT 2026-05-19
Backup état complet après enregistrement vidéo démo de bout en bout.
À utiliser comme point de référence pour la consolidation post-démo.

Changements majeurs de la session 18-19 mai :
- AIVA-URGENCE : page autonome avec preset URL + auto-focus chain
- Workflow Demo_urgence_3_db : merge linux_db + steps AIVA + pause humaine NoMachine
- Bypass LLM (static_result / static_text) dans replay_engine
  pour démos déterministes sans appel Ollama
- Fix api_stream:3013 — replay_paused au premier polling /next
- dag_execute : lift duration_ms vers top-level pour wait runtime
- NPM bypass auth /aiva-urgence/ via location ^~ (proxy_host/10.conf hors git)
- scripts/cancel-replays.sh — workaround Stop VWB qui ne purge pas la queue

Anchors visuels (468) forcés dans le commit pour garantir restorabilité.
DB workflows actuelle + ~12 .bak DB de la journée incluses.

Sujets identifiés pour consolidation post-démo (TODO) :
1. Bug VWB recapture anchor ne régénère pas le PNG
2. Léa client accumule état mémoire (restart périodique requis)
3. Stop VWB ne purge pas la queue serveur (lien manquant vers /replay/cancel)
4. Bug coord client mss tronqué 2560x60 → mapping Y cassé
5. delay_before/delay_after ignorés au runtime (fix partiel duration_ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:55:06 +02:00

291 lines
11 KiB
Python

#!/usr/bin/env python3
"""
core/grounding/infigui_server.py — Service grounding persistant InfiGUI
Charge InfiGUI-G1-3B en 4-bit une fois (~2.4 GB VRAM), puis sert les requêtes
de grounding via un Unix socket. Évite le coût de chargement (~10s) à chaque
appel que paie le subprocess one-shot.
Protocole (length-prefixed JSON) :
Requête : [4 octets uint32 BE = longueur] + payload JSON UTF-8
Réponse : [4 octets uint32 BE = longueur] + payload JSON UTF-8
Opérations supportées (champ "op", défaut "ground") :
- "ping"{"ok": true, "vram_gb": float, "uptime_s": float}
- "ground"{"x": int|None, "y": int|None, "confidence": float, ...}
- "shutdown"{"ok": true} puis arrêt propre du serveur
Le payload "ground" reprend exactement le format de l'ancien worker one-shot :
{"target": str, "description": str, "image_path": str, "anchor_image_path": str}
Les images restent passées via fichiers (/tmp/...) — pas de bytes sur le socket.
Lancement (manuel ou via systemd user unit rpa-grounding.service) :
cd ~/ai/rpa_vision_v3
.venv/bin/python -m core.grounding.infigui_server
Variables d'environnement :
RPA_GROUNDING_SOCKET chemin du socket (défaut: $XDG_RUNTIME_DIR/rpa-grounding.sock
sinon /tmp/rpa-grounding.sock)
RPA_GROUNDING_BACKLOG taille listen backlog (défaut: 4)
"""
from __future__ import annotations
import json
import os
import signal
import socket
import struct
import sys
import threading
import time
import traceback
from typing import Any, Dict, Optional
# Réutilise la logique de chargement et d'inférence du worker one-shot.
# load_model() et infer() sont conçus pour être appelés en process indépendant ;
# on les appelle ici dans un process unique de longue durée.
from core.grounding.infigui_worker import infer, load_model
# ── Configuration ────────────────────────────────────────────────────────
def _default_socket_path() -> str:
# /run/rpa/ est la convention "production" (RuntimeDirectory=rpa partagé
# entre les services systemd). Cohérent avec ui_tars_grounder._default_socket_path.
if os.path.isdir("/run/rpa"):
return "/run/rpa/grounding.sock"
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
if runtime_dir and os.path.isdir(runtime_dir):
return os.path.join(runtime_dir, "rpa-grounding.sock")
return "/tmp/rpa-grounding.sock"
SOCKET_PATH = os.environ.get("RPA_GROUNDING_SOCKET") or _default_socket_path()
LISTEN_BACKLOG = int(os.environ.get("RPA_GROUNDING_BACKLOG", "4"))
# Limite raisonnable pour un payload JSON (la requête contient juste des chemins
# et du texte court — 4 MB suffit largement pour parer un client buggé).
MAX_PAYLOAD_BYTES = 4 * 1024 * 1024
# ── Protocole length-prefixed ────────────────────────────────────────────
def _recv_exact(conn: socket.socket, n: int) -> Optional[bytes]:
"""Lit exactement n octets ou retourne None si la connexion ferme avant."""
chunks = []
remaining = n
while remaining > 0:
chunk = conn.recv(remaining)
if not chunk:
return None
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def recv_message(conn: socket.socket) -> Optional[Dict[str, Any]]:
header = _recv_exact(conn, 4)
if header is None:
return None
(length,) = struct.unpack(">I", header)
if length == 0 or length > MAX_PAYLOAD_BYTES:
raise ValueError(f"Longueur payload invalide: {length}")
payload = _recv_exact(conn, length)
if payload is None:
return None
return json.loads(payload.decode("utf-8"))
def send_message(conn: socket.socket, obj: Dict[str, Any]) -> None:
payload = json.dumps(obj, ensure_ascii=False).encode("utf-8")
conn.sendall(struct.pack(">I", len(payload)) + payload)
# ── Serveur ──────────────────────────────────────────────────────────────
class InfiGUIServer:
"""Daemon qui sert les requêtes de grounding sur un Unix socket."""
def __init__(self, socket_path: str = SOCKET_PATH):
self.socket_path = socket_path
self._server_sock: Optional[socket.socket] = None
self._stop = threading.Event()
# CUDA n'est pas thread-safe sur le même modèle ; sérialise les inférences.
self._infer_lock = threading.Lock()
self._model = None
self._processor = None
self._start_time = time.time()
self._request_count = 0
# ── Lifecycle ────────────────────────────────────────────────────────
def start(self) -> None:
# 1. Charger le modèle AVANT d'ouvrir le socket : si le chargement échoue,
# on n'expose pas un endpoint à moitié fonctionnel aux clients.
print(f"[infigui-server] Chargement InfiGUI-G1-3B...")
self._model, self._processor = load_model()
# 2. Ouvrir le Unix socket (suppression d'un éventuel ancien socket fantôme)
try:
os.unlink(self.socket_path)
except FileNotFoundError:
pass
self._server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._server_sock.bind(self.socket_path)
# Permissions : owner-only (rw-------) pour éviter les autres utilisateurs
os.chmod(self.socket_path, 0o600)
self._server_sock.listen(LISTEN_BACKLOG)
# Petit timeout sur accept pour que la boucle réagisse aux signaux
self._server_sock.settimeout(1.0)
print(f"[infigui-server] Écoute sur {self.socket_path}")
# 3. Signaux d'arrêt propre
signal.signal(signal.SIGTERM, self._on_signal)
signal.signal(signal.SIGINT, self._on_signal)
# 4. Boucle accept
try:
while not self._stop.is_set():
try:
conn, _ = self._server_sock.accept()
except socket.timeout:
continue
except OSError:
if self._stop.is_set():
break
raise
# Une requête à la fois (CUDA non thread-safe). On gère néanmoins
# la connexion dans un thread pour pouvoir lire/écrire sans bloquer
# l'accept boucle quand le client traîne — l'inférence elle-même
# est sérialisée par self._infer_lock.
threading.Thread(
target=self._handle_client,
args=(conn,),
daemon=True,
).start()
finally:
self._cleanup()
def _on_signal(self, signum, _frame) -> None:
print(f"[infigui-server] Signal {signum} reçu, arrêt...")
self._stop.set()
# Casse un éventuel accept() bloqué
try:
if self._server_sock is not None:
self._server_sock.close()
except OSError:
pass
def _cleanup(self) -> None:
try:
if self._server_sock is not None:
self._server_sock.close()
except OSError:
pass
try:
os.unlink(self.socket_path)
except FileNotFoundError:
pass
print(f"[infigui-server] Arrêté ({self._request_count} requêtes traitées)")
# ── Gestion d'une connexion ──────────────────────────────────────────
def _handle_client(self, conn: socket.socket) -> None:
try:
# Une connexion = N requêtes (keep-alive). Le client peut envoyer
# plusieurs grounds successifs sans repayer le coût TCP/socket.
while not self._stop.is_set():
try:
req = recv_message(conn)
except (ValueError, json.JSONDecodeError) as e:
self._safe_send(conn, {"error": f"requête invalide: {e}"})
return
if req is None:
return # client a fermé proprement
op = req.get("op", "ground")
if op == "ping":
self._safe_send(conn, self._do_ping())
elif op == "shutdown":
self._safe_send(conn, {"ok": True})
self._stop.set()
try:
if self._server_sock is not None:
self._server_sock.close()
except OSError:
pass
return
elif op == "ground":
resp = self._do_ground(req)
self._safe_send(conn, resp)
else:
self._safe_send(conn, {"error": f"op inconnue: {op}"})
except Exception as e:
traceback.print_exc()
self._safe_send(conn, {"error": str(e)})
finally:
try:
conn.close()
except OSError:
pass
def _safe_send(self, conn: socket.socket, obj: Dict[str, Any]) -> None:
try:
send_message(conn, obj)
except OSError:
# Client parti ; pas de quoi paniquer
pass
# ── Opérations ───────────────────────────────────────────────────────
def _do_ping(self) -> Dict[str, Any]:
try:
import torch
vram_gb = round(torch.cuda.memory_allocated() / 1e9, 2) if torch.cuda.is_available() else 0.0
except Exception:
vram_gb = 0.0
return {
"ok": True,
"vram_gb": vram_gb,
"uptime_s": round(time.time() - self._start_time, 1),
"requests": self._request_count,
}
def _do_ground(self, req: Dict[str, Any]) -> Dict[str, Any]:
with self._infer_lock:
self._request_count += 1
try:
return infer(self._model, self._processor, req)
except Exception as e:
traceback.print_exc()
return {
"x": None,
"y": None,
"error": str(e),
}
def main() -> int:
server = InfiGUIServer()
try:
server.start()
except KeyboardInterrupt:
pass
except Exception as e:
print(f"[infigui-server] Erreur fatale: {e}")
traceback.print_exc()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())