Spaces:
Running
Running
File size: 4,634 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 | """
backend/api/blackboard.py — S-BB: Blackboard condiviso cross-sessione via Upstash Redis REST.
Riusa UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN già configurati
in backend/api/llm_cache.py — zero setup aggiuntivo.
TTL: 600s (10 minuti) — session-scoped.
Endpoint:
POST /api/blackboard/{session_id}/write — scrive una entry
GET /api/blackboard/{session_id}/read — legge tutte le entry
DEL /api/blackboard/{session_id} — pulisce il blackboard
Pattern: i sub-agenti scrivono via frontend in-memory (agentBlackboard.ts);
il backend persiste su Upstash per cross-tab/cross-reload continuity.
"""
import os
import json
import httpx
import asyncio
from fastapi import APIRouter, Depends
from .auth_guard import require_role, AuthRole
from pydantic import BaseModel
router = APIRouter(prefix="/api/blackboard", tags=["blackboard"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
_URL = os.getenv("UPSTASH_REDIS_REST_URL", "")
_TOKEN = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
_TTL = 600 # 10 minuti
class BBEntry(BaseModel):
agentId: str
key: str
value: str
severity: str = "info"
ts: float = 0.0
async def _redis_post(command: list) -> dict | None:
"""Esegue un comando Redis via Upstash REST API."""
if not _URL or not _TOKEN:
return None
try:
async with httpx.AsyncClient(timeout=2.0) as c:
r = await c.post(
_URL,
json=command,
headers={
"Authorization": f"Bearer {_TOKEN}",
"Content-Type": "application/json",
},
)
return r.json() if r.is_success else None
except Exception:
return None
async def _redis_scan(pattern: str) -> list[str]:
"""Scansiona le chiavi Redis con SCAN paginato via POST (Upstash REST API).
P40-H: la versione precedente usava cursor fisso "0" → solo il primo batch
di 100 chiavi veniva letto. Con >100 entry Redis per sessione le chiavi
extra venivano omesse silenziosamente. Fix: loop cursor finché cursor == "0".
FIX G3: la versione GET usava URL-encoding del pattern (: → %3A, * → %2A).
Upstash router path-decodifica %3A → ':' ma il pattern risultante veniva
spezzato alla prima '/' → SCAN trovava 0 chiavi su pattern con ':'.
La versione POST invia il pattern come stringa JSON → nessun encoding → corretto.
"""
all_keys: list[str] = []
cursor = "0"
while True:
result = await _redis_post(["SCAN", cursor, "MATCH", pattern, "COUNT", "100"])
if not result or "result" not in result:
break
scan_result = result["result"]
# Upstash SCAN ritorna [next_cursor, [keys]] o [[next_cursor, [keys]]] (pipeline)
if not isinstance(scan_result, list) or len(scan_result) < 2:
break
cursor = str(scan_result[0])
keys = scan_result[1]
if isinstance(keys, list):
all_keys.extend(keys)
# cursor "0" segnala fine iterazione
if cursor == "0":
break
return all_keys
@router.post("/{session_id}/write")
async def bb_write(session_id: str, entry: BBEntry):
"""Scrive una entry nel blackboard Upstash. Fail-safe: ritorna ok=True anche se Upstash non disponibile."""
rkey = f"bb:{session_id}:{entry.agentId}:{entry.key}"
payload = json.dumps({
"agentId": entry.agentId,
"key": entry.key,
"value": entry.value,
"severity": entry.severity,
"ts": entry.ts,
})
# SET key value EX ttl
await _redis_post(["SET", rkey, payload, "EX", _TTL])
return {"ok": True}
@router.get("/{session_id}/read")
async def bb_read(session_id: str):
"""Legge tutte le entries del blackboard per la sessione."""
keys = await _redis_scan(f"bb:{session_id}:*")
if not keys:
return {"entries": [], "session_id": session_id}
mget = await _redis_post(["MGET"] + keys)
entries = []
if mget and "result" in mget:
for v in mget["result"]:
if v:
try:
entries.append(json.loads(v))
except Exception:
pass
return {"entries": entries, "session_id": session_id}
@router.delete("/{session_id}")
async def bb_clear(session_id: str):
"""Pulisce il blackboard al termine della sessione."""
keys = await _redis_scan(f"bb:{session_id}:*")
if keys:
await _redis_post(["DEL"] + keys)
return {"ok": True, "deleted": len(keys)}
|