Spaces:
Running
Running
File size: 16,898 Bytes
28a08e7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 | """
skill_tracker.py β GAP-SKILL-SYNC: Session-scoped adaptive tool success/failure tracker.
GAP-SUP1: Supabase persistence β skill stats sopravvivono ai riavvii del backend.
Design originale:
- In-memory Dict[session_id, Dict[tool_name, SkillStats]] β zero DB dep, zero latency
- SkillStats: success_count, fail_count, last_used, total_latency_ms
- record() sincrono β GIL-safe in CPython, asyncio single-threaded
- get_sorted_fallbacks() β Wilson score lower bound (95% CI) per robustezza su n piccoli
- get_stats() β JSON-serializable per /api/agent/skill-stats endpoint
- clear_session() β cleanup opzionale fine task (evita memory leak su run lunghissimi)
GAP-SUP1 (Supabase persistence):
- _supabase_upsert(): httpx POST β PostgREST /rest/v1/skill_stats (upsert conflict)
- _supabase_load_session(): httpx GET β ripristina sessione precedente al boot
- record() fire-and-forget ogni _SYNC_EVERY_N chiamate per tool/sessione
- load_session_from_cloud(): chiamato da unified_loop al boot sessione
- Fallback silente se SUPABASE_URL/SUPABASE_ANON_KEY assenti β comportamento invariato
- Timeout conservativo 8s β mai blocca il loop principale
- Schema SQL: backend/migrations/gap1_skill_stats.sql
Integrazione:
- unified_loop.py: record() dopo ogni executor.run_tool() β registra successo/fallimento
- unified_loop.py: load_session_from_cloud() al boot sessione (se Supabase abilitato)
- api/agent.py: GET /api/agent/skill-stats/{session_id} per merge Dexie frontend
Singleton: get_skill_tracker() restituisce sempre lo stesso SkillTracker globale.
"""
from __future__ import annotations
import asyncio
import math
import os
import time
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
import httpx
_logger = logging.getLogger("agente_ai.skill_tracker")
# βββ Supabase config (GAP-SUP1) βββββββββββββββββββββββββββββββββββββββββββββββ
_SUPA_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
_SUPA_KEY = os.getenv("SUPABASE_ANON_KEY", "")
_SUPA_ENABLED = bool(_SUPA_URL and _SUPA_KEY)
_SUPA_TABLE = "skill_stats" # tabella PostgREST β vedi gap1_skill_stats.sql
_SYNC_EVERY_N = 5 # upsert ogni N record() per tool/sessione (throttle)
if _SUPA_ENABLED:
_logger.info("[skill_tracker] Supabase persistence ABILITATA β %s/rest/v1/%s", _SUPA_URL, _SUPA_TABLE)
else:
_logger.debug("[skill_tracker] Supabase non configurato β solo in-memory")
# βββ SkillStats βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@dataclass
class SkillStats:
success_count: int = 0
fail_count: int = 0
last_used: float = 0.0
total_latency_ms: float = 0.0
@property
def total_count(self) -> int:
return self.success_count + self.fail_count
@property
def success_rate(self) -> float:
if not self.total_count:
return 1.0 # ottimismo iniziale β tool mai usato
return self.success_count / self.total_count
@property
def avg_latency_ms(self) -> float:
if not self.total_count:
return 0.0
return self.total_latency_ms / self.total_count
def wilson_score(self) -> float:
"""Wilson score lower bound (95% CI).
Bilanciamento statisticamente robusto tra success rate e confidenza.
Esempio: tool 1/1 (score β 0.21) vs tool 10/11 (score β 0.68) β
il secondo viene preferito anche se il primo ha 100% raw rate.
Usato da get_sorted_fallbacks() per ordinamento adattivo.
"""
n = self.total_count
if n == 0:
return 0.5 # prior neutro su tool mai usati in questa sessione
p = self.success_count / n
z = 1.96 # 95% confidence interval
num = p + z * z / (2 * n) - z * math.sqrt((p * (1 - p) + z * z / (4 * n)) / n)
den = 1 + z * z / n
return num / den
def to_supabase_row(self, session_id: str, tool_name: str) -> dict:
"""Serializza per upsert PostgREST."""
return {
"session_id": session_id,
"tool_name": tool_name,
"success_count": self.success_count,
"fail_count": self.fail_count,
"last_used": self.last_used,
"total_latency_ms": self.total_latency_ms,
}
@classmethod
def from_supabase_row(cls, row: dict) -> "SkillStats":
"""Deserializza da riga PostgREST."""
return cls(
success_count = int(row.get("success_count", 0)),
fail_count = int(row.get("fail_count", 0)),
last_used = float(row.get("last_used", 0.0)),
total_latency_ms = float(row.get("total_latency_ms", 0.0)),
)
# βββ Supabase helpers (GAP-SUP1) ββββββββββββββββββββββββββββββββββββββββββββββ
async def _supabase_upsert(session_id: str, tool_name: str, stats: SkillStats) -> None:
"""Fire-and-forget: upsert riga skill_stats su Supabase (PostgREST).
Fallback silente su qualsiasi errore β mai blocca il loop principale.
Timeout 8s conservativo.
"""
if not _SUPA_ENABLED:
return
row = stats.to_supabase_row(session_id, tool_name)
try:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.post(
f"{_SUPA_URL}/rest/v1/{_SUPA_TABLE}",
json=row,
headers={
"apikey": _SUPA_KEY,
"Authorization": f"Bearer {_SUPA_KEY}",
"Content-Type": "application/json",
"Prefer": "resolution=merge-duplicates,return=minimal",
},
)
if resp.status_code not in (200, 201, 204):
_logger.debug(
"[skill_tracker] supabase upsert %s: HTTP %d %s",
tool_name[:20], resp.status_code, resp.text[:120],
)
except Exception as exc: # noqa: BLE001
_logger.debug("[skill_tracker] supabase upsert silenced: %s", type(exc).__name__)
async def _supabase_load_session(session_id: str) -> dict[str, SkillStats]:
"""Carica tutti i tool stats di una sessione da Supabase.
Ritorna dict vuoto su qualsiasi errore (fallback silente).
Chiamato da load_session_from_cloud() al boot sessione.
"""
if not _SUPA_ENABLED:
return {}
try:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(
f"{_SUPA_URL}/rest/v1/{_SUPA_TABLE}",
params={"session_id": f"eq.{session_id}", "select": "*"},
headers={
"apikey": _SUPA_KEY,
"Authorization": f"Bearer {_SUPA_KEY}",
},
)
if resp.status_code != 200:
_logger.debug(
"[skill_tracker] supabase load %s: HTTP %d",
session_id[:12], resp.status_code,
)
return {}
rows: list[dict] = resp.json()
loaded = {
row["tool_name"]: SkillStats.from_supabase_row(row)
for row in rows
if "tool_name" in row
}
if loaded:
_logger.info(
"[skill_tracker] GAP-SUP1: ripristinati %d tool stats per sessione %s",
len(loaded), session_id[:12],
)
return loaded
except Exception as exc: # noqa: BLE001
_logger.debug("[skill_tracker] supabase load silenced: %s", type(exc).__name__)
return {}
# βββ SkillTracker βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class SkillTracker:
"""Singleton session-scoped tracker: impara quali tool funzionano per ogni sessione.
GAP-SUP1: i dati persistono su Supabase e vengono ripristinati al boot sessione.
"""
def __init__(self) -> None:
self._sessions: dict[str, dict[str, SkillStats]] = defaultdict(
lambda: defaultdict(SkillStats)
)
# Contatori per throttle upsert (session_id β tool_name β count_since_last_sync)
self._sync_counters: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
# ββ Write βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def record(
self,
session_id: str,
tool_name: str,
success: bool,
latency_ms: float = 0.0,
) -> None:
"""Registra il risultato di una chiamata tool (sincrono, GIL-safe).
Chiamato da unified_loop.py dopo ogni executor.run_tool().
GAP-SUP1: fire-and-forget upsert Supabase ogni _SYNC_EVERY_N chiamate.
"""
s = self._sessions[session_id][tool_name]
if success:
s.success_count += 1
else:
s.fail_count += 1
s.last_used = time.monotonic()
s.total_latency_ms += latency_ms
# GAP-SUP1: sync throttled β ogni _SYNC_EVERY_N record per questo tool/sessione
if _SUPA_ENABLED:
self._sync_counters[session_id][tool_name] += 1
if self._sync_counters[session_id][tool_name] >= _SYNC_EVERY_N:
self._sync_counters[session_id][tool_name] = 0
try:
loop = asyncio.get_running_loop()
loop.create_task(
_supabase_upsert(session_id, tool_name, s),
name=f"skill_sync_{tool_name[:20]}",
)
except RuntimeError:
pass # no running loop (test context) β silente
# ββ Cloud bootstrap (GAP-SUP1) ββββββββββββββββββββββββββββββββββββββββββββ
async def load_session_from_cloud(self, session_id: str) -> int:
"""Ripristina stats precedenti da Supabase per la sessione (chiamare al boot task).
Merge con in-memory: somma i contatori (in-memory Γ¨ vuoto al boot, ma sicuro).
Ritorna numero di tool ripristinati (0 se Supabase non configurato).
Idempotente: chiamate multiple sommano i dati β chiamare una sola volta per sessione.
"""
loaded = await _supabase_load_session(session_id)
if not loaded:
return 0
session = self._sessions[session_id]
for tool_name, cloud_stats in loaded.items():
mem = session[tool_name]
# Merge additivo β in-memory Γ¨ tipicamente vuoto al boot
mem.success_count += cloud_stats.success_count
mem.fail_count += cloud_stats.fail_count
mem.total_latency_ms += cloud_stats.total_latency_ms
# last_used: prendi il piΓΉ recente
if cloud_stats.last_used > mem.last_used:
mem.last_used = cloud_stats.last_used
return len(loaded)
async def flush_session_to_cloud(self, session_id: str) -> int:
"""Forza upsert di tutti i tool di una sessione su Supabase (chiamare a fine task).
Ritorna numero di tool sincronizzati. Fallback silente su errori.
"""
if not _SUPA_ENABLED:
return 0
session = self._sessions.get(session_id, {})
tasks = [
_supabase_upsert(session_id, tool_name, stats)
for tool_name, stats in session.items()
]
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
_logger.info(
"[skill_tracker] GAP-SUP1: flush %d tool stats per sessione %s",
len(tasks), session_id[:12],
)
return len(tasks)
# ββ Read / routing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def get_sorted_fallbacks(
self,
session_id: str,
candidates: list[str],
) -> list[str]:
"""Riordina i candidati tool per Wilson score decrescente.
Tool mai usati in questa sessione β prior neutro 0.5, non penalizzati.
Uso tipico: reordina la lista fallback prima di provarli.
"""
session = self._sessions.get(session_id, {})
return sorted(
candidates,
key=lambda t: session[t].wilson_score() if t in session else 0.5,
reverse=True,
)
def get_stats(self, session_id: str) -> dict[str, dict]:
"""Statistiche JSON-serializable per una sessione (ordinato per Wilson score desc)."""
session = self._sessions.get(session_id, {})
return {
tool: {
"success_count": s.success_count,
"fail_count": s.fail_count,
"total_count": s.total_count,
"success_rate": round(s.success_rate, 3),
"wilson_score": round(s.wilson_score(), 3),
"avg_latency_ms": round(s.avg_latency_ms, 1),
"last_used": round(s.last_used, 3),
}
for tool, s in sorted(
session.items(),
key=lambda kv: kv[1].wilson_score(),
reverse=True,
)
}
def get_all_sessions(self) -> dict[str, Any]:
"""Debug: panoramica tutte le sessioni attive."""
return {
sid: {
"tool_count": len(tools),
"total_calls": sum(s.total_count for s in tools.values()),
"tools": list(tools.keys()),
}
for sid, tools in self._sessions.items()
}
def clear_session(self, session_id: str) -> None:
"""Libera memoria per una sessione terminata."""
removed = self._sessions.pop(session_id, None)
self._sync_counters.pop(session_id, None)
if removed is not None:
_logger.debug(
"[skill_tracker] cleared session %s (%d tools tracked)",
session_id[:12],
len(removed),
)
# βββ Singleton globale ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_skill_tracker = SkillTracker()
def get_skill_tracker() -> SkillTracker:
"""Restituisce il singleton SkillTracker. Thread-safe in CPython (GIL)."""
return _skill_tracker
# βββ P17-B2: FastAPI router per sync frontend βββββββββββββββββββββββββββββββββ
# Endpoint REST che permette al frontend (Dexie) di leggere e scrivere skill stats.
# Montato in backend/main.py tramite _on_startup() se importato.
try:
from fastapi import APIRouter as _APIRouter
from pydantic import BaseModel as _BM
skill_router = _APIRouter(prefix="/api/agent", tags=["skill-tracker"])
class _SkillRecordBody(_BM):
success: bool
latency_ms: float = 0.0
error_msg: str = ""
@skill_router.get("/skill-stats/{session_id}")
async def api_get_skill_stats(session_id: str):
"""Restituisce stats tool per sessione β per merge con Dexie frontend."""
return get_skill_tracker().get_stats(session_id)
@skill_router.post("/skill-record/{session_id}/{tool_name}")
async def api_record_skill(session_id: str, tool_name: str, body: _SkillRecordBody):
"""Registra un risultato tool dal frontend (es. tool chiamato via browser)."""
get_skill_tracker().record(
session_id, tool_name,
success=body.success,
latency_ms=body.latency_ms,
)
return {"ok": True, "session_id": session_id, "tool": tool_name}
@skill_router.delete("/skill-stats/{session_id}")
async def api_clear_skill_session(session_id: str):
"""Pulisce la sessione skill tracker alla fine del task."""
get_skill_tracker().clear_session(session_id)
return {"ok": True}
except ImportError:
skill_router = None # type: ignore[assignment] # FastAPI non disponibile (unit test env)
|