Spaces:
Running
Running
| """API private per il cutover browser → backend delle tabelle Supabase sensibili. | |
| Questi endpoint sono destinati esclusivamente alle Pages Functions, che inoltrano | |
| ``X-Internal-Token`` al master B. Nessun client browser riceve una service-role key | |
| o accede direttamente alle tabelle private. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import logging | |
| import math | |
| import re | |
| import time | |
| from datetime import datetime, timedelta, timezone | |
| from typing import Any | |
| from fastapi import APIRouter, Depends, HTTPException, Query | |
| from pydantic import BaseModel, Field, field_validator | |
| from .auth_guard import require_private_state_machine | |
| from .state import get_supabase | |
| _logger = logging.getLogger("agente_ai.api.private_state") | |
| router = APIRouter( | |
| prefix="/api/private-state", | |
| tags=["private-state"], | |
| dependencies=[Depends(require_private_state_machine)], | |
| ) | |
| _RAG_LANGUAGE = "rag_chunk" | |
| _RAG_PREFIX = "__rag_chunk" | |
| _MAX_RAG_CHUNKS = 100 | |
| _MAX_RAG_CONTENT_CHARS = 10_000 | |
| _MAX_EMBEDDING_DIMENSIONS = 4_096 | |
| _MAX_TASK_PAGE = 100 | |
| def _db() -> Any: | |
| """Restituisce il client service-role del backend o un errore non sensibile.""" | |
| client = get_supabase() | |
| if client is None: | |
| raise HTTPException(status_code=503, detail="Archivio privato temporaneamente non disponibile") | |
| return client | |
| async def _call(operation): | |
| """Esegue il client sincrono Supabase senza bloccare l'event loop FastAPI.""" | |
| try: | |
| return await asyncio.to_thread(operation, _db()) | |
| except HTTPException: | |
| raise | |
| except Exception as exc: # Non esporre dettagli backend, query o dati al browser. | |
| _logger.warning("[private-state] database operation failed: %s", type(exc).__name__) | |
| raise HTTPException(status_code=502, detail="Operazione sullo stato privato non riuscita") from exc | |
| def _json_object(value: object) -> dict[str, Any]: | |
| if isinstance(value, dict): | |
| return value | |
| if isinstance(value, str): | |
| try: | |
| parsed = json.loads(value) | |
| return parsed if isinstance(parsed, dict) else {} | |
| except (TypeError, ValueError): | |
| return {} | |
| return {} | |
| def _as_epoch_ms(value: object) -> int: | |
| """Normalizza i valori `timestamptz` PostgREST in millisecondi browser-safe.""" | |
| if isinstance(value, (int, float)): | |
| return int(value) | |
| if isinstance(value, datetime): | |
| moment = value | |
| elif isinstance(value, str): | |
| try: | |
| moment = datetime.fromisoformat(value.replace("Z", "+00:00")) | |
| except ValueError: | |
| return 0 | |
| else: | |
| return 0 | |
| if moment.tzinfo is None: | |
| moment = moment.replace(tzinfo=timezone.utc) | |
| return int(moment.timestamp() * 1_000) | |
| def _finite_vector(values: list[float]) -> list[float]: | |
| if not values or len(values) > _MAX_EMBEDDING_DIMENSIONS: | |
| raise ValueError("dimensione embedding non valida") | |
| if any(not math.isfinite(value) for value in values): | |
| raise ValueError("embedding contiene valori non finiti") | |
| return values | |
| class TelegramConfigIn(BaseModel): | |
| bot_token: str = Field(min_length=1, max_length=512) | |
| chat_id: str = Field(min_length=1, max_length=128) | |
| class SkillPatternIn(BaseModel): | |
| id: str = Field(min_length=1, max_length=128) | |
| task_signature: str = Field(min_length=1, max_length=200) | |
| tool_sequence: list[str] = Field(min_length=1, max_length=8) | |
| success_count: int = Field(ge=0, le=1_000_000) | |
| total_count: int = Field(ge=1, le=1_000_000) | |
| last_used: int = Field(ge=0) | |
| confidence: float = Field(ge=0, le=1) | |
| def validate_tools(cls, tools: list[str]) -> list[str]: | |
| clean = [tool.strip()[:120] for tool in tools if isinstance(tool, str) and tool.strip()] | |
| if not clean: | |
| raise ValueError("tool_sequence non valida") | |
| return clean | |
| class RagChunkIn(BaseModel): | |
| id: str = Field(min_length=1, max_length=128) | |
| path: str = Field(min_length=1, max_length=256) | |
| content: str = Field(min_length=51, max_length=_MAX_RAG_CONTENT_CHARS) | |
| embedding: list[float] | None = Field(default=None, max_length=_MAX_EMBEDDING_DIMENSIONS) | |
| def validate_embedding(cls, value: list[float] | None) -> list[float] | None: | |
| return _finite_vector(value) if value is not None else None | |
| class RagIndexIn(BaseModel): | |
| file_id: str = Field(min_length=1, max_length=40) | |
| chunks: list[RagChunkIn] = Field(min_length=1, max_length=_MAX_RAG_CHUNKS) | |
| def validate_file_id(cls, value: str) -> str: | |
| if not re.fullmatch(r"[a-z0-9_]+", value): | |
| raise ValueError("file_id non valido") | |
| return value | |
| class RagSearchIn(BaseModel): | |
| query_embedding: list[float] | None = Field(default=None, max_length=_MAX_EMBEDDING_DIMENSIONS) | |
| query: str = Field(default="", max_length=2_000) | |
| similarity_threshold: float = Field(default=0.22, ge=-1, le=1) | |
| match_count: int = Field(default=5, ge=1, le=10) | |
| def validate_query_embedding(cls, value: list[float] | None) -> list[float] | None: | |
| return _finite_vector(value) if value is not None else None | |
| def validate_query(cls, value: str) -> str: | |
| if not value.strip() and value == "": | |
| return "" | |
| return value.strip() | |
| async def list_sessions( | |
| max_age_ms: int = Query(default=300_000, ge=10_000, le=3_600_000), | |
| limit: int = Query(default=100, ge=1, le=200), | |
| ) -> dict[str, object]: | |
| """Restituisce esclusivamente i metadati delle sessioni agente ancora vive.""" | |
| cutoff = (datetime.now(timezone.utc) - timedelta(milliseconds=max_age_ms)).isoformat() | |
| def operation(client: Any): | |
| return client.table("agent_tasks").select("task_id,context,updated_at") \ | |
| .eq("status", "__session__").gte("updated_at", cutoff) \ | |
| .order("updated_at", desc=True).limit(limit).execute() | |
| result = await _call(operation) | |
| sessions: list[dict[str, object]] = [] | |
| for row in result.data or []: | |
| context = _json_object(row.get("context")) | |
| session_id = str(context.get("sessionId") or row.get("task_id") or "").strip() | |
| if not session_id: | |
| continue | |
| claimed = context.get("claimedFiles") | |
| sessions.append({ | |
| "session_id": session_id, | |
| "session_name": str(context.get("sessionName") or session_id)[:160], | |
| "sprint": str(context["sprint"])[:120] if context.get("sprint") else None, | |
| "claimed_files": [str(item)[:300] for item in claimed[:100]] if isinstance(claimed, list) else [], | |
| "last_heartbeat": _as_epoch_ms(context.get("lastHeartbeat")) or _as_epoch_ms(row.get("updated_at")), | |
| "current_task": str(context["currentTask"])[:500] if context.get("currentTask") else None, | |
| }) | |
| return {"sessions": sessions} | |
| async def list_tasks( | |
| limit: int = Query(default=20, ge=1, le=_MAX_TASK_PAGE), | |
| offset: int = Query(default=0, ge=0, le=10_000), | |
| status: str | None = Query(default=None, max_length=64), | |
| ) -> dict[str, object]: | |
| """Lista task non di configurazione per il TMA, con conteggi per stato.""" | |
| normalized_status = status.strip().upper() if status else "" | |
| def operation(client: Any): | |
| query = client.table("agent_tasks").select("task_id,goal,status,updated_at") \ | |
| .neq("status", "__session__").neq("status", "__config__") | |
| if normalized_status: | |
| query = query.eq("status", normalized_status) | |
| page = query.order("updated_at", desc=True).range(offset, offset + limit - 1).execute() | |
| all_statuses = client.table("agent_tasks").select("status") \ | |
| .neq("status", "__session__").neq("status", "__config__").limit(2_000).execute() | |
| return page, all_statuses | |
| page, all_statuses = await _call(operation) | |
| counts: dict[str, int] = {} | |
| for row in all_statuses.data or []: | |
| key = str(row.get("status") or "UNKNOWN").upper() | |
| counts[key] = counts.get(key, 0) + 1 | |
| tasks = [ | |
| { | |
| "task_id": str(row.get("task_id") or ""), | |
| "goal": str(row.get("goal") or "")[:1_000], | |
| "status": str(row.get("status") or "UNKNOWN"), | |
| "updated_at": _as_epoch_ms(row.get("updated_at")), | |
| } | |
| for row in page.data or [] | |
| ] | |
| return {"tasks": tasks, "counts": counts, "offset": offset, "limit": limit} | |
| async def save_telegram_config(payload: TelegramConfigIn) -> dict[str, bool]: | |
| """Salva la configurazione Telegram nel record privato del daemon.""" | |
| now = datetime.now(timezone.utc).isoformat() | |
| row = { | |
| "task_id": "__telegram_config__", | |
| "goal": "__telegram_config__", | |
| "status": "__config__", | |
| "max_steps": 0, | |
| "context": json.dumps({"botToken": payload.bot_token, "chatId": payload.chat_id}), | |
| "updated_at": now, | |
| } | |
| def operation(client: Any): | |
| return client.table("agent_tasks").upsert(row, on_conflict="task_id").execute() | |
| await _call(operation) | |
| return {"ok": True} | |
| async def list_skill_patterns(limit: int = Query(default=100, ge=1, le=100)) -> dict[str, object]: | |
| """Carica pattern cloud per il merge con lo storage locale Dexie.""" | |
| def operation(client: Any): | |
| return client.table("skill_patterns").select( | |
| "id,task_signature,tool_sequence,success_count,total_count,last_used,confidence" | |
| ).order("confidence", desc=True).limit(limit).execute() | |
| result = await _call(operation) | |
| return {"patterns": result.data or []} | |
| async def upsert_skill_pattern(pattern_id: str, payload: SkillPatternIn) -> dict[str, bool]: | |
| """Sincronizza un pattern già validato dal layer locale del browser.""" | |
| if pattern_id != payload.id: | |
| raise HTTPException(status_code=400, detail="Identificatore pattern non coerente") | |
| row = payload.model_dump() | |
| def operation(client: Any): | |
| return client.table("skill_patterns").upsert(row, on_conflict="id").execute() | |
| await _call(operation) | |
| return {"ok": True} | |
| async def index_rag(payload: RagIndexIn) -> dict[str, int]: | |
| """Sostituisce i chunk RAG di un file senza esporre `vfs_files` al browser.""" | |
| prefix = f"{_RAG_PREFIX}/{payload.file_id}/" | |
| rows: list[dict[str, object]] = [] | |
| now = int(time.time() * 1000) | |
| for chunk in payload.chunks: | |
| if not chunk.path.startswith(prefix) or not chunk.id.startswith(f"rag-{payload.file_id}-"): | |
| raise HTTPException(status_code=400, detail="Chunk RAG non coerente con il file") | |
| row: dict[str, object] = { | |
| "id": chunk.id, | |
| "user_id": "default", | |
| "path": chunk.path, | |
| "content": chunk.content, | |
| "language": _RAG_LANGUAGE, | |
| "created_at": now, | |
| "updated_at": now, | |
| } | |
| if chunk.embedding: | |
| row["embedding"] = "[" + ",".join(str(value) for value in chunk.embedding) + "]" | |
| row["embedding_vec"] = chunk.embedding | |
| rows.append(row) | |
| def operation(client: Any): | |
| client.table("vfs_files").delete().eq("language", _RAG_LANGUAGE).like("path", prefix + "%").execute() | |
| return client.table("vfs_files").upsert(rows).execute() | |
| await _call(operation) | |
| return {"indexed": len(rows)} | |
| def _parse_vector(value: object) -> list[float] | None: | |
| if isinstance(value, list): | |
| try: | |
| return [float(item) for item in value] | |
| except (TypeError, ValueError): | |
| return None | |
| if isinstance(value, str): | |
| try: | |
| parsed = json.loads(value) | |
| return [float(item) for item in parsed] if isinstance(parsed, list) else None | |
| except (TypeError, ValueError): | |
| return None | |
| return None | |
| def _cosine_similarity(left: list[float], right: list[float]) -> float: | |
| if len(left) != len(right) or not left: | |
| return 0.0 | |
| numerator = sum(a * b for a, b in zip(left, right)) | |
| left_norm = math.sqrt(sum(a * a for a in left)) | |
| right_norm = math.sqrt(sum(b * b for b in right)) | |
| return numerator / (left_norm * right_norm) if left_norm and right_norm else 0.0 | |
| async def search_rag(payload: RagSearchIn) -> dict[str, object]: | |
| """Ricerca pgvector con fallback server-side alla similarità coseno in memoria.""" | |
| def operation(client: Any): | |
| if payload.query_embedding: | |
| try: | |
| rpc = client.rpc("match_rag_chunks", { | |
| "query_embedding": payload.query_embedding, | |
| "similarity_threshold": payload.similarity_threshold, | |
| "match_count": payload.match_count, | |
| }).execute() | |
| if isinstance(rpc.data, list): | |
| return {"mode": "pgvector", "rows": rpc.data} | |
| except Exception as exc: | |
| _logger.info("[private-state] rag RPC unavailable, using fallback: %s", type(exc).__name__) | |
| result = client.table("vfs_files").select("content,embedding,path") \ | |
| .eq("language", _RAG_LANGUAGE).limit(300).execute() | |
| query_words = {word for word in re.split(r"\W+", payload.query.lower()) if len(word) > 3} | |
| scored: list[dict[str, object]] = [] | |
| for row in result.data or []: | |
| content = str(row.get("content") or "") | |
| embedding = _parse_vector(row.get("embedding")) | |
| if payload.query_embedding and embedding: | |
| score = _cosine_similarity(payload.query_embedding, embedding) | |
| else: | |
| lower = content.lower() | |
| hits = sum(1 for word in query_words if word in lower) | |
| score = hits / len(query_words) if query_words else 0.0 | |
| if score >= payload.similarity_threshold: | |
| scored.append({ | |
| "content": content, | |
| "similarity": score, | |
| "path": str(row.get("path") or ""), | |
| }) | |
| scored.sort(key=lambda item: float(item["similarity"]), reverse=True) | |
| return {"mode": "cosine_fallback", "rows": scored[:payload.match_count]} | |
| result = await _call(operation) | |
| rows = [ | |
| { | |
| "content": str(row.get("content") or ""), | |
| "similarity": float(row.get("similarity") or 0), | |
| "path": str(row.get("path") or ""), | |
| } | |
| for row in result["rows"] | |
| if isinstance(row, dict) | |
| ] | |
| return {"results": rows, "mode": result["mode"]} | |