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)