Spaces:
Running
Running
fix: sync registry+fallback fixes da A (registry.py line692, LLM_TIMEOUT, _ANALYTICAL_VERBS_RE)
Browse files- .env.example +73 -14
- agents/audit_semantic_l2.py +303 -0
- agents/unified_loop_fallback.py +81 -0
- api/TELEGRAM_MODULES.md +172 -0
- api/agent_task_routes.py +4 -0
- api/cache_endpoints.py +164 -0
- api/cache_manager.py +400 -0
- api/database_router.py +303 -0
- api/global_state_sync_optimized.py +343 -0
- api/global_state_sync_with_oracle.py +355 -0
- api/job_queue.py +69 -57
- api/load_balancer.py +104 -0
- api/oci_gateway_manager.py +63 -0
- api/oracle_endpoints.py +189 -0
- api/oracle_node_manager.py +301 -0
- api/prewarm.py +49 -0
- api/telegram_callbacks.py +358 -0
- api/telegram_cmd_ai.py +1154 -0
- api/telegram_cmd_monitoring.py +457 -0
- api/telegram_keyboards.py +122 -0
- api/telegram_tg_client.py +264 -0
- api/telegram_webhook.py +0 -0
- memory/distiller.py +96 -41
- memory/evolutionary.py +93 -0
- memory/manager.py +5 -0
- sql/cache_schema.sql +158 -0
- tests/test_telegram_commands.py +367 -0
- tests/test_telegram_imports.py +162 -0
- tools/registry.py +13 -12
- tools/registry_fs.py +3 -3
.env.example
CHANGED
|
@@ -28,13 +28,36 @@ VAULT_ADMIN_TOKEN=
|
|
| 28 |
INTERNAL_TOKEN=
|
| 29 |
NOTIFY_TOKEN=
|
| 30 |
|
| 31 |
-
#
|
| 32 |
-
#
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
# ββ HuggingFace βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
# huggingface.co β Settings β Access Tokens
|
|
@@ -134,6 +157,19 @@ NOTION_TOKEN=
|
|
| 134 |
CHROMA_DB_DIR=/app/backend/.data/chroma
|
| 135 |
SQLITE_DB_PATH=/app/backend/.data/agent.sqlite
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
# ββ Opzionali βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
# Qdrant (vector DB cloud)
|
| 139 |
QDRANT_URL=
|
|
@@ -155,13 +191,36 @@ PEXELS_API_KEY=
|
|
| 155 |
PIXABAY_API_KEY=
|
| 156 |
|
| 157 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 158 |
-
# ββ
|
| 159 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 160 |
VITE_BACKEND_URL_2=https://baida00-ai-backend-collab.hf.space # HF Space B β backend collab B (chat/AI)
|
| 161 |
VITE_EXEC_BACKEND_URL_2= # Railway B URL (exec/PTY) β formato: https://xxx.up.railway.app β DA CONFIGURARE
|
| 162 |
-
E2B_API_KEY_2= # e2b.dev β account B (100h/mese)
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
INTERNAL_TOKEN=
|
| 29 |
NOTIFY_TOKEN=
|
| 30 |
|
| 31 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
# ββ CLUSTER SUPABASE β Separazione Workload Ottimale ββββββββββββββββββββββββββββββββββ
|
| 33 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
# Architettura:
|
| 35 |
+
# A: Analytics/Cache/Read-Heavy (reporting, dashboard, cache distribuito)
|
| 36 |
+
# B: Sync/State/Transazioni (stato globale, sincronizzazione cluster β PRIMARY)
|
| 37 |
+
# C: Memory/RAG/Embeddings (backend memoria, vector search, skill index)
|
| 38 |
+
# D: Audit/Logging/Compliance (event log, audit trail, compliance records)
|
| 39 |
+
#
|
| 40 |
+
# SUPABASE_URL (primario) β punta a B (Sync/State)
|
| 41 |
+
# SUPABASE_KEY (primario) β punta a B
|
| 42 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
|
| 44 |
+
# ββ Nodo B: PRIMARY β Stato Globale e Sincronizzazione ββββββββββββββββββββββββββββββββββ
|
| 45 |
+
# Workload: transazioni critiche, stato cluster, sincronizzazione, write-heavy
|
| 46 |
+
SUPABASE_URL=https://sluvxtpbtxevrcooaqou.supabase.co
|
| 47 |
+
SUPABASE_KEY=sb_secret_OHtVs6Vw4b5UTbm_QUnh4A_zqx9dadY
|
| 48 |
+
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNsdXZ4dHBidHhldnJjb29hcW91Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjA3Mjk5MiwiZXhwIjoyMDk3NjQ4OTkyfQ.WS4cpsvtO-FGkXukCqpFF6qXfQb38VUs5rtiLwa0khk
|
| 49 |
+
SUPABASE_ANON_KEY=sb_secret_OHtVs6Vw4b5UTbm_QUnh4A_zqx9dadY
|
| 50 |
+
DATABASE_URL=postgresql://postgres:[password]@db.sluvxtpbtxevrcooaqou.supabase.co:5432/postgres
|
| 51 |
+
|
| 52 |
+
# ββ Nodo A: Analytics/Cache β Read Replica e Cache Distribuito ββββββββββββββββββββββββββ
|
| 53 |
+
# Workload: reporting, dashboard, cache, read-heavy queries (non-critical)
|
| 54 |
+
# Nota: Configurare con URL reale se disponibile; altrimenti fallback a B
|
| 55 |
+
SUPABASE_URL_A=https://xxxx.supabase.co
|
| 56 |
+
SUPABASE_KEY_A=
|
| 57 |
+
SUPABASE_SERVICE_ROLE_KEY_A=
|
| 58 |
+
# Enable cache layer su A (riduce carico su B)
|
| 59 |
+
SUPABASE_CACHE_ENABLED=true
|
| 60 |
+
SUPABASE_CACHE_TTL=3600
|
| 61 |
|
| 62 |
# ββ HuggingFace βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
# huggingface.co β Settings β Access Tokens
|
|
|
|
| 157 |
CHROMA_DB_DIR=/app/backend/.data/chroma
|
| 158 |
SQLITE_DB_PATH=/app/backend/.data/agent.sqlite
|
| 159 |
|
| 160 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 161 |
+
# ββ Nodo E: Oracle Cloud (OCI) β Compute/AI Workload ββββββββββββββββββββββββββββββββββ
|
| 162 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 163 |
+
# Workload: Carichi di calcolo intensivi, esecuzione script Python complessi, task di background a lunga durata, hosting modelli AI locali.
|
| 164 |
+
ORACLE_NODE_E_ENABLED=true
|
| 165 |
+
ORACLE_NODE_E_IP=80.225.89.217
|
| 166 |
+
ORACLE_NODE_E_USER=opc
|
| 167 |
+
ORACLE_NODE_E_SSH_KEY_PATH=/app/secrets/oracle_private_key.pem # Path alla chiave privata SSH (gestita come segreto)
|
| 168 |
+
ORACLE_NODE_E_ROLE="Compute/AI Workload"
|
| 169 |
+
ORACLE_NODE_E_REGION="Italy Northwest (Milan)"
|
| 170 |
+
ORACLE_NODE_E_OCPUS=4
|
| 171 |
+
ORACLE_NODE_E_RAM_GB=24
|
| 172 |
+
|
| 173 |
# ββ Opzionali βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 174 |
# Qdrant (vector DB cloud)
|
| 175 |
QDRANT_URL=
|
|
|
|
| 191 |
PIXABAY_API_KEY=
|
| 192 |
|
| 193 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 194 |
+
# ββ HF Spaces e Railway β Configurazione Infra βββββ
|
| 195 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 196 |
VITE_BACKEND_URL_2=https://baida00-ai-backend-collab.hf.space # HF Space B β backend collab B (chat/AI)
|
| 197 |
VITE_EXEC_BACKEND_URL_2= # Railway B URL (exec/PTY) β formato: https://xxx.up.railway.app β DA CONFIGURARE
|
| 198 |
+
E2B_API_KEY_2=2f548a79-9210-4037-87fa-4490a6a6b8fe # e2b.dev β account B (100h/mese)
|
| 199 |
+
|
| 200 |
+
# Groq account B (14.400 req/giorno) β load balancing con A
|
| 201 |
+
GROQ_API_KEY_3=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
|
| 202 |
+
VITE_GROQ_API_KEY_3=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
|
| 203 |
+
|
| 204 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 205 |
+
# ββ Nodo C: Memory/RAG/Embeddings β Backend Memoria e Vector Search ββββββββββββββββββββββ
|
| 206 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 207 |
+
# Workload: embeddings, vector search, RAG index, skill memory, conversazioni
|
| 208 |
+
SUPABASE_URL_C=https://rsiphwzlokhsxnkcvzos.supabase.co
|
| 209 |
+
SUPABASE_ANON_KEY_C=sb_secret_11jeLuJnZBA1IKCAm1ZjiA_QxIyU5u6
|
| 210 |
+
SUPABASE_SERVICE_ROLE_KEY_C=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJzaXBod3psb2toc3hua2N2em9zIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjI1OTEyOSwiZXhwIjoyMDk3ODM1MTI5fQ.LxO8ltt-t8SpHf-ZIi1ogcEtcQcPvW1Vyu2W14i1zzM
|
| 211 |
+
E2B_API_KEY_C=2f548a79-9210-4037-87fa-4490a6a6b8fe
|
| 212 |
+
# RAG/Vector DB β Qdrant per embeddings (opzionale, altrimenti usa Supabase C)
|
| 213 |
+
QDRANT_URL_C=
|
| 214 |
+
QDRANT_API_KEY_C=
|
| 215 |
+
|
| 216 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 217 |
+
# ββ Nodo D: Audit/Logging/Compliance β Event Log e Compliance Records ββββββββββββββββββ
|
| 218 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 219 |
+
# Workload: audit trail, event logging, compliance records, immutable logs
|
| 220 |
+
SUPABASE_URL_D=https://cyotadpvgaxfdwaovstv.supabase.co
|
| 221 |
+
SUPABASE_ANON_KEY_D=sb_secret_aZGjt7d8KWsvIm5-JP4qrg_XrNPBAZE
|
| 222 |
+
SUPABASE_SERVICE_ROLE_KEY_D=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImN5b3RhZHB2Z2F4ZmR3YW92c3R2Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjY1NjQ1OSwiZXhwIjoyMDk4MjMyNDU5fQ.FuLweX2H6RKJmC7PbXdctmgzVIcqspq9FSxwFzFwB9o
|
| 223 |
+
E2B_API_KEY_D=e2b_9d242366288d5e9e0bb267f31cccc5d49298348c
|
| 224 |
+
# Audit log β immutable append-only (opzionale)
|
| 225 |
+
AUDIT_LOG_ENABLED=true
|
| 226 |
+
AUDIT_LOG_RETENTION_DAYS=90
|
agents/audit_semantic_l2.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
audit_semantic_l2.py β S303: Audit Semantico L2 (Critico Senior) su Nodo D.
|
| 3 |
+
|
| 4 |
+
L1 (goal_verifier.py) valida se la risposta *aderisce* al goal.
|
| 5 |
+
L2 (questo file) verifica la *coerenza logica interna* dell'output:
|
| 6 |
+
- Nessuna contraddizione auto-referenziale
|
| 7 |
+
- Claim verificabili non inventati (anti-hallucination guard)
|
| 8 |
+
- Completezza rispetto ai sotto-obiettivi esplicitati nel goal
|
| 9 |
+
- Stato outcome: PASS / FAIL / UNKNOWN β mai forzare PASS
|
| 10 |
+
|
| 11 |
+
Integrazione: chiamato DOPO GoalVerifier L1 in unified_loop_fallback.py.
|
| 12 |
+
Se L1 = FAIL β L2 non viene invocato (risparmio token).
|
| 13 |
+
Se L1 = PASS o UNKNOWN β L2 aggiunge una seconda garanzia semantica.
|
| 14 |
+
|
| 15 |
+
Output: AuditL2Result (dataclass) con status, issues[], confidence, repair_hint.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import asyncio
|
| 20 |
+
import json
|
| 21 |
+
import logging
|
| 22 |
+
import re
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from enum import Enum
|
| 25 |
+
from typing import Any, Optional
|
| 26 |
+
|
| 27 |
+
_logger = logging.getLogger("agents.audit_l2")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class AuditStatus(str, Enum):
|
| 31 |
+
PASS = "PASS"
|
| 32 |
+
FAIL = "FAIL"
|
| 33 |
+
UNKNOWN = "UNKNOWN"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class AuditL2Result:
|
| 38 |
+
status: AuditStatus
|
| 39 |
+
confidence: float = 0.0 # 0.0 β 1.0
|
| 40 |
+
issues: list[str] = field(default_factory=list)
|
| 41 |
+
repair_hint: str = ""
|
| 42 |
+
engine: str = "heuristic" # "heuristic" | "llm"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ββ Pattern anti-hallucination ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
# Claim di azioni che l'agente NON puΓ² eseguire da solo senza tool confirmation.
|
| 47 |
+
# Copre IT / EN / ES / FR β le 4 lingue attive nel cluster.
|
| 48 |
+
_HALLUCINATION_PATTERNS: list[tuple[re.Pattern, str]] = [
|
| 49 |
+
# Deploy / publish
|
| 50 |
+
(re.compile(
|
| 51 |
+
r"\b(ho deployato|ho pubblicato|ho pushato|ho committato|ho inviato|"
|
| 52 |
+
r"ho caricato|ho aggiornato il server|ho rilasciato|"
|
| 53 |
+
r"i deployed|i pushed|i committed|i published|i sent|i uploaded|i released|"
|
| 54 |
+
r"he desplegado|he publicado|he enviado|he subido|he lanzado|"
|
| 55 |
+
r"j'ai dΓ©ployΓ©|j'ai publiΓ©|j'ai envoyΓ©|j'ai poussΓ©|j'ai mis en ligne)\b",
|
| 56 |
+
re.I),
|
| 57 |
+
"claim di deploy/push/send non verificato da tool"),
|
| 58 |
+
|
| 59 |
+
# Stato esterno live
|
| 60 |
+
(re.compile(
|
| 61 |
+
r"\b(il sito Γ¨ live|the site is live|ora funziona|it now works|"
|
| 62 |
+
r"Γ¨ online|is online|Γ¨ andato live|went live|"
|
| 63 |
+
r"the app is running|l'app Γ¨ in esecuzione|"
|
| 64 |
+
r"el sitio estΓ‘ en vivo|el sistema funciona ahora|"
|
| 65 |
+
r"le site est en ligne|l'application fonctionne maintenant)\b",
|
| 66 |
+
re.I),
|
| 67 |
+
"claim di stato esterno non verificabile"),
|
| 68 |
+
|
| 69 |
+
# Assunzioni sull'utente
|
| 70 |
+
(re.compile(
|
| 71 |
+
r"\b(l'utente ha|the user has|hai giΓ |you already|"
|
| 72 |
+
r"your database (is|has)|il tuo database (Γ¨|ha)|"
|
| 73 |
+
r"el usuario ya|vous avez dΓ©jΓ )\b",
|
| 74 |
+
re.I),
|
| 75 |
+
"assunzione su stato dell'utente non verificabile"),
|
| 76 |
+
|
| 77 |
+
# Test / CI passati senza prova
|
| 78 |
+
(re.compile(
|
| 79 |
+
r"\b(tutti i test passano|all tests pass|i test sono verdi|tests are green|"
|
| 80 |
+
r"la CI Γ¨ verde|CI is green|build successful|build riuscita|"
|
| 81 |
+
r"todos los tests pasan|tous les tests passent)\b",
|
| 82 |
+
re.I),
|
| 83 |
+
"claim di test/CI passati senza esecuzione verificata"),
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
# ββ Pattern contraddizione interna ββββββββββββββββββββββββββββββββββββββββββββ
|
| 87 |
+
_CONTRADICTION_PAIRS: list[tuple[str, str]] = [
|
| 88 |
+
("errore", "nessun errore"),
|
| 89 |
+
("error", "no error"),
|
| 90 |
+
("fallito", "completato con successo"),
|
| 91 |
+
("failed", "completed successfully"),
|
| 92 |
+
("non trovato", "trovato correttamente"),
|
| 93 |
+
("not found", "found correctly"),
|
| 94 |
+
("timeout", "risposta ricevuta"),
|
| 95 |
+
("timeout", "response received"),
|
| 96 |
+
("impossibile", "funziona"),
|
| 97 |
+
("impossible", "works"),
|
| 98 |
+
("non funziona", "funziona correttamente"),
|
| 99 |
+
("doesn't work", "works correctly"),
|
| 100 |
+
("eccezione", "nessuna eccezione"),
|
| 101 |
+
("exception", "no exception"),
|
| 102 |
+
("crash", "stabile"),
|
| 103 |
+
("crash", "stable"),
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _check_hallucinations(text: str) -> list[str]:
|
| 108 |
+
issues = []
|
| 109 |
+
for pattern, label in _HALLUCINATION_PATTERNS:
|
| 110 |
+
if pattern.search(text):
|
| 111 |
+
issues.append(f"Possibile hallucination: {label}")
|
| 112 |
+
return issues
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _check_contradictions(text: str) -> list[str]:
|
| 116 |
+
issues = []
|
| 117 |
+
text_lower = text.lower()
|
| 118 |
+
for a, b in _CONTRADICTION_PAIRS:
|
| 119 |
+
if a in text_lower and b in text_lower:
|
| 120 |
+
issues.append(f"Contraddizione interna: '{a}' e '{b}' co-presenti")
|
| 121 |
+
return issues
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _check_completeness(goal: str, answer: str) -> list[str]:
|
| 125 |
+
"""
|
| 126 |
+
Controlla che i sotto-obiettivi espliciti del goal (identificati da liste numerate
|
| 127 |
+
o bullet points) siano almeno menzionati nella risposta.
|
| 128 |
+
"""
|
| 129 |
+
issues = []
|
| 130 |
+
sub_goals = re.findall(
|
| 131 |
+
r"(?:^|\n)\s*(?:\d+\.|[-*β’])\s+(.+?)(?:\n|$)", goal
|
| 132 |
+
)
|
| 133 |
+
if not sub_goals:
|
| 134 |
+
return []
|
| 135 |
+
answer_lower = answer.lower()
|
| 136 |
+
missing = []
|
| 137 |
+
for sg in sub_goals[:8]: # max 8 sotto-obiettivi
|
| 138 |
+
words = [w for w in sg.lower().split() if len(w) > 4][:4]
|
| 139 |
+
if words and sum(1 for w in words if w in answer_lower) < max(1, len(words) // 2):
|
| 140 |
+
missing.append(sg.strip()[:60])
|
| 141 |
+
if missing:
|
| 142 |
+
issues.append(f"Sotto-obiettivi non indirizzati: {missing[:3]}")
|
| 143 |
+
return issues
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _heuristic_audit(goal: str, answer: str) -> AuditL2Result:
|
| 147 |
+
"""Audit euristico: pattern matching su testo, senza LLM."""
|
| 148 |
+
issues: list[str] = []
|
| 149 |
+
issues.extend(_check_hallucinations(answer))
|
| 150 |
+
issues.extend(_check_contradictions(answer))
|
| 151 |
+
issues.extend(_check_completeness(goal, answer))
|
| 152 |
+
|
| 153 |
+
if not issues:
|
| 154 |
+
return AuditL2Result(
|
| 155 |
+
status=AuditStatus.PASS,
|
| 156 |
+
confidence=0.75,
|
| 157 |
+
engine="heuristic",
|
| 158 |
+
)
|
| 159 |
+
# Gravi (hallucination o contraddizione) β FAIL; solo completeness β UNKNOWN
|
| 160 |
+
has_severe = any(
|
| 161 |
+
"hallucination" in i or "Contraddizione" in i or "contradiction" in i.lower()
|
| 162 |
+
for i in issues
|
| 163 |
+
)
|
| 164 |
+
return AuditL2Result(
|
| 165 |
+
status=AuditStatus.FAIL if has_severe else AuditStatus.UNKNOWN,
|
| 166 |
+
confidence=0.82 if has_severe else 0.55,
|
| 167 |
+
issues=issues,
|
| 168 |
+
repair_hint="Rivedere e rimuovere claim non verificati o contraddizioni.",
|
| 169 |
+
engine="heuristic",
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
_AUDIT_SYSTEM = (
|
| 174 |
+
"Sei un Critico Senior che verifica la coerenza logica delle risposte di un agente AI. "
|
| 175 |
+
"Rispondi SOLO con JSON valido, senza markdown. Formato:\n"
|
| 176 |
+
'{"status":"PASS"|"FAIL"|"UNKNOWN","confidence":0.0-1.0,'
|
| 177 |
+
'"issues":["..."],"repair_hint":"..."}\n\n'
|
| 178 |
+
"Regole: FAIL solo per problemi gravi (hallucination, contraddizioni). "
|
| 179 |
+
"UNKNOWN per incertezze moderate. PASS se la risposta Γ¨ coerente. "
|
| 180 |
+
"Mai forzare PASS se ci sono dubbi fondati."
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _build_audit_prompt(goal: str, answer: str) -> str:
|
| 185 |
+
# Tronca intelligentemente: preserva inizio e fine dell'answer
|
| 186 |
+
max_ans = 1400
|
| 187 |
+
if len(answer) > max_ans:
|
| 188 |
+
half = max_ans // 2
|
| 189 |
+
answer_trunc = answer[:half] + "\n[...]\n" + answer[-half:]
|
| 190 |
+
else:
|
| 191 |
+
answer_trunc = answer
|
| 192 |
+
return (
|
| 193 |
+
f"GOAL ORIGINALE:\n{goal[:500]}\n\n"
|
| 194 |
+
f"RISPOSTA AGENTE:\n{answer_trunc}\n\n"
|
| 195 |
+
"VERIFICA (rispondi solo con JSON):\n"
|
| 196 |
+
"1. Ci sono claim di azioni esterne non verificabili (deploy/push/send/test-pass senza tool proof)?\n"
|
| 197 |
+
"2. Ci sono contraddizioni interne (es. 'errore' e 'completato con successo' co-presenti)?\n"
|
| 198 |
+
"3. La risposta indirizza almeno i sotto-obiettivi espliciti del goal?\n"
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class SemanticAuditorL2:
|
| 203 |
+
"""
|
| 204 |
+
S303 β Audit Semantico L2.
|
| 205 |
+
Istanziato come singleton.
|
| 206 |
+
Usato in unified_loop_fallback.py dopo GoalVerifier L1 (solo se L1 β FAIL).
|
| 207 |
+
"""
|
| 208 |
+
|
| 209 |
+
def __init__(self, ai_client: Any = None, timeout_s: float = 12.0):
|
| 210 |
+
self.ai_client = ai_client
|
| 211 |
+
self.timeout_s = timeout_s
|
| 212 |
+
|
| 213 |
+
async def audit(self, goal: str, answer: str) -> AuditL2Result:
|
| 214 |
+
"""
|
| 215 |
+
Punto di ingresso principale.
|
| 216 |
+
1. Prova audit LLM se ai_client disponibile.
|
| 217 |
+
2. Fallback a audit euristico in caso di errore o timeout.
|
| 218 |
+
"""
|
| 219 |
+
if not goal or not answer:
|
| 220 |
+
return AuditL2Result(status=AuditStatus.UNKNOWN, confidence=0.0,
|
| 221 |
+
issues=["goal o answer vuoti"])
|
| 222 |
+
|
| 223 |
+
# Euristico sempre eseguito β base line gratuita
|
| 224 |
+
heuristic_result = _heuristic_audit(goal, answer)
|
| 225 |
+
|
| 226 |
+
# Se euristico ha giΓ trovato problemi gravi, non invocare LLM per efficienza
|
| 227 |
+
if heuristic_result.status == AuditStatus.FAIL and len(heuristic_result.issues) >= 2:
|
| 228 |
+
_logger.debug("[AuditL2] heuristic FAIL con %d issues β skip LLM", len(heuristic_result.issues))
|
| 229 |
+
return heuristic_result
|
| 230 |
+
|
| 231 |
+
if self.ai_client is not None:
|
| 232 |
+
try:
|
| 233 |
+
result = await asyncio.wait_for(
|
| 234 |
+
self._llm_audit(goal, answer),
|
| 235 |
+
timeout=self.timeout_s
|
| 236 |
+
)
|
| 237 |
+
if result:
|
| 238 |
+
# Merge: se LLM dice PASS ma euristico ha trovato issue β UNKNOWN
|
| 239 |
+
if result.status == AuditStatus.PASS and heuristic_result.issues:
|
| 240 |
+
result.status = AuditStatus.UNKNOWN
|
| 241 |
+
result.issues = heuristic_result.issues
|
| 242 |
+
result.confidence = min(result.confidence, 0.65)
|
| 243 |
+
return result
|
| 244 |
+
except asyncio.TimeoutError:
|
| 245 |
+
_logger.warning("[AuditL2] timeout LLM (%.1fs) β fallback euristico", self.timeout_s)
|
| 246 |
+
except Exception as e:
|
| 247 |
+
_logger.warning("[AuditL2] errore LLM (%s) β fallback euristico", type(e).__name__)
|
| 248 |
+
|
| 249 |
+
return heuristic_result
|
| 250 |
+
|
| 251 |
+
async def _llm_audit(self, goal: str, answer: str) -> Optional[AuditL2Result]:
|
| 252 |
+
"""Chiamata LLM reale per l'audit semantico."""
|
| 253 |
+
prompt = _build_audit_prompt(goal, answer)
|
| 254 |
+
# Preferisce modello veloce/economico (8B) β audit non richiede ragionamento profondo
|
| 255 |
+
_model = getattr(self.ai_client, "_audit_model", None) or "llama-3.1-8b-instant"
|
| 256 |
+
response = await self.ai_client.chat.completions.create(
|
| 257 |
+
model=_model,
|
| 258 |
+
messages=[
|
| 259 |
+
{"role": "system", "content": _AUDIT_SYSTEM},
|
| 260 |
+
{"role": "user", "content": prompt},
|
| 261 |
+
],
|
| 262 |
+
max_tokens=256,
|
| 263 |
+
temperature=0.0, # deterministico
|
| 264 |
+
)
|
| 265 |
+
raw = response.choices[0].message.content or ""
|
| 266 |
+
match = re.search(r"\{[\s\S]*?\}", raw)
|
| 267 |
+
if not match:
|
| 268 |
+
_logger.warning("[AuditL2] risposta LLM non contiene JSON: %.80s", raw)
|
| 269 |
+
return None
|
| 270 |
+
try:
|
| 271 |
+
parsed = json.loads(match.group(0))
|
| 272 |
+
except json.JSONDecodeError as _je:
|
| 273 |
+
_logger.warning("[AuditL2] JSON decode error: %s", _je)
|
| 274 |
+
return None
|
| 275 |
+
status_raw = parsed.get("status", "UNKNOWN").upper()
|
| 276 |
+
try:
|
| 277 |
+
status = AuditStatus(status_raw)
|
| 278 |
+
except ValueError:
|
| 279 |
+
status = AuditStatus.UNKNOWN
|
| 280 |
+
return AuditL2Result(
|
| 281 |
+
status=status,
|
| 282 |
+
confidence=float(parsed.get("confidence", 0.70)),
|
| 283 |
+
issues=parsed.get("issues", []),
|
| 284 |
+
repair_hint=parsed.get("repair_hint", ""),
|
| 285 |
+
engine="llm",
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ββ Singleton βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 290 |
+
_auditor: Optional[SemanticAuditorL2] = None
|
| 291 |
+
|
| 292 |
+
def get_auditor(ai_client: Any = None, timeout_s: float = 12.0) -> SemanticAuditorL2:
|
| 293 |
+
"""
|
| 294 |
+
Ritorna o crea il singleton SemanticAuditorL2.
|
| 295 |
+
Se chiamato con ai_client=<client> e il singleton esiste giΓ senza client,
|
| 296 |
+
aggiorna il client sul singleton esistente (upgrade lazy).
|
| 297 |
+
"""
|
| 298 |
+
global _auditor
|
| 299 |
+
if _auditor is None:
|
| 300 |
+
_auditor = SemanticAuditorL2(ai_client=ai_client, timeout_s=timeout_s)
|
| 301 |
+
elif ai_client is not None and _auditor.ai_client is None:
|
| 302 |
+
_auditor.ai_client = ai_client # upgrade: inserisce client dopo init
|
| 303 |
+
return _auditor
|
agents/unified_loop_fallback.py
CHANGED
|
@@ -33,10 +33,22 @@ from agents.unified_loop_types import (
|
|
| 33 |
UnifiedLoopState,
|
| 34 |
_maybe_await,
|
| 35 |
_LANG_INSTRUCTIONS,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
)
|
| 37 |
|
| 38 |
_logger = logging.getLogger("agente_ai")
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
class FallbackMixin:
|
| 42 |
async def _run_fallback(self, state: UnifiedLoopState,
|
|
@@ -2425,6 +2437,75 @@ class FallbackMixin:
|
|
| 2425 |
except Exception as _gv_exc:
|
| 2426 |
_logger.warning("S624 GoalVerifier failed (silent): %s", _gv_exc) # S624
|
| 2427 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2428 |
# Sprint 3b ITEM 8: Browser Goal Verification Γ’ΒΒ Playwright headless su app live
|
| 2429 |
# Attivato solo se l'answer contiene un URL di deploy (pages.dev / vercel.app / ecc.)
|
| 2430 |
# e il RequirementEngine ha trovato requisiti (giΓΒ estratti sopra in _gv2_reqs).
|
|
|
|
| 33 |
UnifiedLoopState,
|
| 34 |
_maybe_await,
|
| 35 |
_LANG_INSTRUCTIONS,
|
| 36 |
+
_ANALYTICAL_VERBS_RE,
|
| 37 |
+
_TASK_VERBS_RE,
|
| 38 |
+
_is_goal_ambiguous,
|
| 39 |
+
_is_borderline_ambiguous,
|
| 40 |
+
_BORDERLINE_FIX_RE,
|
| 41 |
+
_BORDERLINE_HELP_RE,
|
| 42 |
+
_BORDERLINE_MAKE_RE,
|
| 43 |
+
_detect_user_lang,
|
| 44 |
)
|
| 45 |
|
| 46 |
_logger = logging.getLogger("agente_ai")
|
| 47 |
|
| 48 |
+
# QF-2: costanti timeout β replicate da unified_loop.py (evita circular import)
|
| 49 |
+
LLM_TIMEOUT: float = float(os.getenv('LLM_CALL_TIMEOUT', '60'))
|
| 50 |
+
TOOL_TIMEOUT: float = float(os.getenv('TOOL_CALL_TIMEOUT', '25'))
|
| 51 |
+
|
| 52 |
|
| 53 |
class FallbackMixin:
|
| 54 |
async def _run_fallback(self, state: UnifiedLoopState,
|
|
|
|
| 2437 |
except Exception as _gv_exc:
|
| 2438 |
_logger.warning("S624 GoalVerifier failed (silent): %s", _gv_exc) # S624
|
| 2439 |
|
| 2440 |
+
|
| 2441 |
+
# S303: Audit Semantico L2 β verifica coerenza logica interna dell'output.
|
| 2442 |
+
# Eseguito DOPO GoalVerifier L1, solo se L1 β FAIL (risparmio token).
|
| 2443 |
+
# Rileva: hallucination claims (deploy/push/send non verificati),
|
| 2444 |
+
# contraddizioni interne (errore + completato), sotto-obiettivi mancanti.
|
| 2445 |
+
# Silent failure totale β non blocca mai la risposta al client.
|
| 2446 |
+
_l2_should_run = (
|
| 2447 |
+
answer
|
| 2448 |
+
and not answer.startswith('[LLM')
|
| 2449 |
+
and len(answer.split()) > 15
|
| 2450 |
+
)
|
| 2451 |
+
try:
|
| 2452 |
+
_l1_was_fail = (
|
| 2453 |
+
getattr(_gvr, 'verification_status', None) is not None # type: ignore[name-defined]
|
| 2454 |
+
and str(getattr(_gvr, 'verification_status', '')).endswith('FAIL')
|
| 2455 |
+
)
|
| 2456 |
+
except NameError:
|
| 2457 |
+
_l1_was_fail = False # _gvr non definito β L1 non era attivo (goal non-code)
|
| 2458 |
+
if _l2_should_run and not _l1_was_fail:
|
| 2459 |
+
try:
|
| 2460 |
+
from agents.audit_semantic_l2 import get_auditor as _get_auditor_l2
|
| 2461 |
+
_auditor_l2 = _get_auditor_l2(
|
| 2462 |
+
ai_client=self._get_verifier_llm(), # cross-model (P25-B4)
|
| 2463 |
+
timeout_s=12.0,
|
| 2464 |
+
)
|
| 2465 |
+
_l2_result = await asyncio.wait_for(
|
| 2466 |
+
_auditor_l2.audit(state.goal, answer),
|
| 2467 |
+
timeout=13.0,
|
| 2468 |
+
)
|
| 2469 |
+
# Telemetria
|
| 2470 |
+
try:
|
| 2471 |
+
from api.state import increment_stat as _inc_l2
|
| 2472 |
+
_inc_l2(f"audit_l2_{_l2_result.status.value.lower()}")
|
| 2473 |
+
except Exception as _exc:
|
| 2474 |
+
_logger.debug("[S303] telemetry silenced: %s", type(_exc).__name__)
|
| 2475 |
+
_logger.info(
|
| 2476 |
+
"[S303] AuditL2 %s (conf=%.2f engine=%s) issues=%d",
|
| 2477 |
+
_l2_result.status.value,
|
| 2478 |
+
_l2_result.confidence,
|
| 2479 |
+
_l2_result.engine,
|
| 2480 |
+
len(_l2_result.issues),
|
| 2481 |
+
)
|
| 2482 |
+
if _l2_result.status.value == "FAIL" and _l2_result.issues:
|
| 2483 |
+
# Notifica UI β step visibile nel pannello avanzamento
|
| 2484 |
+
if on_step:
|
| 2485 |
+
await _maybe_await(on_step({
|
| 2486 |
+
"action": "audit_l2",
|
| 2487 |
+
"status": "warning",
|
| 2488 |
+
"visibility": "progress",
|
| 2489 |
+
"title": "β οΈ Verifica coerenza risposta",
|
| 2490 |
+
"explanation": _l2_result.issues[0][:120],
|
| 2491 |
+
}))
|
| 2492 |
+
# Appende nota discreta β non modifica il codice, solo avvisa
|
| 2493 |
+
if answer:
|
| 2494 |
+
_l2_note_parts = ["\n\n> β οΈ **Nota di coerenza**:"]
|
| 2495 |
+
for _iss in _l2_result.issues[:2]:
|
| 2496 |
+
_l2_note_parts.append(f" {_iss}")
|
| 2497 |
+
if _l2_result.repair_hint:
|
| 2498 |
+
_l2_note_parts.append(f" \n> π‘ {_l2_result.repair_hint}")
|
| 2499 |
+
answer += "".join(_l2_note_parts)
|
| 2500 |
+
except asyncio.TimeoutError:
|
| 2501 |
+
try:
|
| 2502 |
+
from api.state import increment_stat as _inc_l2t
|
| 2503 |
+
_inc_l2t("audit_l2_timeout")
|
| 2504 |
+
except Exception as _exc:
|
| 2505 |
+
_logger.debug("[S303] timeout counter silenced: %s", type(_exc).__name__)
|
| 2506 |
+
except Exception as _l2_exc:
|
| 2507 |
+
_logger.debug("[S303] AuditL2 silenced: %s", type(_l2_exc).__name__) # S624
|
| 2508 |
+
|
| 2509 |
# Sprint 3b ITEM 8: Browser Goal Verification Γ’ΒΒ Playwright headless su app live
|
| 2510 |
# Attivato solo se l'answer contiene un URL di deploy (pages.dev / vercel.app / ecc.)
|
| 2511 |
# e il RequirementEngine ha trovato requisiti (giΓΒ estratti sopra in _gv2_reqs).
|
api/TELEGRAM_MODULES.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/api β Moduli Telegram (M2 Split)
|
| 2 |
+
|
| 3 |
+
> **Refactor M2 β 2 Luglio 2026**
|
| 4 |
+
> Il monolite `telegram_webhook.py` (2844 righe, 5 hotfix consecutivi) Γ¨ stato
|
| 5 |
+
> spezzato in 6 moduli a responsabilitΓ singola. Hotfix futuri toccano **un solo file**.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Grafo delle dipendenze
|
| 10 |
+
|
| 11 |
+
```
|
| 12 |
+
telegram_tg_client.py (stdlib + httpx only)
|
| 13 |
+
β
|
| 14 |
+
ββββΊ telegram_keyboards.py (solo costanti/dict β zero import interni)
|
| 15 |
+
β β
|
| 16 |
+
β ββββΊ telegram_cmd_monitoring.py
|
| 17 |
+
β β ββββΊ (esposto via telegram_webhook.py)
|
| 18 |
+
β β
|
| 19 |
+
β ββββΊ telegram_cmd_ai.py
|
| 20 |
+
β β ββββΊ (esposto via telegram_webhook.py)
|
| 21 |
+
β β
|
| 22 |
+
β ββββΊ telegram_callbacks.py
|
| 23 |
+
β βββ importa _cmd_do, _cmd_autofix ... da telegram_cmd_ai
|
| 24 |
+
β βββ importa _cmd_help, _cmd_status ... da telegram_cmd_monitoring
|
| 25 |
+
β
|
| 26 |
+
ββββΊ telegram_webhook.py (router FastAPI puro β importa da tutti)
|
| 27 |
+
ββββΊ montato in backend/main.py come _tg_webhook_router
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Moduli
|
| 33 |
+
|
| 34 |
+
### `telegram_tg_client.py` β 276 righe
|
| 35 |
+
**Ruolo:** Telegram Bot API puro. Layer 0 senza dipendenze interne.
|
| 36 |
+
|
| 37 |
+
| Simbolo | Descrizione |
|
| 38 |
+
|---------|-------------|
|
| 39 |
+
| `_get_bot_token()` | Legge `TELEGRAM_BOT_TOKEN` dall'env |
|
| 40 |
+
| `_log_tg_exc(task)` | Log eccezioni fire-and-forget (Gap-2.6) |
|
| 41 |
+
| `_fmt_elapsed(sec)` | Formatta durata in human-readable |
|
| 42 |
+
| `_tg_reply(chat_id, text)` | Invia messaggio con parse_mode=HTML |
|
| 43 |
+
| `_tg_send(chat_id, text)` | Come reply, ritorna `message_id` |
|
| 44 |
+
| `_tg_edit(chat_id, msg_id, text)` | Modifica messaggio esistente |
|
| 45 |
+
| `_tg_photo(chat_id, url, caption)` | Invia foto via URL |
|
| 46 |
+
| `_tg_typing(chat_id)` | Invia `sendChatAction typing` |
|
| 47 |
+
| `_tg_react(chat_id, msg_id, emoji)` | Imposta reaction emoji |
|
| 48 |
+
| `_tg_answer_callback(callback_id)` | Risponde a `callback_query` (β€3s) |
|
| 49 |
+
|
| 50 |
+
**Import:** `asyncio, html, logging, os, time, httpx`
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
### `telegram_keyboards.py` β 109 righe
|
| 55 |
+
**Ruolo:** Costanti UI e keyboards. Zero logica, zero import interni.
|
| 56 |
+
|
| 57 |
+
| Simbolo | Tipo | Descrizione |
|
| 58 |
+
|---------|------|-------------|
|
| 59 |
+
| `_QUICK_PICK_KB` | `dict` | Inline keyboard selezione task rapida |
|
| 60 |
+
| `_MAIN_KB` | `dict` | Reply keyboard principale |
|
| 61 |
+
| `_BENCH_ACTION_KB` | `dict` | Keyboard post-benchmark |
|
| 62 |
+
| `_TASK_MENU_KB` | `dict` | Sub-menu task |
|
| 63 |
+
| `_STATUS_MENU_KB` | `dict` | Sub-menu stato sistema |
|
| 64 |
+
| `_PERF_MENU_KB` | `dict` | Sub-menu performance |
|
| 65 |
+
| `_HEALTH_MENU_KB` | `dict` | Sub-menu health |
|
| 66 |
+
| `_DEV_MENU_KB` | `dict` | Sub-menu developer |
|
| 67 |
+
| `_LAST_GOAL` | `dict[int,str]` | Memoria per bottone π Rifai |
|
| 68 |
+
| `_BENCH_CACHE` | `dict[int,dict]` | Ultimo run benchmark per chat |
|
| 69 |
+
| `_after_task_kb(chat_id)` | func | Keyboard dinamica post-task |
|
| 70 |
+
|
| 71 |
+
**Import:** solo `from __future__ import annotations`
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
### `telegram_cmd_monitoring.py` β 456 righe
|
| 76 |
+
**Ruolo:** Comandi di monitoraggio, stato e diagnostica.
|
| 77 |
+
|
| 78 |
+
| Comando / Funzione | Trigger Telegram |
|
| 79 |
+
|--------------------|-----------------|
|
| 80 |
+
| `_cmd_help(chat_id)` | `/start` `/help` `tgw_help` |
|
| 81 |
+
| `_cmd_logs(chat_id)` | `/logs` `tgw_logs` |
|
| 82 |
+
| `_cmd_status(chat_id)` | `/stato` `tgw_status` |
|
| 83 |
+
| `_cmd_commit_summary(chat_id)` | `/commit` `tgw_commits` |
|
| 84 |
+
| `_cmd_check(chat_id)` | `/salute` `tgw_health` |
|
| 85 |
+
| `_cmd_tasks(chat_id)` | `/attivitΓ ` `tgw_tasks` |
|
| 86 |
+
| `_cmd_git(chat_id, args)` | `/git` `tgw_git` |
|
| 87 |
+
| `_cmd_coord(chat_id)` | `/coord` `tgw_coord` |
|
| 88 |
+
| `_cmd_scan_now(chat_id)` | `/scan` `tgw_scan` |
|
| 89 |
+
| `_cmd_telemetry(chat_id)` | `/telemetria` `tgw_telemetry` |
|
| 90 |
+
|
| 91 |
+
**Import:** stdlib + `telegram_tg_client` + `telegram_keyboards`
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
### `telegram_cmd_ai.py` β 1152 righe
|
| 96 |
+
**Ruolo:** Comandi AI, LLM e operativi. Modulo piΓΉ pesante β contiene lo streaming loop.
|
| 97 |
+
|
| 98 |
+
| Comando / Funzione | Trigger Telegram |
|
| 99 |
+
|--------------------|-----------------|
|
| 100 |
+
| `_cmd_do(chat_id, goal)` | `/avvia` β lancia task AI con streaming |
|
| 101 |
+
| `_cmd_autofix(chat_id)` | `/fix` `tgw_autofix` |
|
| 102 |
+
| `_cmd_nota(chat_id, text)` | `/nota` `tgw_nota` |
|
| 103 |
+
| `_cmd_cerca(chat_id, query)` | `/cerca` `tgw_cerca` |
|
| 104 |
+
| `_cmd_meteo(chat_id, city)` | `/meteo` `tgw_meteo` |
|
| 105 |
+
| `_cmd_riepilogo(chat_id)` | `/riepilogo` `tgw_briefing` |
|
| 106 |
+
| `_cmd_score(chat_id)` | `/score` `tgw_score` |
|
| 107 |
+
| `_cmd_bench(chat_id)` | `/bench` `tgw_bench` |
|
| 108 |
+
| `_cmd_improve(chat_id, target)` | `/migliora` `tgw_improve` |
|
| 109 |
+
|
| 110 |
+
**Import:** stdlib + `telegram_tg_client` + `telegram_keyboards`
|
| 111 |
+
|
| 112 |
+
---
|
| 113 |
+
|
| 114 |
+
### `telegram_callbacks.py` β 353 righe
|
| 115 |
+
**Ruolo:** Smista `callback_query` e `inline_query` in arrivo da Telegram.
|
| 116 |
+
|
| 117 |
+
| Funzione | Descrizione |
|
| 118 |
+
|----------|-------------|
|
| 119 |
+
| `_handle_inline(iq, token)` | Risponde alle inline query (`@ARJagent_ap_bot testo`) |
|
| 120 |
+
| `_handle_callback(cb, token)` | Dispatch per tutti i `callback_data` `tgw_*` / `agent` / `qp_*` |
|
| 121 |
+
|
| 122 |
+
**Import:** `telegram_tg_client` + `telegram_keyboards` + `telegram_cmd_ai` + `telegram_cmd_monitoring`
|
| 123 |
+
|
| 124 |
+
> β οΈ **Questo modulo crea dipendenze circolari potenziali** β non importare
|
| 125 |
+
> `telegram_callbacks` da `telegram_cmd_ai` o `telegram_cmd_monitoring`.
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
### `telegram_webhook.py` β 605 righe *(era 2844)*
|
| 130 |
+
**Ruolo:** Router FastAPI puro. Solo endpoint HTTP, zero logica di business.
|
| 131 |
+
|
| 132 |
+
| Endpoint | Metodo | Descrizione |
|
| 133 |
+
|----------|--------|-------------|
|
| 134 |
+
| `/api/telegram/webhook` | POST | Ricezione update getUpdates (polling daemon) |
|
| 135 |
+
| `/api/telegram/process` | POST | Endpoint alternativo per CF Pages proxy |
|
| 136 |
+
| `/api/telegram/config/invalidate` | POST | Invalida cache config bot |
|
| 137 |
+
|
| 138 |
+
**Import:** FastAPI + tutti i 5 moduli sopra
|
| 139 |
+
**Montato in:** `backend/main.py` β `app.include_router(_tg_webhook_router)`
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## Regole per gli hotfix
|
| 144 |
+
|
| 145 |
+
| Vuoi cambiare... | Tocca solo... |
|
| 146 |
+
|------------------|---------------|
|
| 147 |
+
| Aspetto di un bottone / label | `telegram_keyboards.py` |
|
| 148 |
+
| Risposta a un comando /help, /stato⦠| `telegram_cmd_monitoring.py` |
|
| 149 |
+
| Logica task AI / streaming / bench | `telegram_cmd_ai.py` |
|
| 150 |
+
| Routing dei bottoni inline (tgw_*) | `telegram_callbacks.py` |
|
| 151 |
+
| Helpers HTTP verso api.telegram.org | `telegram_tg_client.py` |
|
| 152 |
+
| Routing endpoint FastAPI | `telegram_webhook.py` |
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
## Daemon Node.js (scripts/lib/daemon-callbacks.mjs)
|
| 157 |
+
|
| 158 |
+
Il daemon usa **getUpdates polling** (non webhook registrato).
|
| 159 |
+
Riceve gli update, li smista ai command handler Python via HTTP interno.
|
| 160 |
+
Il flusso `callback_data tgw_*` viene elaborato da `telegram_callbacks.py::_handle_callback`.
|
| 161 |
+
|
| 162 |
+
```
|
| 163 |
+
Telegram βββΊ getUpdates daemon βββΊ POST /api/telegram/process
|
| 164 |
+
β
|
| 165 |
+
telegram_webhook.py (router)
|
| 166 |
+
β
|
| 167 |
+
βββββββββββββ΄ββββββββββββ
|
| 168 |
+
message? callback_query?
|
| 169 |
+
β β
|
| 170 |
+
telegram_cmd_*.py telegram_callbacks.py
|
| 171 |
+
::_handle_callback
|
| 172 |
+
```
|
api/agent_task_routes.py
CHANGED
|
@@ -23,6 +23,7 @@ from .state import (
|
|
| 23 |
write_ahead_task_created,
|
| 24 |
)
|
| 25 |
from .speculative import fire_speculative_tools
|
|
|
|
| 26 |
try:
|
| 27 |
from .quality_guardian import run_quality_check as _run_quality_check
|
| 28 |
except Exception:
|
|
@@ -98,6 +99,8 @@ async def create_agent_task(body: AgentTaskIn):
|
|
| 98 |
# S361: Speculative Tool Firing β pre-fires read-only tools in parallel
|
| 99 |
# while the main model processes. Results cached for _run_direct_tools to consume.
|
| 100 |
asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
| 101 |
return {'taskId': task_id, 'status': 'QUEUED'}
|
| 102 |
|
| 103 |
|
|
@@ -794,3 +797,4 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
|
|
| 794 |
'X-Accel-Buffering': 'no',
|
| 795 |
}
|
| 796 |
)
|
|
|
|
|
|
| 23 |
write_ahead_task_created,
|
| 24 |
)
|
| 25 |
from .speculative import fire_speculative_tools
|
| 26 |
+
from .prewarm import fire_predictive_prewarm # S950
|
| 27 |
try:
|
| 28 |
from .quality_guardian import run_quality_check as _run_quality_check
|
| 29 |
except Exception:
|
|
|
|
| 99 |
# S361: Speculative Tool Firing β pre-fires read-only tools in parallel
|
| 100 |
# while the main model processes. Results cached for _run_direct_tools to consume.
|
| 101 |
asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
|
| 102 |
+
# S950: Predictive Pre-warming cluster 4x4
|
| 103 |
+
fire_predictive_prewarm(body.goal)
|
| 104 |
return {'taskId': task_id, 'status': 'QUEUED'}
|
| 105 |
|
| 106 |
|
|
|
|
| 797 |
'X-Accel-Buffering': 'no',
|
| 798 |
}
|
| 799 |
)
|
| 800 |
+
|
api/cache_endpoints.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cache_endpoints.py β Endpoint API per Gestione Cache
|
| 3 |
+
|
| 4 |
+
Endpoint:
|
| 5 |
+
GET /api/cache/stats β Statistiche cache
|
| 6 |
+
POST /api/cache/invalidate β Invalida un entry
|
| 7 |
+
GET /api/cache/health β Health check cache
|
| 8 |
+
POST /api/cache/reset-stats β Resetta statistiche
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, HTTPException
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
from typing import Optional
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
# Import cache manager
|
| 17 |
+
try:
|
| 18 |
+
from backend.api.cache_manager import (
|
| 19 |
+
get_cache_stats,
|
| 20 |
+
reset_cache_stats,
|
| 21 |
+
invalidate_cache,
|
| 22 |
+
CacheStrategy,
|
| 23 |
+
CACHE_ENABLED,
|
| 24 |
+
)
|
| 25 |
+
except ImportError:
|
| 26 |
+
from cache_manager import (
|
| 27 |
+
get_cache_stats,
|
| 28 |
+
reset_cache_stats,
|
| 29 |
+
invalidate_cache,
|
| 30 |
+
CacheStrategy,
|
| 31 |
+
CACHE_ENABLED,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
_logger = logging.getLogger("cache_endpoints")
|
| 35 |
+
|
| 36 |
+
router = APIRouter(prefix="/api/cache", tags=["cache"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ββ Modelli Pydantic ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
class InvalidateCacheRequest(BaseModel):
|
| 41 |
+
strategy: str # "query", "memory", "embedding", "conversation", "analytics"
|
| 42 |
+
identifier: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class CacheStatsResponse(BaseModel):
|
| 46 |
+
enabled: bool
|
| 47 |
+
hits: int
|
| 48 |
+
misses: int
|
| 49 |
+
sets: int
|
| 50 |
+
deletes: int
|
| 51 |
+
evictions: int
|
| 52 |
+
hit_rate_percent: float
|
| 53 |
+
total_requests: int
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class CacheHealthResponse(BaseModel):
|
| 57 |
+
ok: bool
|
| 58 |
+
cache_enabled: bool
|
| 59 |
+
message: str
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ββ Endpoint: Statistiche Cache ββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
+
@router.get("/stats", response_model=CacheStatsResponse)
|
| 64 |
+
async def cache_stats():
|
| 65 |
+
"""Ritorna le statistiche del cache layer."""
|
| 66 |
+
try:
|
| 67 |
+
stats = get_cache_stats()
|
| 68 |
+
return CacheStatsResponse(**stats)
|
| 69 |
+
except Exception as exc:
|
| 70 |
+
_logger.error(f"Error fetching cache stats: {exc}")
|
| 71 |
+
raise HTTPException(500, "Error fetching cache stats")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ββ Endpoint: Invalida Cache ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 75 |
+
@router.post("/invalidate")
|
| 76 |
+
async def invalidate_cache_entry(req: InvalidateCacheRequest):
|
| 77 |
+
"""Invalida un entry specifico dalla cache."""
|
| 78 |
+
try:
|
| 79 |
+
# Valida strategy
|
| 80 |
+
try:
|
| 81 |
+
strategy = CacheStrategy(req.strategy)
|
| 82 |
+
except ValueError:
|
| 83 |
+
raise HTTPException(
|
| 84 |
+
400,
|
| 85 |
+
f"Invalid strategy. Must be one of: {', '.join([s.value for s in CacheStrategy])}",
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Invalida
|
| 89 |
+
success = await invalidate_cache(strategy, req.identifier)
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"ok": success,
|
| 93 |
+
"strategy": req.strategy,
|
| 94 |
+
"identifier": req.identifier,
|
| 95 |
+
"message": "Cache entry invalidated" if success else "Failed to invalidate cache entry",
|
| 96 |
+
}
|
| 97 |
+
except HTTPException:
|
| 98 |
+
raise
|
| 99 |
+
except Exception as exc:
|
| 100 |
+
_logger.error(f"Error invalidating cache: {exc}")
|
| 101 |
+
raise HTTPException(500, "Error invalidating cache")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ββ Endpoint: Health Check ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
@router.get("/health", response_model=CacheHealthResponse)
|
| 106 |
+
async def cache_health():
|
| 107 |
+
"""Health check per il cache layer."""
|
| 108 |
+
try:
|
| 109 |
+
stats = get_cache_stats()
|
| 110 |
+
|
| 111 |
+
return CacheHealthResponse(
|
| 112 |
+
ok=True,
|
| 113 |
+
cache_enabled=CACHE_ENABLED,
|
| 114 |
+
message=f"Cache layer operational. Hit rate: {stats.get('hit_rate_percent', 0):.1f}%",
|
| 115 |
+
)
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
_logger.error(f"Cache health check failed: {exc}")
|
| 118 |
+
return CacheHealthResponse(
|
| 119 |
+
ok=False,
|
| 120 |
+
cache_enabled=CACHE_ENABLED,
|
| 121 |
+
message=f"Cache health check failed: {str(exc)}",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ββ Endpoint: Reset Statistiche βββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
+
@router.post("/reset-stats")
|
| 127 |
+
async def reset_stats():
|
| 128 |
+
"""Resetta le statistiche del cache."""
|
| 129 |
+
try:
|
| 130 |
+
reset_cache_stats()
|
| 131 |
+
return {
|
| 132 |
+
"ok": True,
|
| 133 |
+
"message": "Cache statistics reset",
|
| 134 |
+
}
|
| 135 |
+
except Exception as exc:
|
| 136 |
+
_logger.error(f"Error resetting cache stats: {exc}")
|
| 137 |
+
raise HTTPException(500, "Error resetting cache stats")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ββ Endpoint: Info Cache ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 141 |
+
@router.get("/info")
|
| 142 |
+
async def cache_info():
|
| 143 |
+
"""Ritorna informazioni sulla configurazione del cache."""
|
| 144 |
+
try:
|
| 145 |
+
from cache_manager import (
|
| 146 |
+
CACHE_ENABLED,
|
| 147 |
+
CACHE_TTL_DEFAULT,
|
| 148 |
+
CACHE_MAX_SIZE,
|
| 149 |
+
CACHE_TTL_BY_STRATEGY,
|
| 150 |
+
SUPABASE_A_URL,
|
| 151 |
+
SUPABASE_B_URL,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
return {
|
| 155 |
+
"enabled": CACHE_ENABLED,
|
| 156 |
+
"ttl_default_seconds": CACHE_TTL_DEFAULT,
|
| 157 |
+
"max_size": CACHE_MAX_SIZE,
|
| 158 |
+
"ttl_by_strategy": {k.value: v for k, v in CACHE_TTL_BY_STRATEGY.items()},
|
| 159 |
+
"supabase_a_configured": bool(SUPABASE_A_URL),
|
| 160 |
+
"supabase_b_configured": bool(SUPABASE_B_URL),
|
| 161 |
+
}
|
| 162 |
+
except Exception as exc:
|
| 163 |
+
_logger.error(f"Error fetching cache info: {exc}")
|
| 164 |
+
raise HTTPException(500, "Error fetching cache info")
|
api/cache_manager.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cache_manager.py β Cache Layer Distribuito su Supabase A
|
| 3 |
+
|
| 4 |
+
Architettura:
|
| 5 |
+
- Supabase A funge da archivio cache distribuito (read-only, non-critical)
|
| 6 |
+
- TTL configurabile per invalidazione automatica
|
| 7 |
+
- Fallback a B se A non disponibile
|
| 8 |
+
- Supporta cache per query, memoria, embeddings, conversazioni
|
| 9 |
+
|
| 10 |
+
Strategie di Caching:
|
| 11 |
+
1. Query Cache: Risultati query SELECT memorizzati con TTL
|
| 12 |
+
2. Memory Cache: Memoria agente memorizzata per accesso veloce
|
| 13 |
+
3. Embedding Cache: Embeddings pre-calcolati per RAG
|
| 14 |
+
4. Conversation Cache: Conversazioni recenti per accesso veloce
|
| 15 |
+
|
| 16 |
+
Invalidazione:
|
| 17 |
+
- TTL-based: Scadenza automatica dopo TTL
|
| 18 |
+
- Event-based: Invalidazione su INSERT/UPDATE/DELETE su B
|
| 19 |
+
- Manual: Invalidazione esplicita via API
|
| 20 |
+
|
| 21 |
+
Statistiche:
|
| 22 |
+
- Hit rate: % di richieste servite da cache
|
| 23 |
+
- Miss rate: % di richieste non in cache
|
| 24 |
+
- Eviction rate: % di entry rimosse per TTL
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
import os
|
| 28 |
+
import asyncio
|
| 29 |
+
import logging
|
| 30 |
+
import hashlib
|
| 31 |
+
import json
|
| 32 |
+
from typing import Optional, Dict, List, Any, Callable
|
| 33 |
+
from datetime import datetime, timedelta
|
| 34 |
+
from enum import Enum
|
| 35 |
+
import time
|
| 36 |
+
|
| 37 |
+
_logger = logging.getLogger("cache_manager")
|
| 38 |
+
|
| 39 |
+
# ββ Configurazione Cache βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
CACHE_ENABLED = os.getenv("SUPABASE_CACHE_ENABLED", "true").lower() == "true"
|
| 41 |
+
CACHE_TTL_DEFAULT = int(os.getenv("SUPABASE_CACHE_TTL", "3600")) # 1 ora
|
| 42 |
+
CACHE_MAX_SIZE = int(os.getenv("SUPABASE_CACHE_MAX_SIZE", "10000")) # max entries
|
| 43 |
+
CACHE_STATS_ENABLED = os.getenv("SUPABASE_CACHE_STATS_ENABLED", "true").lower() == "true"
|
| 44 |
+
|
| 45 |
+
# ββ Enumerazione Strategie Cache βββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
class CacheStrategy(str, Enum):
|
| 47 |
+
QUERY = "query" # Cache risultati query
|
| 48 |
+
MEMORY = "memory" # Cache memoria agente
|
| 49 |
+
EMBEDDING = "embedding" # Cache embeddings
|
| 50 |
+
CONVERSATION = "conversation" # Cache conversazioni
|
| 51 |
+
ANALYTICS = "analytics" # Cache analytics/reporting
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ββ TTL per Strategia βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
CACHE_TTL_BY_STRATEGY = {
|
| 56 |
+
CacheStrategy.QUERY: int(os.getenv("CACHE_TTL_QUERY", "600")), # 10 min
|
| 57 |
+
CacheStrategy.MEMORY: int(os.getenv("CACHE_TTL_MEMORY", "1800")), # 30 min
|
| 58 |
+
CacheStrategy.EMBEDDING: int(os.getenv("CACHE_TTL_EMBEDDING", "7200")), # 2 ore
|
| 59 |
+
CacheStrategy.CONVERSATION: int(os.getenv("CACHE_TTL_CONVERSATION", "3600")), # 1 ora
|
| 60 |
+
CacheStrategy.ANALYTICS: int(os.getenv("CACHE_TTL_ANALYTICS", "300")), # 5 min
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
# ββ Configurazione Supabase A ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
SUPABASE_A_URL = os.getenv("SUPABASE_URL_A", "")
|
| 65 |
+
SUPABASE_A_KEY = os.getenv("SUPABASE_KEY_A", "")
|
| 66 |
+
SUPABASE_B_URL = os.getenv("SUPABASE_URL", "")
|
| 67 |
+
SUPABASE_B_KEY = os.getenv("SUPABASE_KEY", "")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ββ Client Cache βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
class CacheClient:
|
| 72 |
+
"""Client per gestire cache su Supabase A."""
|
| 73 |
+
|
| 74 |
+
def __init__(self, url: str, key: str):
|
| 75 |
+
self.url = url
|
| 76 |
+
self.key = key
|
| 77 |
+
self.base_url = f"{url}/rest/v1" if url else None
|
| 78 |
+
self._stats = {
|
| 79 |
+
"hits": 0,
|
| 80 |
+
"misses": 0,
|
| 81 |
+
"sets": 0,
|
| 82 |
+
"deletes": 0,
|
| 83 |
+
"evictions": 0,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
def _generate_cache_key(self, strategy: str, identifier: str) -> str:
|
| 87 |
+
"""Genera una chiave cache univoca."""
|
| 88 |
+
combined = f"{strategy}:{identifier}"
|
| 89 |
+
return hashlib.sha256(combined.encode()).hexdigest()[:32]
|
| 90 |
+
|
| 91 |
+
async def get(self, strategy: CacheStrategy, identifier: str) -> Optional[Dict]:
|
| 92 |
+
"""Recupera un valore dalla cache."""
|
| 93 |
+
if not CACHE_ENABLED or not self.base_url:
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
cache_key = self._generate_cache_key(strategy.value, identifier)
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
import httpx
|
| 100 |
+
|
| 101 |
+
url = f"{self.base_url}/cache_entries?cache_key=eq.{cache_key}"
|
| 102 |
+
headers = {
|
| 103 |
+
"apikey": self.key,
|
| 104 |
+
"Authorization": f"Bearer {self.key}",
|
| 105 |
+
"Content-Type": "application/json",
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
async with httpx.AsyncClient() as client:
|
| 109 |
+
response = await client.get(url, headers=headers, timeout=5.0)
|
| 110 |
+
|
| 111 |
+
if response.status_code == 200:
|
| 112 |
+
rows = response.json()
|
| 113 |
+
if rows:
|
| 114 |
+
entry = rows[0]
|
| 115 |
+
|
| 116 |
+
# Verifica TTL
|
| 117 |
+
if self._is_expired(entry):
|
| 118 |
+
# Elimina entry scaduta
|
| 119 |
+
await self._delete_entry(cache_key)
|
| 120 |
+
if CACHE_STATS_ENABLED:
|
| 121 |
+
self._stats["evictions"] += 1
|
| 122 |
+
return None
|
| 123 |
+
|
| 124 |
+
# Hit
|
| 125 |
+
if CACHE_STATS_ENABLED:
|
| 126 |
+
self._stats["hits"] += 1
|
| 127 |
+
|
| 128 |
+
return {
|
| 129 |
+
"value": entry.get("value"),
|
| 130 |
+
"cached_at": entry.get("created_at"),
|
| 131 |
+
"ttl_remaining": self._get_ttl_remaining(entry),
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
# Miss
|
| 135 |
+
if CACHE_STATS_ENABLED:
|
| 136 |
+
self._stats["misses"] += 1
|
| 137 |
+
return None
|
| 138 |
+
|
| 139 |
+
except Exception as exc:
|
| 140 |
+
_logger.error(f"Cache get error: {exc}")
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
async def set(
|
| 144 |
+
self,
|
| 145 |
+
strategy: CacheStrategy,
|
| 146 |
+
identifier: str,
|
| 147 |
+
value: Any,
|
| 148 |
+
ttl: Optional[int] = None,
|
| 149 |
+
) -> bool:
|
| 150 |
+
"""Memorizza un valore nella cache."""
|
| 151 |
+
if not CACHE_ENABLED or not self.base_url:
|
| 152 |
+
return False
|
| 153 |
+
|
| 154 |
+
cache_key = self._generate_cache_key(strategy.value, identifier)
|
| 155 |
+
ttl = ttl or CACHE_TTL_BY_STRATEGY.get(strategy, CACHE_TTL_DEFAULT)
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
import httpx
|
| 159 |
+
|
| 160 |
+
url = f"{self.base_url}/cache_entries"
|
| 161 |
+
headers = {
|
| 162 |
+
"apikey": self.key,
|
| 163 |
+
"Authorization": f"Bearer {self.key}",
|
| 164 |
+
"Content-Type": "application/json",
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
data = {
|
| 168 |
+
"cache_key": cache_key,
|
| 169 |
+
"strategy": strategy.value,
|
| 170 |
+
"identifier": identifier,
|
| 171 |
+
"value": json.dumps(value) if not isinstance(value, str) else value,
|
| 172 |
+
"ttl_seconds": ttl,
|
| 173 |
+
"created_at": datetime.now().isoformat(),
|
| 174 |
+
"expires_at": (datetime.now() + timedelta(seconds=ttl)).isoformat(),
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
async with httpx.AsyncClient() as client:
|
| 178 |
+
response = await client.post(url, json=data, headers=headers, timeout=5.0)
|
| 179 |
+
|
| 180 |
+
if response.status_code in (200, 201):
|
| 181 |
+
if CACHE_STATS_ENABLED:
|
| 182 |
+
self._stats["sets"] += 1
|
| 183 |
+
return True
|
| 184 |
+
else:
|
| 185 |
+
_logger.warning(f"Cache set failed: {response.status_code}")
|
| 186 |
+
return False
|
| 187 |
+
|
| 188 |
+
except Exception as exc:
|
| 189 |
+
_logger.error(f"Cache set error: {exc}")
|
| 190 |
+
return False
|
| 191 |
+
|
| 192 |
+
async def delete(self, strategy: CacheStrategy, identifier: str) -> bool:
|
| 193 |
+
"""Elimina un valore dalla cache."""
|
| 194 |
+
if not CACHE_ENABLED or not self.base_url:
|
| 195 |
+
return False
|
| 196 |
+
|
| 197 |
+
cache_key = self._generate_cache_key(strategy.value, identifier)
|
| 198 |
+
return await self._delete_entry(cache_key)
|
| 199 |
+
|
| 200 |
+
async def _delete_entry(self, cache_key: str) -> bool:
|
| 201 |
+
"""Elimina un entry dalla cache per chiave."""
|
| 202 |
+
try:
|
| 203 |
+
import httpx
|
| 204 |
+
|
| 205 |
+
url = f"{self.base_url}/cache_entries?cache_key=eq.{cache_key}"
|
| 206 |
+
headers = {
|
| 207 |
+
"apikey": self.key,
|
| 208 |
+
"Authorization": f"Bearer {self.key}",
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
async with httpx.AsyncClient() as client:
|
| 212 |
+
response = await client.delete(url, headers=headers, timeout=5.0)
|
| 213 |
+
|
| 214 |
+
if response.status_code in (200, 204):
|
| 215 |
+
if CACHE_STATS_ENABLED:
|
| 216 |
+
self._stats["deletes"] += 1
|
| 217 |
+
return True
|
| 218 |
+
return False
|
| 219 |
+
|
| 220 |
+
except Exception as exc:
|
| 221 |
+
_logger.error(f"Cache delete error: {exc}")
|
| 222 |
+
return False
|
| 223 |
+
|
| 224 |
+
def _is_expired(self, entry: Dict) -> bool:
|
| 225 |
+
"""Verifica se un entry Γ¨ scaduto."""
|
| 226 |
+
expires_at = entry.get("expires_at")
|
| 227 |
+
if not expires_at:
|
| 228 |
+
return False
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
expires_dt = datetime.fromisoformat(expires_at)
|
| 232 |
+
return datetime.now() > expires_dt
|
| 233 |
+
except:
|
| 234 |
+
return False
|
| 235 |
+
|
| 236 |
+
def _get_ttl_remaining(self, entry: Dict) -> int:
|
| 237 |
+
"""Calcola il TTL rimanente in secondi."""
|
| 238 |
+
expires_at = entry.get("expires_at")
|
| 239 |
+
if not expires_at:
|
| 240 |
+
return 0
|
| 241 |
+
|
| 242 |
+
try:
|
| 243 |
+
expires_dt = datetime.fromisoformat(expires_at)
|
| 244 |
+
remaining = (expires_dt - datetime.now()).total_seconds()
|
| 245 |
+
return max(0, int(remaining))
|
| 246 |
+
except:
|
| 247 |
+
return 0
|
| 248 |
+
|
| 249 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 250 |
+
"""Ritorna statistiche cache."""
|
| 251 |
+
total = self._stats["hits"] + self._stats["misses"]
|
| 252 |
+
hit_rate = (self._stats["hits"] / total * 100) if total > 0 else 0
|
| 253 |
+
|
| 254 |
+
return {
|
| 255 |
+
"enabled": CACHE_ENABLED,
|
| 256 |
+
"hits": self._stats["hits"],
|
| 257 |
+
"misses": self._stats["misses"],
|
| 258 |
+
"sets": self._stats["sets"],
|
| 259 |
+
"deletes": self._stats["deletes"],
|
| 260 |
+
"evictions": self._stats["evictions"],
|
| 261 |
+
"hit_rate_percent": round(hit_rate, 2),
|
| 262 |
+
"total_requests": total,
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
def reset_stats(self):
|
| 266 |
+
"""Resetta le statistiche."""
|
| 267 |
+
self._stats = {
|
| 268 |
+
"hits": 0,
|
| 269 |
+
"misses": 0,
|
| 270 |
+
"sets": 0,
|
| 271 |
+
"deletes": 0,
|
| 272 |
+
"evictions": 0,
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# ββ Cache Wrapper con Fallback βββββββββββββββββββββββββββββββββββββββββββββ
|
| 277 |
+
class CacheManager:
|
| 278 |
+
"""Gestore cache con fallback intelligente."""
|
| 279 |
+
|
| 280 |
+
def __init__(self):
|
| 281 |
+
self.cache_client = CacheClient(SUPABASE_A_URL, SUPABASE_A_KEY) if SUPABASE_A_URL else None
|
| 282 |
+
self.fallback_client = CacheClient(SUPABASE_B_URL, SUPABASE_B_KEY) if SUPABASE_B_URL else None
|
| 283 |
+
|
| 284 |
+
async def get_or_fetch(
|
| 285 |
+
self,
|
| 286 |
+
strategy: CacheStrategy,
|
| 287 |
+
identifier: str,
|
| 288 |
+
fetch_fn: Callable,
|
| 289 |
+
ttl: Optional[int] = None,
|
| 290 |
+
) -> Any:
|
| 291 |
+
"""
|
| 292 |
+
Recupera valore da cache o lo genera con fetch_fn.
|
| 293 |
+
|
| 294 |
+
Logica:
|
| 295 |
+
1. Prova a leggere da cache A
|
| 296 |
+
2. Se miss, chiama fetch_fn
|
| 297 |
+
3. Memorizza risultato in cache A
|
| 298 |
+
4. Ritorna valore
|
| 299 |
+
"""
|
| 300 |
+
# Prova cache A
|
| 301 |
+
if self.cache_client:
|
| 302 |
+
cached = await self.cache_client.get(strategy, identifier)
|
| 303 |
+
if cached:
|
| 304 |
+
_logger.debug(f"Cache hit: {strategy.value}:{identifier}")
|
| 305 |
+
return json.loads(cached["value"]) if isinstance(cached["value"], str) else cached["value"]
|
| 306 |
+
|
| 307 |
+
# Cache miss β fetch
|
| 308 |
+
_logger.debug(f"Cache miss: {strategy.value}:{identifier}, fetching...")
|
| 309 |
+
try:
|
| 310 |
+
value = await fetch_fn() if asyncio.iscoroutinefunction(fetch_fn) else fetch_fn()
|
| 311 |
+
except Exception as exc:
|
| 312 |
+
_logger.error(f"Fetch error: {exc}")
|
| 313 |
+
return None
|
| 314 |
+
|
| 315 |
+
# Memorizza in cache A
|
| 316 |
+
if self.cache_client and value is not None:
|
| 317 |
+
ttl = ttl or CACHE_TTL_BY_STRATEGY.get(strategy, CACHE_TTL_DEFAULT)
|
| 318 |
+
await self.cache_client.set(strategy, identifier, value, ttl)
|
| 319 |
+
|
| 320 |
+
return value
|
| 321 |
+
|
| 322 |
+
async def invalidate(self, strategy: CacheStrategy, identifier: str) -> bool:
|
| 323 |
+
"""Invalida un entry cache."""
|
| 324 |
+
if self.cache_client:
|
| 325 |
+
return await self.cache_client.delete(strategy, identifier)
|
| 326 |
+
return False
|
| 327 |
+
|
| 328 |
+
async def invalidate_pattern(self, strategy: CacheStrategy, pattern: str) -> int:
|
| 329 |
+
"""Invalida tutti gli entry che corrispondono a un pattern."""
|
| 330 |
+
# TODO: Implementare pattern matching
|
| 331 |
+
return 0
|
| 332 |
+
|
| 333 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 334 |
+
"""Ritorna statistiche cache."""
|
| 335 |
+
if self.cache_client:
|
| 336 |
+
return self.cache_client.get_stats()
|
| 337 |
+
return {"enabled": False}
|
| 338 |
+
|
| 339 |
+
def reset_stats(self):
|
| 340 |
+
"""Resetta statistiche."""
|
| 341 |
+
if self.cache_client:
|
| 342 |
+
self.cache_client.reset_stats()
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
# ββ Istanza Globale βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 346 |
+
_cache_manager = CacheManager()
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ββ Funzioni Pubbliche ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 350 |
+
async def get_cached(
|
| 351 |
+
strategy: CacheStrategy,
|
| 352 |
+
identifier: str,
|
| 353 |
+
fetch_fn: Callable,
|
| 354 |
+
ttl: Optional[int] = None,
|
| 355 |
+
) -> Any:
|
| 356 |
+
"""Recupera valore da cache o lo genera."""
|
| 357 |
+
return await _cache_manager.get_or_fetch(strategy, identifier, fetch_fn, ttl)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
async def invalidate_cache(strategy: CacheStrategy, identifier: str) -> bool:
|
| 361 |
+
"""Invalida un entry cache."""
|
| 362 |
+
return await _cache_manager.invalidate(strategy, identifier)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def get_cache_stats() -> Dict[str, Any]:
|
| 366 |
+
"""Ritorna statistiche cache."""
|
| 367 |
+
return _cache_manager.get_stats()
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def reset_cache_stats():
|
| 371 |
+
"""Resetta statistiche cache."""
|
| 372 |
+
_cache_manager.reset_stats()
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
# ββ Decorator per Caching Automatico βββββββββββββββββββββββββββββββββββββββ
|
| 376 |
+
def cached(strategy: CacheStrategy, ttl: Optional[int] = None):
|
| 377 |
+
"""
|
| 378 |
+
Decorator per caching automatico di funzioni.
|
| 379 |
+
|
| 380 |
+
Uso:
|
| 381 |
+
@cached(CacheStrategy.QUERY, ttl=600)
|
| 382 |
+
async def get_user_data(user_id: str):
|
| 383 |
+
return await fetch_user_from_db(user_id)
|
| 384 |
+
"""
|
| 385 |
+
def decorator(func: Callable):
|
| 386 |
+
async def wrapper(*args, **kwargs):
|
| 387 |
+
# Genera identifier da args/kwargs
|
| 388 |
+
identifier = f"{func.__name__}:{str(args)}:{str(kwargs)}"
|
| 389 |
+
|
| 390 |
+
# Usa cache manager
|
| 391 |
+
return await _cache_manager.get_or_fetch(
|
| 392 |
+
strategy,
|
| 393 |
+
identifier,
|
| 394 |
+
lambda: func(*args, **kwargs),
|
| 395 |
+
ttl,
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
return wrapper
|
| 399 |
+
|
| 400 |
+
return decorator
|
api/database_router.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
database_router.py β Router Query Intelligente per Separazione Workload Supabase A/B/C/D
|
| 3 |
+
|
| 4 |
+
Architettura:
|
| 5 |
+
A: Analytics/Cache/Read-Heavy (reporting, dashboard, cache distribuito)
|
| 6 |
+
B: Sync/State/Transazioni (stato globale, sincronizzazione cluster β PRIMARY)
|
| 7 |
+
C: Memory/RAG/Embeddings (backend memoria, vector search, skill index)
|
| 8 |
+
D: Audit/Logging/Compliance (event log, audit trail, compliance records)
|
| 9 |
+
|
| 10 |
+
Routing Logic:
|
| 11 |
+
1. Query di LETTURA (SELECT) β Preferisci A (read replica), fallback a B
|
| 12 |
+
2. Query di SCRITTURA (INSERT/UPDATE) β Usa B (PRIMARY)
|
| 13 |
+
3. Query su MEMORIA/RAG (skill_memory, embeddings, conversations) β Usa C
|
| 14 |
+
4. Query su AUDIT/LOG (audit_events, compliance_log) β Usa D
|
| 15 |
+
5. Query di SINCRONIZZAZIONE (cluster_state, global_state) β Usa B
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import asyncio
|
| 19 |
+
import os
|
| 20 |
+
import logging
|
| 21 |
+
import re as _re
|
| 22 |
+
from typing import Optional, Literal
|
| 23 |
+
from enum import Enum
|
| 24 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 25 |
+
from pydantic import BaseModel
|
| 26 |
+
|
| 27 |
+
router = APIRouter(prefix="/api/database", tags=["database"])
|
| 28 |
+
_logger = logging.getLogger("database_router")
|
| 29 |
+
|
| 30 |
+
# βββ Enumerazione Nodi Supabase βββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
class SupabaseNode(str, Enum):
|
| 32 |
+
A = "A" # Analytics/Cache
|
| 33 |
+
B = "B" # PRIMARY (Sync/State)
|
| 34 |
+
C = "C" # Memory/RAG
|
| 35 |
+
D = "D" # Audit/Logging
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# βββ Configurazione Nodi ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
SUPABASE_CONFIG = {
|
| 40 |
+
"A": {
|
| 41 |
+
"url": os.getenv("SUPABASE_URL_A", ""),
|
| 42 |
+
"key": os.getenv("SUPABASE_KEY_A", ""),
|
| 43 |
+
"role": "Analytics/Cache (Read-Heavy)",
|
| 44 |
+
"priority": 1, # Preferito per letture
|
| 45 |
+
},
|
| 46 |
+
"B": {
|
| 47 |
+
"url": os.getenv("SUPABASE_URL", ""), # PRIMARY
|
| 48 |
+
"key": os.getenv("SUPABASE_KEY", ""),
|
| 49 |
+
"role": "Sync/State (PRIMARY)",
|
| 50 |
+
"priority": 0, # Fallback universale
|
| 51 |
+
},
|
| 52 |
+
"C": {
|
| 53 |
+
"url": os.getenv("SUPABASE_URL_C", ""),
|
| 54 |
+
"key": os.getenv("SUPABASE_KEY_C", ""),
|
| 55 |
+
"role": "Memory/RAG/Embeddings",
|
| 56 |
+
"priority": 2,
|
| 57 |
+
},
|
| 58 |
+
"D": {
|
| 59 |
+
"url": os.getenv("SUPABASE_URL_D", ""),
|
| 60 |
+
"key": os.getenv("SUPABASE_KEY_D", ""),
|
| 61 |
+
"role": "Audit/Logging/Compliance",
|
| 62 |
+
"priority": 3,
|
| 63 |
+
},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
# βββ Keyword Pericolosi (per read-only) βββββββββββββββββββββββββββββββββββ
|
| 67 |
+
_DANGEROUS = frozenset(
|
| 68 |
+
{"drop", "truncate", "delete", "update", "insert", "alter", "create", "grant", "revoke"}
|
| 69 |
+
)
|
| 70 |
+
_MAX_ROWS = 500
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# βββ Modelli Pydantic βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
+
class QueryRequest(BaseModel):
|
| 75 |
+
sql: str
|
| 76 |
+
params: list = []
|
| 77 |
+
read_only: bool = True
|
| 78 |
+
preferred_node: Optional[SupabaseNode] = None # Override routing logic
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class QueryResponse(BaseModel):
|
| 82 |
+
ok: bool
|
| 83 |
+
rows: list = []
|
| 84 |
+
columns: list = []
|
| 85 |
+
count: int = 0
|
| 86 |
+
truncated: bool = False
|
| 87 |
+
node_used: Optional[str] = None
|
| 88 |
+
error: Optional[str] = None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# βββ Funzioni Utility βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 92 |
+
def _is_dangerous(sql: str) -> Optional[str]:
|
| 93 |
+
"""Rilevamento keyword pericolose robusto contro CTE e multi-spazio."""
|
| 94 |
+
s_norm = " ".join(sql.strip().lower().split())
|
| 95 |
+
tokens = _re.split(r"[\s\(\),;]+", s_norm)
|
| 96 |
+
for tok in tokens:
|
| 97 |
+
if tok in _DANGEROUS:
|
| 98 |
+
return tok
|
| 99 |
+
for m in _re.finditer(r"\bas\s*\(\s*(\w+)", s_norm):
|
| 100 |
+
first_word = m.group(1).lower()
|
| 101 |
+
if first_word in _DANGEROUS:
|
| 102 |
+
return first_word
|
| 103 |
+
return None
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _detect_query_type(sql: str) -> Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"]:
|
| 107 |
+
"""Rileva il tipo di query (SELECT, INSERT, UPDATE, DELETE, OTHER)."""
|
| 108 |
+
s_norm = " ".join(sql.strip().upper().split())
|
| 109 |
+
if s_norm.startswith("SELECT"):
|
| 110 |
+
return "SELECT"
|
| 111 |
+
elif s_norm.startswith("INSERT"):
|
| 112 |
+
return "INSERT"
|
| 113 |
+
elif s_norm.startswith("UPDATE"):
|
| 114 |
+
return "UPDATE"
|
| 115 |
+
elif s_norm.startswith("DELETE"):
|
| 116 |
+
return "DELETE"
|
| 117 |
+
return "OTHER"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _detect_table_context(sql: str) -> Optional[str]:
|
| 121 |
+
"""Rileva il contesto della tabella per routing intelligente."""
|
| 122 |
+
sql_lower = sql.lower()
|
| 123 |
+
|
| 124 |
+
# Tabelle di memoria/RAG β Nodo C
|
| 125 |
+
if any(t in sql_lower for t in ["skill_memory", "embeddings", "conversations", "rag_index", "vector_store"]):
|
| 126 |
+
return "C"
|
| 127 |
+
|
| 128 |
+
# Tabelle di audit/logging β Nodo D
|
| 129 |
+
if any(t in sql_lower for t in ["audit_events", "audit_log", "compliance_log", "event_log", "activity_log"]):
|
| 130 |
+
return "D"
|
| 131 |
+
|
| 132 |
+
# Tabelle di stato globale β Nodo B
|
| 133 |
+
if any(t in sql_lower for t in ["cluster_state", "global_state", "sync_state", "agent_state", "daemon_status"]):
|
| 134 |
+
return "B"
|
| 135 |
+
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _choose_node(
|
| 140 |
+
query_type: Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"],
|
| 141 |
+
table_context: Optional[str],
|
| 142 |
+
preferred_node: Optional[SupabaseNode],
|
| 143 |
+
) -> SupabaseNode:
|
| 144 |
+
"""
|
| 145 |
+
Logica di routing intelligente per scegliere il nodo Supabase.
|
| 146 |
+
|
| 147 |
+
PrioritΓ :
|
| 148 |
+
1. preferred_node (override esplicito)
|
| 149 |
+
2. table_context (rilevamento tabella)
|
| 150 |
+
3. query_type (tipo di query)
|
| 151 |
+
4. Fallback a B (PRIMARY)
|
| 152 |
+
"""
|
| 153 |
+
# 1. Override esplicito
|
| 154 |
+
if preferred_node:
|
| 155 |
+
return preferred_node
|
| 156 |
+
|
| 157 |
+
# 2. Routing per contesto tabella
|
| 158 |
+
if table_context:
|
| 159 |
+
return SupabaseNode(table_context)
|
| 160 |
+
|
| 161 |
+
# 3. Routing per tipo query
|
| 162 |
+
if query_type == "SELECT":
|
| 163 |
+
# Preferisci A (read replica) se disponibile, altrimenti B
|
| 164 |
+
if SUPABASE_CONFIG["A"]["url"]:
|
| 165 |
+
return SupabaseNode.A
|
| 166 |
+
return SupabaseNode.B
|
| 167 |
+
elif query_type in ("INSERT", "UPDATE", "DELETE"):
|
| 168 |
+
# Sempre su B (PRIMARY)
|
| 169 |
+
return SupabaseNode.B
|
| 170 |
+
|
| 171 |
+
# 4. Fallback a B (PRIMARY)
|
| 172 |
+
return SupabaseNode.B
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# βββ Endpoint Principale ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
@router.post("/query", response_model=QueryResponse)
|
| 177 |
+
async def database_query(req: QueryRequest, request: Request):
|
| 178 |
+
"""
|
| 179 |
+
Endpoint query con routing intelligente tra nodi Supabase A/B/C/D.
|
| 180 |
+
|
| 181 |
+
Parametri:
|
| 182 |
+
- sql: query SQL
|
| 183 |
+
- params: parametri query
|
| 184 |
+
- read_only: blocca query pericolose (default: true)
|
| 185 |
+
- preferred_node: forza un nodo specifico (opzionale)
|
| 186 |
+
|
| 187 |
+
Ritorna:
|
| 188 |
+
- ok: successo
|
| 189 |
+
- rows: righe risultato
|
| 190 |
+
- columns: nomi colonne
|
| 191 |
+
- count: numero righe
|
| 192 |
+
- truncated: se risultato Γ¨ stato troncato
|
| 193 |
+
- node_used: nodo Supabase utilizzato
|
| 194 |
+
"""
|
| 195 |
+
# Verifica token interno
|
| 196 |
+
_internal_token = os.getenv("INTERNAL_TOKEN", "")
|
| 197 |
+
if _internal_token and request.headers.get("X-Internal-Token") != _internal_token:
|
| 198 |
+
raise HTTPException(401, "Unauthorized")
|
| 199 |
+
|
| 200 |
+
# Rileva tipo query e contesto
|
| 201 |
+
query_type = _detect_query_type(req.sql)
|
| 202 |
+
table_context = _detect_table_context(req.sql)
|
| 203 |
+
|
| 204 |
+
# Scegli nodo
|
| 205 |
+
chosen_node = _choose_node(query_type, table_context, req.preferred_node)
|
| 206 |
+
|
| 207 |
+
# Verifica configurazione nodo
|
| 208 |
+
node_config = SUPABASE_CONFIG.get(chosen_node.value)
|
| 209 |
+
if not node_config or not node_config["url"]:
|
| 210 |
+
# Fallback a B se nodo non configurato
|
| 211 |
+
if chosen_node != SupabaseNode.B:
|
| 212 |
+
_logger.warning(
|
| 213 |
+
f"Nodo {chosen_node.value} non configurato, fallback a B. "
|
| 214 |
+
f"Configura SUPABASE_URL_{chosen_node.value} e SUPABASE_KEY_{chosen_node.value}."
|
| 215 |
+
)
|
| 216 |
+
chosen_node = SupabaseNode.B
|
| 217 |
+
node_config = SUPABASE_CONFIG["B"]
|
| 218 |
+
|
| 219 |
+
if not node_config["url"]:
|
| 220 |
+
return QueryResponse(
|
| 221 |
+
ok=False,
|
| 222 |
+
error=f"Nodo {chosen_node.value} non configurato. Imposta SUPABASE_URL_{chosen_node.value}.",
|
| 223 |
+
node_used=chosen_node.value,
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Verifica read-only
|
| 227 |
+
if req.read_only:
|
| 228 |
+
kw = _is_dangerous(req.sql)
|
| 229 |
+
if kw:
|
| 230 |
+
return QueryResponse(
|
| 231 |
+
ok=False,
|
| 232 |
+
error=f"Query bloccata (read-only): '{kw.upper()}' non consentito.",
|
| 233 |
+
node_used=chosen_node.value,
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# Esegui query
|
| 237 |
+
try:
|
| 238 |
+
result = await _execute_query(
|
| 239 |
+
node_config["url"],
|
| 240 |
+
node_config["key"],
|
| 241 |
+
req.sql,
|
| 242 |
+
req.params,
|
| 243 |
+
)
|
| 244 |
+
result["node_used"] = chosen_node.value
|
| 245 |
+
return QueryResponse(**result)
|
| 246 |
+
except Exception as e:
|
| 247 |
+
_logger.error(f"Errore query su nodo {chosen_node.value}: {str(e)}")
|
| 248 |
+
return QueryResponse(
|
| 249 |
+
ok=False,
|
| 250 |
+
error=str(e)[:400],
|
| 251 |
+
node_used=chosen_node.value,
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# βββ Esecuzione Query (Supabase PostgreSQL) βββββββββββββββββββββββββββββββ
|
| 256 |
+
async def _execute_query(url: str, key: str, sql: str, params: list) -> dict:
|
| 257 |
+
"""Esegue query su Supabase PostgreSQL."""
|
| 258 |
+
try:
|
| 259 |
+
import psycopg2
|
| 260 |
+
import psycopg2.extras
|
| 261 |
+
except ImportError:
|
| 262 |
+
return {
|
| 263 |
+
"ok": False,
|
| 264 |
+
"error": "psycopg2 non installato. Aggiungi 'psycopg2-binary' a requirements.txt.",
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
def _run():
|
| 268 |
+
conn = psycopg2.connect(url)
|
| 269 |
+
try:
|
| 270 |
+
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
| 271 |
+
cur.execute(sql, params or None)
|
| 272 |
+
try:
|
| 273 |
+
rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
|
| 274 |
+
cols = [d.name for d in (cur.description or [])]
|
| 275 |
+
except psycopg2.ProgrammingError:
|
| 276 |
+
rows, cols = [], []
|
| 277 |
+
conn.commit()
|
| 278 |
+
finally:
|
| 279 |
+
conn.close()
|
| 280 |
+
return rows, cols
|
| 281 |
+
|
| 282 |
+
rows, cols = await asyncio.to_thread(_run)
|
| 283 |
+
return {
|
| 284 |
+
"ok": True,
|
| 285 |
+
"rows": rows,
|
| 286 |
+
"columns": cols,
|
| 287 |
+
"count": len(rows),
|
| 288 |
+
"truncated": len(rows) == _MAX_ROWS,
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
# βββ Endpoint Debug (info nodi) βββββββββββββββββββββββββββββββββββββββββββ
|
| 293 |
+
@router.get("/nodes/status")
|
| 294 |
+
async def nodes_status():
|
| 295 |
+
"""Ritorna lo stato di configurazione di tutti i nodi Supabase."""
|
| 296 |
+
status = {}
|
| 297 |
+
for node_id, config in SUPABASE_CONFIG.items():
|
| 298 |
+
status[node_id] = {
|
| 299 |
+
"role": config["role"],
|
| 300 |
+
"configured": bool(config["url"]),
|
| 301 |
+
"url_preview": config["url"][:30] + "..." if config["url"] else "NOT SET",
|
| 302 |
+
}
|
| 303 |
+
return {"nodes": status}
|
api/global_state_sync_optimized.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
global_state_sync_optimized.py β Sincronizzazione Selettiva Ottimizzata per Workload Separati
|
| 3 |
+
|
| 4 |
+
Architettura Separazione Workload:
|
| 5 |
+
A: Analytics/Cache/Read-Heavy (NO sincronizzazione, solo letture)
|
| 6 |
+
B: Sync/State/Transazioni (PRIMARY β sincronizzazione completa)
|
| 7 |
+
C: Memory/RAG/Embeddings (sincronizzazione memoria e embeddings)
|
| 8 |
+
D: Audit/Logging/Compliance (sincronizzazione audit-only, append-only)
|
| 9 |
+
|
| 10 |
+
Strategia Sincronizzazione:
|
| 11 |
+
1. B β A: Nessuna (A Γ¨ read-only, non genera stato)
|
| 12 |
+
2. B β C: Sincronizzazione memoria/skill (bidirezionale)
|
| 13 |
+
3. B β D: Sincronizzazione audit (unidirezionale, D β B)
|
| 14 |
+
4. C β B: Sincronizzazione stato globale (unidirezionale, B β C)
|
| 15 |
+
5. D β B: Sincronizzazione eventi (unidirezionale, B β D)
|
| 16 |
+
|
| 17 |
+
Benefici:
|
| 18 |
+
- Riduce traffico di sincronizzazione (solo tabelle rilevanti)
|
| 19 |
+
- Evita conflitti di write (ogni nodo scrive su tabelle specifiche)
|
| 20 |
+
- Mantiene audit immutabile (D append-only)
|
| 21 |
+
- Massimizza throughput (parallelizzazione per nodo)
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import os
|
| 25 |
+
import asyncio
|
| 26 |
+
import logging
|
| 27 |
+
from typing import Optional, Dict, List, Any, Literal
|
| 28 |
+
from datetime import datetime, timedelta
|
| 29 |
+
from enum import Enum
|
| 30 |
+
import json
|
| 31 |
+
|
| 32 |
+
_logger = logging.getLogger("global_state_sync_optimized")
|
| 33 |
+
|
| 34 |
+
# ββ Enumerazione Nodi ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
class SupabaseNode(str, Enum):
|
| 36 |
+
A = "A" # Analytics/Cache
|
| 37 |
+
B = "B" # PRIMARY (Sync/State)
|
| 38 |
+
C = "C" # Memory/RAG
|
| 39 |
+
D = "D" # Audit/Logging
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ββ Configurazione Nodi βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
SUPABASE_CONFIG = {
|
| 44 |
+
"A": {
|
| 45 |
+
"url": os.getenv("SUPABASE_URL_A", ""),
|
| 46 |
+
"key": os.getenv("SUPABASE_KEY_A", ""),
|
| 47 |
+
"role": "Analytics/Cache",
|
| 48 |
+
"sync_enabled": False, # A non sincronizza (read-only)
|
| 49 |
+
},
|
| 50 |
+
"B": {
|
| 51 |
+
"url": os.getenv("SUPABASE_URL", ""), # PRIMARY
|
| 52 |
+
"key": os.getenv("SUPABASE_KEY", ""),
|
| 53 |
+
"role": "Sync/State (PRIMARY)",
|
| 54 |
+
"sync_enabled": True,
|
| 55 |
+
},
|
| 56 |
+
"C": {
|
| 57 |
+
"url": os.getenv("SUPABASE_URL_C", ""),
|
| 58 |
+
"key": os.getenv("SUPABASE_KEY_C", ""),
|
| 59 |
+
"role": "Memory/RAG",
|
| 60 |
+
"sync_enabled": True,
|
| 61 |
+
},
|
| 62 |
+
"D": {
|
| 63 |
+
"url": os.getenv("SUPABASE_URL_D", ""),
|
| 64 |
+
"key": os.getenv("SUPABASE_KEY_D", ""),
|
| 65 |
+
"role": "Audit/Logging",
|
| 66 |
+
"sync_enabled": True,
|
| 67 |
+
},
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# ββ Mapping Tabelle β Nodi βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
TABLE_NODE_MAPPING = {
|
| 72 |
+
# Nodo B (PRIMARY) β Stato Globale
|
| 73 |
+
"cluster_state": "B",
|
| 74 |
+
"global_state": "B",
|
| 75 |
+
"sync_state": "B",
|
| 76 |
+
"agent_state": "B",
|
| 77 |
+
"daemon_status": "B",
|
| 78 |
+
|
| 79 |
+
# Nodo C β Memoria e RAG
|
| 80 |
+
"agent_memory": "C",
|
| 81 |
+
"skill_memory": "C",
|
| 82 |
+
"embeddings": "C",
|
| 83 |
+
"conversations": "C",
|
| 84 |
+
"rag_index": "C",
|
| 85 |
+
"vector_store": "C",
|
| 86 |
+
|
| 87 |
+
# Nodo D β Audit e Logging
|
| 88 |
+
"audit_events": "D",
|
| 89 |
+
"audit_log": "D",
|
| 90 |
+
"compliance_log": "D",
|
| 91 |
+
"event_log": "D",
|
| 92 |
+
"activity_log": "D",
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
# ββ Configurazione Sincronizzazione ββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
SYNC_RULES = {
|
| 97 |
+
# (source_node, target_node): [tabelle da sincronizzare]
|
| 98 |
+
("B", "C"): ["agent_memory", "skill_memory", "cluster_state"], # B β C: stato globale
|
| 99 |
+
("C", "B"): ["agent_memory", "skill_memory"], # C β B: memoria aggiornata
|
| 100 |
+
("B", "D"): ["audit_events", "event_log"], # B β D: eventi
|
| 101 |
+
("D", "B"): [], # D β B: nessuna (audit Γ¨ append-only)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
SYNC_ENABLED = os.getenv("GLOBAL_STATE_SYNC_ENABLED", "true").lower() == "true"
|
| 105 |
+
SYNC_INTERVAL_SECONDS = int(os.getenv("SYNC_INTERVAL_SECONDS", "30"))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ββ Client Supabase βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
+
class SupabaseClient:
|
| 110 |
+
"""Client per accedere a un singolo database Supabase."""
|
| 111 |
+
|
| 112 |
+
def __init__(self, url: str, key: str, node_id: str):
|
| 113 |
+
self.url = url
|
| 114 |
+
self.key = key
|
| 115 |
+
self.node_id = node_id
|
| 116 |
+
self.base_url = f"{url}/rest/v1" if url else None
|
| 117 |
+
|
| 118 |
+
async def query(self, table: str, filters: Optional[Dict] = None) -> List[Dict]:
|
| 119 |
+
"""Esegue una query SELECT su una tabella."""
|
| 120 |
+
if not self.base_url:
|
| 121 |
+
return []
|
| 122 |
+
|
| 123 |
+
import httpx
|
| 124 |
+
|
| 125 |
+
url = f"{self.base_url}/{table}"
|
| 126 |
+
headers = {
|
| 127 |
+
"apikey": self.key,
|
| 128 |
+
"Authorization": f"Bearer {self.key}",
|
| 129 |
+
"Content-Type": "application/json",
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
async with httpx.AsyncClient() as client:
|
| 134 |
+
response = await client.get(url, headers=headers, timeout=10.0)
|
| 135 |
+
if response.status_code == 200:
|
| 136 |
+
return response.json()
|
| 137 |
+
else:
|
| 138 |
+
_logger.warning(f"Supabase {self.node_id} query failed: {response.status_code}")
|
| 139 |
+
return []
|
| 140 |
+
except Exception as exc:
|
| 141 |
+
_logger.error(f"Supabase {self.node_id} error: {exc}")
|
| 142 |
+
return []
|
| 143 |
+
|
| 144 |
+
async def insert(self, table: str, data: Dict) -> bool:
|
| 145 |
+
"""Inserisce un record in una tabella."""
|
| 146 |
+
if not self.base_url:
|
| 147 |
+
return False
|
| 148 |
+
|
| 149 |
+
import httpx
|
| 150 |
+
|
| 151 |
+
url = f"{self.base_url}/{table}"
|
| 152 |
+
headers = {
|
| 153 |
+
"apikey": self.key,
|
| 154 |
+
"Authorization": f"Bearer {self.key}",
|
| 155 |
+
"Content-Type": "application/json",
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
async with httpx.AsyncClient() as client:
|
| 160 |
+
response = await client.post(url, json=data, headers=headers, timeout=10.0)
|
| 161 |
+
return response.status_code in (200, 201)
|
| 162 |
+
except Exception as exc:
|
| 163 |
+
_logger.error(f"Supabase {self.node_id} insert error: {exc}")
|
| 164 |
+
return False
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ββ Sincronizzazione Selettiva βββββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
+
class SelectiveSyncManager:
|
| 169 |
+
"""Gestisce la sincronizzazione selettiva tra nodi."""
|
| 170 |
+
|
| 171 |
+
def __init__(self):
|
| 172 |
+
self.clients = {}
|
| 173 |
+
self._enabled = SYNC_ENABLED
|
| 174 |
+
self._last_sync = {}
|
| 175 |
+
|
| 176 |
+
for node_id, config in SUPABASE_CONFIG.items():
|
| 177 |
+
if config["url"] and config["key"]:
|
| 178 |
+
self.clients[node_id] = SupabaseClient(config["url"], config["key"], node_id)
|
| 179 |
+
|
| 180 |
+
async def sync_all(self) -> Dict[str, Any]:
|
| 181 |
+
"""Esegue la sincronizzazione completa tra tutti i nodi."""
|
| 182 |
+
if not self._enabled or not self.clients:
|
| 183 |
+
return {"ok": False, "error": "Sync disabled or no clients configured"}
|
| 184 |
+
|
| 185 |
+
results = {
|
| 186 |
+
"ok": True,
|
| 187 |
+
"synced_at": datetime.now().isoformat(),
|
| 188 |
+
"syncs": [],
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
# Esegui sincronizzazioni secondo le regole
|
| 192 |
+
for (source, target), tables in SYNC_RULES.items():
|
| 193 |
+
if not tables:
|
| 194 |
+
continue # Salta se nessuna tabella da sincronizzare
|
| 195 |
+
|
| 196 |
+
source_client = self.clients.get(source)
|
| 197 |
+
target_client = self.clients.get(target)
|
| 198 |
+
|
| 199 |
+
if not source_client or not target_client:
|
| 200 |
+
continue
|
| 201 |
+
|
| 202 |
+
sync_result = await self._sync_nodes(source_client, target_client, tables)
|
| 203 |
+
results["syncs"].append(sync_result)
|
| 204 |
+
|
| 205 |
+
return results
|
| 206 |
+
|
| 207 |
+
async def _sync_nodes(
|
| 208 |
+
self,
|
| 209 |
+
source: SupabaseClient,
|
| 210 |
+
target: SupabaseClient,
|
| 211 |
+
tables: List[str],
|
| 212 |
+
) -> Dict[str, Any]:
|
| 213 |
+
"""Sincronizza tabelle specifiche da source a target."""
|
| 214 |
+
result = {
|
| 215 |
+
"source": source.node_id,
|
| 216 |
+
"target": target.node_id,
|
| 217 |
+
"tables": tables,
|
| 218 |
+
"synced_count": 0,
|
| 219 |
+
"errors": [],
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
for table in tables:
|
| 223 |
+
try:
|
| 224 |
+
# Leggi da source
|
| 225 |
+
rows = await source.query(table)
|
| 226 |
+
if not rows:
|
| 227 |
+
continue
|
| 228 |
+
|
| 229 |
+
# Scrivi su target (upsert logic)
|
| 230 |
+
for row in rows:
|
| 231 |
+
success = await target.insert(table, row)
|
| 232 |
+
if success:
|
| 233 |
+
result["synced_count"] += 1
|
| 234 |
+
else:
|
| 235 |
+
result["errors"].append(f"Failed to sync {table} row")
|
| 236 |
+
|
| 237 |
+
except Exception as exc:
|
| 238 |
+
result["errors"].append(f"Error syncing {table}: {str(exc)}")
|
| 239 |
+
_logger.error(f"Sync error {source.node_id} β {target.node_id} ({table}): {exc}")
|
| 240 |
+
|
| 241 |
+
return result
|
| 242 |
+
|
| 243 |
+
async def sync_memory(self, session_id: str) -> Dict[str, Any]:
|
| 244 |
+
"""Sincronizza memoria per una sessione specifica (B β C)."""
|
| 245 |
+
if not self._enabled:
|
| 246 |
+
return {"ok": False}
|
| 247 |
+
|
| 248 |
+
source_client = self.clients.get("B")
|
| 249 |
+
target_client = self.clients.get("C")
|
| 250 |
+
|
| 251 |
+
if not source_client or not target_client:
|
| 252 |
+
return {"ok": False, "error": "B or C not configured"}
|
| 253 |
+
|
| 254 |
+
try:
|
| 255 |
+
# Leggi memoria da B
|
| 256 |
+
rows = await source_client.query("agent_memory", {"session_id": session_id})
|
| 257 |
+
|
| 258 |
+
# Scrivi su C
|
| 259 |
+
for row in rows:
|
| 260 |
+
await target_client.insert("agent_memory", row)
|
| 261 |
+
|
| 262 |
+
return {
|
| 263 |
+
"ok": True,
|
| 264 |
+
"session_id": session_id,
|
| 265 |
+
"synced_rows": len(rows),
|
| 266 |
+
}
|
| 267 |
+
except Exception as exc:
|
| 268 |
+
_logger.error(f"Memory sync error: {exc}")
|
| 269 |
+
return {"ok": False, "error": str(exc)}
|
| 270 |
+
|
| 271 |
+
async def log_audit_event(self, event: Dict) -> bool:
|
| 272 |
+
"""Registra un evento di audit su D (append-only)."""
|
| 273 |
+
if not self._enabled:
|
| 274 |
+
return False
|
| 275 |
+
|
| 276 |
+
target_client = self.clients.get("D")
|
| 277 |
+
if not target_client:
|
| 278 |
+
return False
|
| 279 |
+
|
| 280 |
+
event["timestamp"] = datetime.now().isoformat()
|
| 281 |
+
return await target_client.insert("audit_events", event)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# ββ Scheduler Sincronizzazione βββββββββββββββββββββββββββββββββββββββββββββ
|
| 285 |
+
class SyncScheduler:
|
| 286 |
+
"""Scheduler per sincronizzazione periodica."""
|
| 287 |
+
|
| 288 |
+
def __init__(self, manager: SelectiveSyncManager):
|
| 289 |
+
self.manager = manager
|
| 290 |
+
self._running = False
|
| 291 |
+
|
| 292 |
+
async def start(self):
|
| 293 |
+
"""Avvia il scheduler di sincronizzazione."""
|
| 294 |
+
self._running = True
|
| 295 |
+
_logger.info(f"Starting sync scheduler (interval: {SYNC_INTERVAL_SECONDS}s)")
|
| 296 |
+
|
| 297 |
+
while self._running:
|
| 298 |
+
try:
|
| 299 |
+
result = await self.manager.sync_all()
|
| 300 |
+
if result["ok"]:
|
| 301 |
+
_logger.debug(f"Sync completed: {len(result['syncs'])} operations")
|
| 302 |
+
else:
|
| 303 |
+
_logger.warning(f"Sync failed: {result.get('error')}")
|
| 304 |
+
except Exception as exc:
|
| 305 |
+
_logger.error(f"Sync scheduler error: {exc}")
|
| 306 |
+
|
| 307 |
+
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
|
| 308 |
+
|
| 309 |
+
def stop(self):
|
| 310 |
+
"""Ferma il scheduler."""
|
| 311 |
+
self._running = False
|
| 312 |
+
_logger.info("Sync scheduler stopped")
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# ββ Istanza Globale βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 316 |
+
_sync_manager = SelectiveSyncManager()
|
| 317 |
+
_sync_scheduler = SyncScheduler(_sync_manager)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# ββ Funzioni Pubbliche ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
+
async def get_unified_memory(session_id: str) -> Dict[str, Any]:
|
| 322 |
+
"""Recupera memoria unificata per una sessione."""
|
| 323 |
+
return await _sync_manager.sync_memory(session_id)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
async def sync_all() -> Dict[str, Any]:
|
| 327 |
+
"""Esegue sincronizzazione completa."""
|
| 328 |
+
return await _sync_manager.sync_all()
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
async def log_audit(event: Dict) -> bool:
|
| 332 |
+
"""Registra un evento di audit."""
|
| 333 |
+
return await _sync_manager.log_audit_event(event)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
async def start_scheduler():
|
| 337 |
+
"""Avvia il scheduler di sincronizzazione."""
|
| 338 |
+
await _sync_scheduler.start()
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def stop_scheduler():
|
| 342 |
+
"""Ferma il scheduler."""
|
| 343 |
+
_sync_scheduler.stop()
|
api/global_state_sync_with_oracle.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
global_state_sync_with_oracle.py β Sincronizzazione Globale con Nodo E (Oracle)
|
| 3 |
+
|
| 4 |
+
Integra il Nodo E (Oracle Cloud) nella logica di sincronizzazione del cluster.
|
| 5 |
+
|
| 6 |
+
Architettura:
|
| 7 |
+
B (PRIMARY): Stato globale, transazioni critiche
|
| 8 |
+
C (Memory/RAG): Embeddings, conversazioni
|
| 9 |
+
D (Audit): Event log, compliance
|
| 10 |
+
E (Compute): Task distribuiti, carichi pesanti
|
| 11 |
+
A (Cache): Read-only, analytics
|
| 12 |
+
|
| 13 |
+
Flussi di Sincronizzazione:
|
| 14 |
+
- B β C: Memoria bidirezionale
|
| 15 |
+
- B β D: Audit unidirezionale
|
| 16 |
+
- B β E: Task distribuiti
|
| 17 |
+
- E β B: Risultati task
|
| 18 |
+
- A: Nessuna (read-only)
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
import asyncio
|
| 23 |
+
import logging
|
| 24 |
+
from typing import Dict, List, Any, Optional
|
| 25 |
+
from datetime import datetime, timedelta
|
| 26 |
+
from enum import Enum
|
| 27 |
+
import json
|
| 28 |
+
|
| 29 |
+
_logger = logging.getLogger("global_state_sync_oracle")
|
| 30 |
+
|
| 31 |
+
# ββ Configurazione Nodi ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
ORACLE_NODE_E_ENABLED = os.getenv("ORACLE_NODE_E_ENABLED", "true").lower() == "true"
|
| 33 |
+
ORACLE_NODE_E_IP = os.getenv("ORACLE_NODE_E_IP", "80.225.89.217")
|
| 34 |
+
ORACLE_NODE_E_PORT = int(os.getenv("ORACLE_NODE_E_PORT", "8080"))
|
| 35 |
+
|
| 36 |
+
SYNC_INTERVAL_SECONDS = int(os.getenv("SYNC_INTERVAL_SECONDS", "30"))
|
| 37 |
+
SYNC_BATCH_SIZE = int(os.getenv("SYNC_BATCH_SIZE", "100"))
|
| 38 |
+
SYNC_TIMEOUT_SECONDS = int(os.getenv("SYNC_TIMEOUT_SECONDS", "60"))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ββ Enumerazione Tipo Sincronizzazione βββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
class SyncType(str, Enum):
|
| 43 |
+
MEMORY = "memory" # B β C
|
| 44 |
+
AUDIT = "audit" # B β D
|
| 45 |
+
TASK = "task" # B β E
|
| 46 |
+
RESULT = "result" # E β B
|
| 47 |
+
CACHE = "cache" # A (read-only)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ββ Classe Sincronizzatore ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
class GlobalStateSyncWithOracle:
|
| 52 |
+
"""Gestore sincronizzazione globale con Nodo E."""
|
| 53 |
+
|
| 54 |
+
def __init__(self):
|
| 55 |
+
self.sync_history = []
|
| 56 |
+
self.pending_tasks = []
|
| 57 |
+
self.last_sync_time = {}
|
| 58 |
+
self.sync_stats = {
|
| 59 |
+
"total_syncs": 0,
|
| 60 |
+
"successful_syncs": 0,
|
| 61 |
+
"failed_syncs": 0,
|
| 62 |
+
"total_items_synced": 0,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async def sync_memory(self, session_id: str) -> Dict[str, Any]:
|
| 66 |
+
"""Sincronizza memoria tra B e C."""
|
| 67 |
+
try:
|
| 68 |
+
_logger.info(f"Sincronizzazione memoria: {session_id}")
|
| 69 |
+
|
| 70 |
+
# Recupera memoria da B
|
| 71 |
+
memory_b = await self._fetch_from_node_b(f"agent_memory:{session_id}")
|
| 72 |
+
|
| 73 |
+
# Sincronizza su C
|
| 74 |
+
await self._sync_to_node_c(f"agent_memory:{session_id}", memory_b)
|
| 75 |
+
|
| 76 |
+
# Sincronizza su E (se abilitato e ha task correlati)
|
| 77 |
+
if ORACLE_NODE_E_ENABLED:
|
| 78 |
+
await self._sync_to_node_e(f"memory:{session_id}", memory_b)
|
| 79 |
+
|
| 80 |
+
self.sync_stats["successful_syncs"] += 1
|
| 81 |
+
self.sync_stats["total_items_synced"] += 1
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
"ok": True,
|
| 85 |
+
"session_id": session_id,
|
| 86 |
+
"synced_to": ["B", "C", "E"] if ORACLE_NODE_E_ENABLED else ["B", "C"],
|
| 87 |
+
}
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
_logger.error(f"Errore sincronizzazione memoria: {exc}")
|
| 90 |
+
self.sync_stats["failed_syncs"] += 1
|
| 91 |
+
return {"ok": False, "error": str(exc)}
|
| 92 |
+
|
| 93 |
+
async def sync_audit(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
| 94 |
+
"""Sincronizza evento audit da B a D."""
|
| 95 |
+
try:
|
| 96 |
+
_logger.info(f"Sincronizzazione audit: {event.get('event_type')}")
|
| 97 |
+
|
| 98 |
+
# Sincronizza su D (append-only)
|
| 99 |
+
await self._sync_to_node_d("audit_event", event)
|
| 100 |
+
|
| 101 |
+
# Log anche su E se Γ¨ un evento critico
|
| 102 |
+
if ORACLE_NODE_E_ENABLED and event.get("severity") in ["critical", "error"]:
|
| 103 |
+
await self._sync_to_node_e("audit_log", event)
|
| 104 |
+
|
| 105 |
+
self.sync_stats["successful_syncs"] += 1
|
| 106 |
+
self.sync_stats["total_items_synced"] += 1
|
| 107 |
+
|
| 108 |
+
return {
|
| 109 |
+
"ok": True,
|
| 110 |
+
"event_id": event.get("id"),
|
| 111 |
+
"synced_to": ["D", "E"] if ORACLE_NODE_E_ENABLED else ["D"],
|
| 112 |
+
}
|
| 113 |
+
except Exception as exc:
|
| 114 |
+
_logger.error(f"Errore sincronizzazione audit: {exc}")
|
| 115 |
+
self.sync_stats["failed_syncs"] += 1
|
| 116 |
+
return {"ok": False, "error": str(exc)}
|
| 117 |
+
|
| 118 |
+
async def distribute_task_to_oracle(self, task: Dict[str, Any]) -> Dict[str, Any]:
|
| 119 |
+
"""Distribuisce un task computazionale al Nodo E (Oracle)."""
|
| 120 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 121 |
+
return {"ok": False, "error": "Oracle Node E disabilitato"}
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
_logger.info(f"Distribuzione task a Nodo E: {task.get('id')}")
|
| 125 |
+
|
| 126 |
+
# Salva task in B (PRIMARY)
|
| 127 |
+
task_id = await self._save_task_to_node_b(task)
|
| 128 |
+
|
| 129 |
+
# Invia task a E
|
| 130 |
+
result = await self._send_task_to_node_e(task_id, task)
|
| 131 |
+
|
| 132 |
+
if result.get("ok"):
|
| 133 |
+
self.pending_tasks.append({
|
| 134 |
+
"task_id": task_id,
|
| 135 |
+
"node": "E",
|
| 136 |
+
"created_at": datetime.now().isoformat(),
|
| 137 |
+
"status": "pending",
|
| 138 |
+
})
|
| 139 |
+
self.sync_stats["successful_syncs"] += 1
|
| 140 |
+
else:
|
| 141 |
+
self.sync_stats["failed_syncs"] += 1
|
| 142 |
+
|
| 143 |
+
return result
|
| 144 |
+
except Exception as exc:
|
| 145 |
+
_logger.error(f"Errore distribuzione task: {exc}")
|
| 146 |
+
self.sync_stats["failed_syncs"] += 1
|
| 147 |
+
return {"ok": False, "error": str(exc)}
|
| 148 |
+
|
| 149 |
+
async def collect_task_results(self) -> Dict[str, Any]:
|
| 150 |
+
"""Raccoglie i risultati dei task dal Nodo E."""
|
| 151 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 152 |
+
return {"ok": False, "error": "Oracle Node E disabilitato"}
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
_logger.info("Raccolta risultati task da Nodo E...")
|
| 156 |
+
|
| 157 |
+
results = []
|
| 158 |
+
for task_info in self.pending_tasks[:]:
|
| 159 |
+
if task_info["node"] == "E":
|
| 160 |
+
result = await self._fetch_task_result_from_node_e(task_info["task_id"])
|
| 161 |
+
|
| 162 |
+
if result.get("ok") and result.get("status") == "completed":
|
| 163 |
+
# Sincronizza risultato su B
|
| 164 |
+
await self._sync_result_to_node_b(task_info["task_id"], result)
|
| 165 |
+
|
| 166 |
+
results.append(result)
|
| 167 |
+
self.pending_tasks.remove(task_info)
|
| 168 |
+
self.sync_stats["successful_syncs"] += 1
|
| 169 |
+
|
| 170 |
+
return {
|
| 171 |
+
"ok": True,
|
| 172 |
+
"results_collected": len(results),
|
| 173 |
+
"pending_tasks": len(self.pending_tasks),
|
| 174 |
+
"results": results,
|
| 175 |
+
}
|
| 176 |
+
except Exception as exc:
|
| 177 |
+
_logger.error(f"Errore raccolta risultati: {exc}")
|
| 178 |
+
return {"ok": False, "error": str(exc)}
|
| 179 |
+
|
| 180 |
+
async def sync_all(self) -> Dict[str, Any]:
|
| 181 |
+
"""Sincronizzazione completa tra tutti i nodi."""
|
| 182 |
+
try:
|
| 183 |
+
_logger.info("Sincronizzazione completa iniziata...")
|
| 184 |
+
|
| 185 |
+
start_time = datetime.now()
|
| 186 |
+
|
| 187 |
+
# 1. Sincronizza memoria (B β C)
|
| 188 |
+
memory_sync = await self._sync_all_memory()
|
| 189 |
+
|
| 190 |
+
# 2. Sincronizza audit (B β D)
|
| 191 |
+
audit_sync = await self._sync_all_audit()
|
| 192 |
+
|
| 193 |
+
# 3. Raccoglie risultati task da E
|
| 194 |
+
task_results = None
|
| 195 |
+
if ORACLE_NODE_E_ENABLED:
|
| 196 |
+
task_results = await self.collect_task_results()
|
| 197 |
+
|
| 198 |
+
elapsed = (datetime.now() - start_time).total_seconds()
|
| 199 |
+
|
| 200 |
+
self.sync_stats["total_syncs"] += 1
|
| 201 |
+
self.last_sync_time["all"] = datetime.now().isoformat()
|
| 202 |
+
|
| 203 |
+
return {
|
| 204 |
+
"ok": True,
|
| 205 |
+
"memory_synced": memory_sync.get("count", 0),
|
| 206 |
+
"audit_synced": audit_sync.get("count", 0),
|
| 207 |
+
"task_results_collected": task_results.get("results_collected", 0) if task_results else 0,
|
| 208 |
+
"elapsed_seconds": elapsed,
|
| 209 |
+
"stats": self.sync_stats,
|
| 210 |
+
}
|
| 211 |
+
except Exception as exc:
|
| 212 |
+
_logger.error(f"Errore sincronizzazione completa: {exc}")
|
| 213 |
+
self.sync_stats["failed_syncs"] += 1
|
| 214 |
+
return {"ok": False, "error": str(exc)}
|
| 215 |
+
|
| 216 |
+
# ββ Metodi Privati ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 217 |
+
|
| 218 |
+
async def _fetch_from_node_b(self, key: str) -> Any:
|
| 219 |
+
"""Recupera dato da Nodo B (PRIMARY)."""
|
| 220 |
+
# Implementazione: Query Supabase B
|
| 221 |
+
_logger.debug(f"Fetch da B: {key}")
|
| 222 |
+
return {} # Placeholder
|
| 223 |
+
|
| 224 |
+
async def _sync_to_node_c(self, key: str, data: Any) -> bool:
|
| 225 |
+
"""Sincronizza dato a Nodo C (Memory/RAG)."""
|
| 226 |
+
# Implementazione: Insert/Update Supabase C
|
| 227 |
+
_logger.debug(f"Sync a C: {key}")
|
| 228 |
+
return True # Placeholder
|
| 229 |
+
|
| 230 |
+
async def _sync_to_node_d(self, key: str, data: Any) -> bool:
|
| 231 |
+
"""Sincronizza evento audit a Nodo D (Audit)."""
|
| 232 |
+
# Implementazione: Append-only insert Supabase D
|
| 233 |
+
_logger.debug(f"Sync a D: {key}")
|
| 234 |
+
return True # Placeholder
|
| 235 |
+
|
| 236 |
+
async def _sync_to_node_e(self, key: str, data: Any) -> bool:
|
| 237 |
+
"""Sincronizza dato a Nodo E (Oracle Compute)."""
|
| 238 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 239 |
+
return False
|
| 240 |
+
|
| 241 |
+
try:
|
| 242 |
+
import httpx
|
| 243 |
+
|
| 244 |
+
async with httpx.AsyncClient() as client:
|
| 245 |
+
response = await client.post(
|
| 246 |
+
f"http://{ORACLE_NODE_E_IP}:{ORACLE_NODE_E_PORT}/api/sync/receive",
|
| 247 |
+
json={"key": key, "data": data},
|
| 248 |
+
timeout=SYNC_TIMEOUT_SECONDS,
|
| 249 |
+
)
|
| 250 |
+
return response.status_code == 200
|
| 251 |
+
except Exception as exc:
|
| 252 |
+
_logger.error(f"Errore sync a E: {exc}")
|
| 253 |
+
return False
|
| 254 |
+
|
| 255 |
+
async def _save_task_to_node_b(self, task: Dict[str, Any]) -> str:
|
| 256 |
+
"""Salva task in B."""
|
| 257 |
+
# Implementazione: Insert Supabase B
|
| 258 |
+
_logger.debug(f"Save task a B: {task.get('id')}")
|
| 259 |
+
return task.get("id", "task_unknown") # Placeholder
|
| 260 |
+
|
| 261 |
+
async def _send_task_to_node_e(self, task_id: str, task: Dict[str, Any]) -> Dict[str, Any]:
|
| 262 |
+
"""Invia task a Nodo E."""
|
| 263 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 264 |
+
return {"ok": False, "error": "Oracle Node E disabilitato"}
|
| 265 |
+
|
| 266 |
+
try:
|
| 267 |
+
import httpx
|
| 268 |
+
|
| 269 |
+
async with httpx.AsyncClient() as client:
|
| 270 |
+
response = await client.post(
|
| 271 |
+
f"http://{ORACLE_NODE_E_IP}:{ORACLE_NODE_E_PORT}/api/tasks/submit",
|
| 272 |
+
json={"task_id": task_id, "task": task},
|
| 273 |
+
timeout=SYNC_TIMEOUT_SECONDS,
|
| 274 |
+
)
|
| 275 |
+
return response.json() if response.status_code == 200 else {"ok": False}
|
| 276 |
+
except Exception as exc:
|
| 277 |
+
_logger.error(f"Errore invio task a E: {exc}")
|
| 278 |
+
return {"ok": False, "error": str(exc)}
|
| 279 |
+
|
| 280 |
+
async def _fetch_task_result_from_node_e(self, task_id: str) -> Dict[str, Any]:
|
| 281 |
+
"""Recupera risultato task da Nodo E."""
|
| 282 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 283 |
+
return {"ok": False}
|
| 284 |
+
|
| 285 |
+
try:
|
| 286 |
+
import httpx
|
| 287 |
+
|
| 288 |
+
async with httpx.AsyncClient() as client:
|
| 289 |
+
response = await client.get(
|
| 290 |
+
f"http://{ORACLE_NODE_E_IP}:{ORACLE_NODE_E_PORT}/api/tasks/{task_id}/result",
|
| 291 |
+
timeout=SYNC_TIMEOUT_SECONDS,
|
| 292 |
+
)
|
| 293 |
+
return response.json() if response.status_code == 200 else {"ok": False}
|
| 294 |
+
except Exception as exc:
|
| 295 |
+
_logger.error(f"Errore fetch risultato da E: {exc}")
|
| 296 |
+
return {"ok": False}
|
| 297 |
+
|
| 298 |
+
async def _sync_result_to_node_b(self, task_id: str, result: Dict[str, Any]) -> bool:
|
| 299 |
+
"""Sincronizza risultato task a B."""
|
| 300 |
+
# Implementazione: Update Supabase B
|
| 301 |
+
_logger.debug(f"Sync risultato a B: {task_id}")
|
| 302 |
+
return True # Placeholder
|
| 303 |
+
|
| 304 |
+
async def _sync_all_memory(self) -> Dict[str, Any]:
|
| 305 |
+
"""Sincronizza tutta la memoria."""
|
| 306 |
+
_logger.debug("Sincronizzazione memoria completa...")
|
| 307 |
+
return {"count": 0} # Placeholder
|
| 308 |
+
|
| 309 |
+
async def _sync_all_audit(self) -> Dict[str, Any]:
|
| 310 |
+
"""Sincronizza tutti gli audit."""
|
| 311 |
+
_logger.debug("Sincronizzazione audit completa...")
|
| 312 |
+
return {"count": 0} # Placeholder
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# ββ Istanza Globale βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 316 |
+
_sync_manager = GlobalStateSyncWithOracle()
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# ββ Scheduler Sincronizzazione ββββββββββββββββββββββββββββββββββββββββββββ
|
| 320 |
+
async def start_sync_scheduler():
|
| 321 |
+
"""Avvia lo scheduler di sincronizzazione periodica."""
|
| 322 |
+
_logger.info(f"Avvio scheduler sincronizzazione (intervallo: {SYNC_INTERVAL_SECONDS}s)...")
|
| 323 |
+
|
| 324 |
+
while True:
|
| 325 |
+
try:
|
| 326 |
+
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
|
| 327 |
+
result = await _sync_manager.sync_all()
|
| 328 |
+
|
| 329 |
+
if result.get("ok"):
|
| 330 |
+
_logger.info(f"Sincronizzazione completata: {result}")
|
| 331 |
+
else:
|
| 332 |
+
_logger.warning(f"Sincronizzazione parziale: {result}")
|
| 333 |
+
except Exception as exc:
|
| 334 |
+
_logger.error(f"Errore scheduler: {exc}")
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
# ββ Funzioni Pubbliche ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 338 |
+
async def sync_memory(session_id: str) -> Dict[str, Any]:
|
| 339 |
+
"""Sincronizza memoria."""
|
| 340 |
+
return await _sync_manager.sync_memory(session_id)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
async def sync_audit(event: Dict[str, Any]) -> Dict[str, Any]:
|
| 344 |
+
"""Sincronizza audit."""
|
| 345 |
+
return await _sync_manager.sync_audit(event)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
async def distribute_task(task: Dict[str, Any]) -> Dict[str, Any]:
|
| 349 |
+
"""Distribuisce task a Nodo E."""
|
| 350 |
+
return await _sync_manager.distribute_task_to_oracle(task)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
async def get_sync_stats() -> Dict[str, Any]:
|
| 354 |
+
"""Ritorna statistiche sincronizzazione."""
|
| 355 |
+
return _sync_manager.sync_stats
|
api/job_queue.py
CHANGED
|
@@ -4,21 +4,28 @@ import time
|
|
| 4 |
import asyncio
|
| 5 |
import logging
|
| 6 |
import uuid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from typing import Optional, List, Dict, Any
|
| 8 |
from fastapi import APIRouter, HTTPException, Request
|
| 9 |
from pydantic import BaseModel
|
|
|
|
| 10 |
import httpx
|
| 11 |
|
| 12 |
_logger = logging.getLogger("agente_ai.jq")
|
| 13 |
router = APIRouter(prefix="/jq", tags=["job_queue"])
|
| 14 |
|
| 15 |
# ββ Configurazione ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
_SPACE_ROLE = os.getenv("SPACE_ROLE", "unknown")
|
| 19 |
-
_JQ_ENABLED = os.getenv("JQ_ENABLED", "0") == "1"
|
| 20 |
_INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
|
| 21 |
|
|
|
|
|
|
|
|
|
|
| 22 |
# Redis keys (Upstash)
|
| 23 |
_K_PENDING = "jq:pending"
|
| 24 |
_K_WAKE = "jq:wake"
|
|
@@ -65,14 +72,23 @@ async def _lrange(key: str, start: int, end: int = -1) -> List[str]:
|
|
| 65 |
|
| 66 |
# ββ Core Logic ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
async def publish_load_metrics():
|
| 68 |
-
"""Pubblica il carico corrente su Redis per il bilanciamento.
|
|
|
|
|
|
|
| 69 |
if not _redis_ok(): return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
data = {
|
| 71 |
-
"role":
|
| 72 |
-
"ts":
|
| 73 |
-
"active_tasks":
|
| 74 |
-
"cpu":
|
| 75 |
-
"mem":
|
| 76 |
}
|
| 77 |
await _rcmd(["SET", _K_LOAD(_SPACE_ROLE), json.dumps(data), "EX", "60"])
|
| 78 |
|
|
@@ -87,6 +103,7 @@ async def _load_publisher_loop():
|
|
| 87 |
|
| 88 |
async def _hands_consumer_loop():
|
| 89 |
"""Loop consumer reale per nodi distribuiti (S42)."""
|
|
|
|
| 90 |
_logger.info("[jq] consumer loop avviato per ruolo: %s", _SPACE_ROLE)
|
| 91 |
_consecutive_errors = 0
|
| 92 |
_MAX_CONSECUTIVE_ERRORS = 3 # INV-R1: dopo 3 errori infra β pausa 30s
|
|
@@ -96,21 +113,25 @@ async def _hands_consumer_loop():
|
|
| 96 |
# 1. Heartbeat consumer
|
| 97 |
await _rcmd(["SET", _K_CONSUMER, "1", "EX", "15"])
|
| 98 |
|
| 99 |
-
# 2. Prelievo job
|
| 100 |
res = await _rcmd(["RPOP", _K_PENDING])
|
| 101 |
if res and res.get("result"):
|
| 102 |
job_raw = res["result"]
|
| 103 |
try:
|
| 104 |
job_data = json.loads(job_raw)
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
task_id = job_data.get("taskId", str(uuid.uuid4()))
|
| 109 |
goal = job_data.get("goal", "")
|
| 110 |
context_raw = job_data.get("context", {})
|
| 111 |
_logger.info("[jq] job ricevuto: %s | goal: %.80s", task_id, goal)
|
| 112 |
|
| 113 |
-
# Normalizza context β stringa
|
| 114 |
if isinstance(context_raw, dict):
|
| 115 |
context_str = "\n".join(f"{k}: {v}" for k, v in context_raw.items() if v)
|
| 116 |
elif isinstance(context_raw, str):
|
|
@@ -118,7 +139,6 @@ async def _hands_consumer_loop():
|
|
| 118 |
else:
|
| 119 |
context_str = ""
|
| 120 |
|
| 121 |
-
# S429 β Esecuzione REALE via UnifiedAgentLoop
|
| 122 |
result_payload: Dict[str, Any] = {}
|
| 123 |
try:
|
| 124 |
from agents.unified_loop import UnifiedAgentLoop
|
|
@@ -136,7 +156,6 @@ async def _hands_consumer_loop():
|
|
| 136 |
_critic = None
|
| 137 |
_verifier = None
|
| 138 |
|
| 139 |
-
# Accumula step per tracciabilitΓ nel risultato Redis
|
| 140 |
_steps: list = []
|
| 141 |
|
| 142 |
async def _on_step(step: dict) -> None:
|
|
@@ -154,17 +173,21 @@ async def _hands_consumer_loop():
|
|
| 154 |
planner=_get_planner(),
|
| 155 |
)
|
| 156 |
|
| 157 |
-
# max_steps: clampato 5β16, scalato per prioritΓ job
|
| 158 |
_priority = int(job_data.get("priority", 1))
|
| 159 |
_max_steps = max(5, min(4 + _priority * 2, 16))
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
output = raw_result.get("output", "") if isinstance(raw_result, dict) else str(raw_result)
|
| 170 |
success = bool(raw_result.get("success", False)) if isinstance(raw_result, dict) else bool(output)
|
|
@@ -184,7 +207,6 @@ async def _hands_consumer_loop():
|
|
| 184 |
_logger.info("[jq] job completato: %s | engine: %s | success: %s", task_id, engine, success)
|
| 185 |
|
| 186 |
except (ImportError, ModuleNotFoundError) as imp_err:
|
| 187 |
-
# UnifiedAgentLoop non disponibile su questo nodo
|
| 188 |
_logger.warning("[jq] agents.unified_loop non disponibile su %s: %s", _SPACE_ROLE, imp_err)
|
| 189 |
result_payload = {
|
| 190 |
"taskId": task_id,
|
|
@@ -204,7 +226,6 @@ async def _hands_consumer_loop():
|
|
| 204 |
"error": str(exec_err)[:500],
|
| 205 |
"ts": int(time.time() * 1000),
|
| 206 |
}
|
| 207 |
-
# INV-R1: 3 errori consecutivi infra β pausa 30s, log esplicito
|
| 208 |
if _consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
| 209 |
_logger.error(
|
| 210 |
"[jq] INV-R1: %d errori consecutivi β pausa 30s (nodo: %s)",
|
|
@@ -217,7 +238,6 @@ async def _hands_consumer_loop():
|
|
| 217 |
_logger.info("[jq] risultato Redis: %s β %s", task_id, result_payload.get("status"))
|
| 218 |
|
| 219 |
else:
|
| 220 |
-
# Coda vuota β reset errori consecutivi, polling leggero via REST
|
| 221 |
_consecutive_errors = 0
|
| 222 |
await asyncio.sleep(2)
|
| 223 |
|
|
@@ -231,7 +251,6 @@ async def start_job_queue_consumer() -> None:
|
|
| 231 |
_logger.warning("[jq] Redis non configurato β job queue disabilitato")
|
| 232 |
return
|
| 233 |
_bg_tasks.append(asyncio.create_task(_load_publisher_loop()))
|
| 234 |
-
# Grid Orchestrator: Consumer specializzati (HANDS, MEMORY, AUDIT)
|
| 235 |
if _SPACE_ROLE in ("hands", "memory", "audit", "unknown"):
|
| 236 |
_bg_tasks.append(asyncio.create_task(_hands_consumer_loop()))
|
| 237 |
else:
|
|
@@ -240,13 +259,13 @@ async def start_job_queue_consumer() -> None:
|
|
| 240 |
# ββ FastAPI endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 241 |
@router.get("/status")
|
| 242 |
async def jq_status():
|
| 243 |
-
|
| 244 |
"space_role": _SPACE_ROLE,
|
| 245 |
"jq_enabled": _JQ_ENABLED,
|
| 246 |
"redis_configured": _redis_ok(),
|
|
|
|
| 247 |
"ts": int(time.time() * 1000),
|
| 248 |
}
|
| 249 |
-
return result
|
| 250 |
|
| 251 |
@router.get("/load/{role}")
|
| 252 |
async def jq_load(role: str):
|
|
@@ -262,56 +281,46 @@ async def jq_load(role: str):
|
|
| 262 |
|
| 263 |
@router.post("/submit")
|
| 264 |
async def jq_submit(job: JobPayload, request: Request):
|
| 265 |
-
"""
|
| 266 |
-
S42 β Grid Orchestrator: sottomissione job reale su Redis.
|
| 267 |
-
Inserisce il job in coda LPUSH (FIFO con RPOP dal consumer).
|
| 268 |
-
Restituisce taskId e lunghezza coda corrente.
|
| 269 |
-
"""
|
| 270 |
if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
|
| 271 |
raise HTTPException(401, "Unauthorized")
|
| 272 |
-
|
| 273 |
if not _redis_ok():
|
| 274 |
raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
|
| 275 |
|
| 276 |
-
# Normalizza taskId: usa quello fornito o genera uno nuovo
|
| 277 |
task_id = job.taskId if job.taskId else str(uuid.uuid4())
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
job_payload = {
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
| 286 |
}
|
| 287 |
-
|
| 288 |
-
# LPUSH β consumer usa RPOP (FIFO)
|
| 289 |
res = await _rcmd(["LPUSH", _K_PENDING, json.dumps(job_payload)])
|
| 290 |
if res is None:
|
| 291 |
raise HTTPException(503, "Errore Redis durante la sottomissione del job")
|
| 292 |
|
| 293 |
queue_len = int(res.get("result", 0)) if res else 0
|
| 294 |
-
|
| 295 |
-
# Segnale di wake per consumer (TTL breve)
|
| 296 |
await _rcmd(["SET", _K_WAKE, "1", "EX", "10"])
|
| 297 |
-
|
| 298 |
_logger.info("[jq] job sottomesso: %s (coda: %d)", task_id, queue_len)
|
| 299 |
return {
|
| 300 |
-
"taskId":
|
| 301 |
-
"status":
|
| 302 |
"queueLength": queue_len,
|
| 303 |
-
"ts":
|
| 304 |
}
|
| 305 |
|
| 306 |
@router.get("/result/{task_id}")
|
| 307 |
async def jq_result(task_id: str, request: Request):
|
| 308 |
-
"""
|
| 309 |
-
S429 β Tool Success Contract: recupero risultato job per taskId.
|
| 310 |
-
Restituisce il risultato se disponibile, altrimenti pending.
|
| 311 |
-
"""
|
| 312 |
if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
|
| 313 |
raise HTTPException(401, "Unauthorized")
|
| 314 |
-
|
| 315 |
if not _redis_ok():
|
| 316 |
raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
|
| 317 |
|
|
@@ -323,3 +332,6 @@ async def jq_result(task_id: str, request: Request):
|
|
| 323 |
except json.JSONDecodeError as _je:
|
| 324 |
_logger.error("[jq] risultato Redis corrotto task=%s: %s", task_id, _je)
|
| 325 |
raise HTTPException(500, f"Risultato corrotto in Redis per {task_id}: {_je}")
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import asyncio
|
| 5 |
import logging
|
| 6 |
import uuid
|
| 7 |
+
try:
|
| 8 |
+
import resource as _resource
|
| 9 |
+
_HAS_RESOURCE = True
|
| 10 |
+
except ImportError:
|
| 11 |
+
_HAS_RESOURCE = False
|
| 12 |
from typing import Optional, List, Dict, Any
|
| 13 |
from fastapi import APIRouter, HTTPException, Request
|
| 14 |
from pydantic import BaseModel
|
| 15 |
+
from .load_balancer import balancer # S951
|
| 16 |
import httpx
|
| 17 |
|
| 18 |
_logger = logging.getLogger("agente_ai.jq")
|
| 19 |
router = APIRouter(prefix="/jq", tags=["job_queue"])
|
| 20 |
|
| 21 |
# ββ Configurazione ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
_SPACE_ROLE = os.getenv("SPACE_ROLE", "unknown")
|
| 23 |
+
_JQ_ENABLED = os.getenv("JQ_ENABLED", "0") == "1"
|
|
|
|
|
|
|
| 24 |
_INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
|
| 25 |
|
| 26 |
+
# C2-FIX: contatore reale di job attivi β incrementato/decrementato intorno a loop_inst.run()
|
| 27 |
+
_active_job_count: int = 0
|
| 28 |
+
|
| 29 |
# Redis keys (Upstash)
|
| 30 |
_K_PENDING = "jq:pending"
|
| 31 |
_K_WAKE = "jq:wake"
|
|
|
|
| 72 |
|
| 73 |
# ββ Core Logic ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
async def publish_load_metrics():
|
| 75 |
+
"""Pubblica il carico corrente su Redis per il bilanciamento (S951).
|
| 76 |
+
C2-FIX: usa _active_job_count (contatore reale) invece di asyncio.all_tasks()-5.
|
| 77 |
+
"""
|
| 78 |
if not _redis_ok(): return
|
| 79 |
+
# Memoria processo in MB (Linux: ru_maxrss Γ¨ in kB)
|
| 80 |
+
mem_mb = 0
|
| 81 |
+
if _HAS_RESOURCE:
|
| 82 |
+
try:
|
| 83 |
+
mem_mb = _resource.getrusage(_resource.RUSAGE_SELF).ru_maxrss // 1024
|
| 84 |
+
except Exception:
|
| 85 |
+
mem_mb = 0
|
| 86 |
data = {
|
| 87 |
+
"role": _SPACE_ROLE,
|
| 88 |
+
"ts": int(time.time() * 1000),
|
| 89 |
+
"active_tasks": max(0, _active_job_count), # mai negativo
|
| 90 |
+
"cpu": 0, # placeholder β psutil non installato
|
| 91 |
+
"mem": mem_mb,
|
| 92 |
}
|
| 93 |
await _rcmd(["SET", _K_LOAD(_SPACE_ROLE), json.dumps(data), "EX", "60"])
|
| 94 |
|
|
|
|
| 103 |
|
| 104 |
async def _hands_consumer_loop():
|
| 105 |
"""Loop consumer reale per nodi distribuiti (S42)."""
|
| 106 |
+
global _active_job_count
|
| 107 |
_logger.info("[jq] consumer loop avviato per ruolo: %s", _SPACE_ROLE)
|
| 108 |
_consecutive_errors = 0
|
| 109 |
_MAX_CONSECUTIVE_ERRORS = 3 # INV-R1: dopo 3 errori infra β pausa 30s
|
|
|
|
| 113 |
# 1. Heartbeat consumer
|
| 114 |
await _rcmd(["SET", _K_CONSUMER, "1", "EX", "15"])
|
| 115 |
|
| 116 |
+
# 2. Prelievo job
|
| 117 |
res = await _rcmd(["RPOP", _K_PENDING])
|
| 118 |
if res and res.get("result"):
|
| 119 |
job_raw = res["result"]
|
| 120 |
try:
|
| 121 |
job_data = json.loads(job_raw)
|
| 122 |
+
# S951: Check assignment
|
| 123 |
+
assigned_to = job_data.get("assignedTo", "hands")
|
| 124 |
+
if assigned_to != _SPACE_ROLE and _SPACE_ROLE != "brain":
|
| 125 |
+
await _rcmd(["LPUSH", _K_PENDING, job_raw])
|
| 126 |
+
await asyncio.sleep(1)
|
| 127 |
+
continue
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
task_id = job_data.get("taskId", str(uuid.uuid4()))
|
| 131 |
goal = job_data.get("goal", "")
|
| 132 |
context_raw = job_data.get("context", {})
|
| 133 |
_logger.info("[jq] job ricevuto: %s | goal: %.80s", task_id, goal)
|
| 134 |
|
|
|
|
| 135 |
if isinstance(context_raw, dict):
|
| 136 |
context_str = "\n".join(f"{k}: {v}" for k, v in context_raw.items() if v)
|
| 137 |
elif isinstance(context_raw, str):
|
|
|
|
| 139 |
else:
|
| 140 |
context_str = ""
|
| 141 |
|
|
|
|
| 142 |
result_payload: Dict[str, Any] = {}
|
| 143 |
try:
|
| 144 |
from agents.unified_loop import UnifiedAgentLoop
|
|
|
|
| 156 |
_critic = None
|
| 157 |
_verifier = None
|
| 158 |
|
|
|
|
| 159 |
_steps: list = []
|
| 160 |
|
| 161 |
async def _on_step(step: dict) -> None:
|
|
|
|
| 173 |
planner=_get_planner(),
|
| 174 |
)
|
| 175 |
|
|
|
|
| 176 |
_priority = int(job_data.get("priority", 1))
|
| 177 |
_max_steps = max(5, min(4 + _priority * 2, 16))
|
| 178 |
|
| 179 |
+
# C2-FIX: incrementa contatore PRIMA di run(), decrementa in finally
|
| 180 |
+
_active_job_count += 1
|
| 181 |
+
try:
|
| 182 |
+
raw_result = await loop_inst.run(
|
| 183 |
+
goal=goal,
|
| 184 |
+
context=context_str,
|
| 185 |
+
max_steps=_max_steps,
|
| 186 |
+
on_step=_on_step,
|
| 187 |
+
session_id=task_id,
|
| 188 |
+
)
|
| 189 |
+
finally:
|
| 190 |
+
_active_job_count = max(0, _active_job_count - 1)
|
| 191 |
|
| 192 |
output = raw_result.get("output", "") if isinstance(raw_result, dict) else str(raw_result)
|
| 193 |
success = bool(raw_result.get("success", False)) if isinstance(raw_result, dict) else bool(output)
|
|
|
|
| 207 |
_logger.info("[jq] job completato: %s | engine: %s | success: %s", task_id, engine, success)
|
| 208 |
|
| 209 |
except (ImportError, ModuleNotFoundError) as imp_err:
|
|
|
|
| 210 |
_logger.warning("[jq] agents.unified_loop non disponibile su %s: %s", _SPACE_ROLE, imp_err)
|
| 211 |
result_payload = {
|
| 212 |
"taskId": task_id,
|
|
|
|
| 226 |
"error": str(exec_err)[:500],
|
| 227 |
"ts": int(time.time() * 1000),
|
| 228 |
}
|
|
|
|
| 229 |
if _consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
| 230 |
_logger.error(
|
| 231 |
"[jq] INV-R1: %d errori consecutivi β pausa 30s (nodo: %s)",
|
|
|
|
| 238 |
_logger.info("[jq] risultato Redis: %s β %s", task_id, result_payload.get("status"))
|
| 239 |
|
| 240 |
else:
|
|
|
|
| 241 |
_consecutive_errors = 0
|
| 242 |
await asyncio.sleep(2)
|
| 243 |
|
|
|
|
| 251 |
_logger.warning("[jq] Redis non configurato β job queue disabilitato")
|
| 252 |
return
|
| 253 |
_bg_tasks.append(asyncio.create_task(_load_publisher_loop()))
|
|
|
|
| 254 |
if _SPACE_ROLE in ("hands", "memory", "audit", "unknown"):
|
| 255 |
_bg_tasks.append(asyncio.create_task(_hands_consumer_loop()))
|
| 256 |
else:
|
|
|
|
| 259 |
# ββ FastAPI endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 260 |
@router.get("/status")
|
| 261 |
async def jq_status():
|
| 262 |
+
return {
|
| 263 |
"space_role": _SPACE_ROLE,
|
| 264 |
"jq_enabled": _JQ_ENABLED,
|
| 265 |
"redis_configured": _redis_ok(),
|
| 266 |
+
"active_tasks": max(0, _active_job_count),
|
| 267 |
"ts": int(time.time() * 1000),
|
| 268 |
}
|
|
|
|
| 269 |
|
| 270 |
@router.get("/load/{role}")
|
| 271 |
async def jq_load(role: str):
|
|
|
|
| 281 |
|
| 282 |
@router.post("/submit")
|
| 283 |
async def jq_submit(job: JobPayload, request: Request):
|
| 284 |
+
"""S42 β Grid Orchestrator: sottomissione job reale su Redis."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
|
| 286 |
raise HTTPException(401, "Unauthorized")
|
|
|
|
| 287 |
if not _redis_ok():
|
| 288 |
raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
|
| 289 |
|
|
|
|
| 290 |
task_id = job.taskId if job.taskId else str(uuid.uuid4())
|
| 291 |
|
| 292 |
+
# S951: Dynamic Load Balancing
|
| 293 |
+
target_role = job.context.get('preferred_role', 'hands') if job.context else 'hands'
|
| 294 |
+
assigned_role = await balancer.get_best_node(target_role)
|
| 295 |
+
|
| 296 |
job_payload = {
|
| 297 |
+
'taskId': task_id,
|
| 298 |
+
'goal': job.goal,
|
| 299 |
+
'context': job.context or {},
|
| 300 |
+
'priority': job.priority,
|
| 301 |
+
'submittedAt': int(time.time() * 1000),
|
| 302 |
+
'submittedBy': _SPACE_ROLE,
|
| 303 |
+
'assignedTo': assigned_role,
|
| 304 |
}
|
|
|
|
|
|
|
| 305 |
res = await _rcmd(["LPUSH", _K_PENDING, json.dumps(job_payload)])
|
| 306 |
if res is None:
|
| 307 |
raise HTTPException(503, "Errore Redis durante la sottomissione del job")
|
| 308 |
|
| 309 |
queue_len = int(res.get("result", 0)) if res else 0
|
|
|
|
|
|
|
| 310 |
await _rcmd(["SET", _K_WAKE, "1", "EX", "10"])
|
|
|
|
| 311 |
_logger.info("[jq] job sottomesso: %s (coda: %d)", task_id, queue_len)
|
| 312 |
return {
|
| 313 |
+
"taskId": task_id,
|
| 314 |
+
"status": "queued",
|
| 315 |
"queueLength": queue_len,
|
| 316 |
+
"ts": int(time.time() * 1000),
|
| 317 |
}
|
| 318 |
|
| 319 |
@router.get("/result/{task_id}")
|
| 320 |
async def jq_result(task_id: str, request: Request):
|
| 321 |
+
"""S429 β Tool Success Contract: recupero risultato job per taskId."""
|
|
|
|
|
|
|
|
|
|
| 322 |
if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
|
| 323 |
raise HTTPException(401, "Unauthorized")
|
|
|
|
| 324 |
if not _redis_ok():
|
| 325 |
raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
|
| 326 |
|
|
|
|
| 332 |
except json.JSONDecodeError as _je:
|
| 333 |
_logger.error("[jq] risultato Redis corrotto task=%s: %s", task_id, _je)
|
| 334 |
raise HTTPException(500, f"Risultato corrotto in Redis per {task_id}: {_je}")
|
| 335 |
+
|
| 336 |
+
# Lista dei background tasks (popolata da start_job_queue_consumer)
|
| 337 |
+
_bg_tasks: List[asyncio.Task] = []
|
api/load_balancer.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/load_balancer.py β Dynamic Load Balancer (S951)
|
| 3 |
+
Analizza il carico dei nodi (A, B, C, D) e decide a chi assegnare il task.
|
| 4 |
+
Supporta il Cross-Node Offloading: se il nodo target Γ¨ saturo, delega al nodo piΓΉ scarico.
|
| 5 |
+
M1-FIX: timeout Redis configurabile via env REDIS_TIMEOUT_S (default 5.0s, allineato a job_queue e state_sync).
|
| 6 |
+
"""
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
from typing import Optional, Dict, Any, List
|
| 12 |
+
import httpx
|
| 13 |
+
|
| 14 |
+
_logger = logging.getLogger("agente_ai.balancer")
|
| 15 |
+
|
| 16 |
+
# M1-FIX: timeout Redis allineato agli altri moduli (job_queue: 5.0s, state_sync: 5.0s)
|
| 17 |
+
_REDIS_TIMEOUT = float(os.getenv("REDIS_TIMEOUT_S", "5.0"))
|
| 18 |
+
|
| 19 |
+
# Ruoli del cluster
|
| 20 |
+
ROLES = ["brain", "hands", "memory", "audit"]
|
| 21 |
+
|
| 22 |
+
class LoadBalancer:
|
| 23 |
+
def __init__(self):
|
| 24 |
+
self.redis_url = os.getenv("UPSTASH_REDIS_REST_URL")
|
| 25 |
+
self.redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN")
|
| 26 |
+
self.internal_token = os.getenv("INTERNAL_TOKEN", "")
|
| 27 |
+
|
| 28 |
+
async def _rcmd(self, cmd: List[Any]) -> Optional[Dict[str, Any]]:
|
| 29 |
+
if not self.redis_url or not self.redis_token: return None
|
| 30 |
+
try:
|
| 31 |
+
async with httpx.AsyncClient() as client:
|
| 32 |
+
r = await client.post(
|
| 33 |
+
self.redis_url,
|
| 34 |
+
headers={"Authorization": f"Bearer {self.redis_token}"},
|
| 35 |
+
json=cmd,
|
| 36 |
+
timeout=_REDIS_TIMEOUT, # M1-FIX: era hardcodato 2.0
|
| 37 |
+
)
|
| 38 |
+
return r.json()
|
| 39 |
+
except Exception as e:
|
| 40 |
+
_logger.error("[S951] Redis error: %s", e)
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
async def get_node_load(self, role: str) -> Optional[Dict[str, Any]]:
|
| 44 |
+
"""Recupera le metriche di carico per un nodo specifico."""
|
| 45 |
+
res = await self._rcmd(["GET", f"jq:load:{role}"])
|
| 46 |
+
if res and res.get("result"):
|
| 47 |
+
try:
|
| 48 |
+
return json.loads(res["result"])
|
| 49 |
+
except (json.JSONDecodeError, TypeError):
|
| 50 |
+
return None
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
async def get_best_node(self, preferred_role: str = "hands") -> str:
|
| 54 |
+
"""
|
| 55 |
+
Determina il miglior nodo per un task.
|
| 56 |
+
Se il preferred_role Γ¨ sotto soglia di carico, lo mantiene.
|
| 57 |
+
Altrimenti cerca il nodo con meno active_tasks.
|
| 58 |
+
"""
|
| 59 |
+
pref_load = await self.get_node_load(preferred_role)
|
| 60 |
+
if pref_load:
|
| 61 |
+
active = pref_load.get("active_tasks", 0)
|
| 62 |
+
if active < 3:
|
| 63 |
+
return preferred_role
|
| 64 |
+
|
| 65 |
+
_logger.info("[S951] Nodo %s saturo o offline. Ricerca miglior nodo alternativo...", preferred_role)
|
| 66 |
+
|
| 67 |
+
best_role = preferred_role
|
| 68 |
+
min_load = 999
|
| 69 |
+
|
| 70 |
+
for role in ROLES:
|
| 71 |
+
load = await self.get_node_load(role)
|
| 72 |
+
if load:
|
| 73 |
+
active = load.get("active_tasks", 0)
|
| 74 |
+
ts = load.get("ts", 0)
|
| 75 |
+
if (time.time() * 1000) - ts < 90000:
|
| 76 |
+
if active < min_load:
|
| 77 |
+
min_load = active
|
| 78 |
+
best_role = role
|
| 79 |
+
|
| 80 |
+
if best_role != preferred_role:
|
| 81 |
+
_logger.info("[S951] Offloading dinamico: %s -> %s (carico: %d)", preferred_role, best_role, min_load)
|
| 82 |
+
|
| 83 |
+
return best_role
|
| 84 |
+
|
| 85 |
+
async def get_cluster_status(self) -> Dict[str, Any]:
|
| 86 |
+
"""Ritorna lo stato di salute di tutto il cluster."""
|
| 87 |
+
status = {}
|
| 88 |
+
for role in ROLES:
|
| 89 |
+
load = await self.get_node_load(role)
|
| 90 |
+
if load:
|
| 91 |
+
ts = load.get("ts", 0)
|
| 92 |
+
is_online = (time.time() * 1000) - ts < 90000
|
| 93 |
+
status[role] = {
|
| 94 |
+
"online": is_online,
|
| 95 |
+
"active_tasks": load.get("active_tasks", 0),
|
| 96 |
+
"mem_mb": load.get("mem", 0),
|
| 97 |
+
"last_seen": ts,
|
| 98 |
+
}
|
| 99 |
+
else:
|
| 100 |
+
status[role] = {"online": False}
|
| 101 |
+
return status
|
| 102 |
+
|
| 103 |
+
# Singleton instance
|
| 104 |
+
balancer = LoadBalancer()
|
api/oci_gateway_manager.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Dict, Any, Optional
|
| 4 |
+
import httpx
|
| 5 |
+
|
| 6 |
+
# Configurazione OCI API Gateway
|
| 7 |
+
OCI_GATEWAY_ID = "ocid1.apigatewayapi.oc1.eu-milan-1.amaaaaaavx6czpaabfdwezue3fo7jpfsrxqwlo2nt7pwhs7o2ezvqztuaffa"
|
| 8 |
+
ORACLE_NODE_E_IP = os.getenv("ORACLE_NODE_E_IP", "80.225.89.217")
|
| 9 |
+
ORACLE_NODE_E_PORT = int(os.getenv("ORACLE_NODE_E_PORT", "8080"))
|
| 10 |
+
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger("agente_ai.oci_gateway")
|
| 13 |
+
|
| 14 |
+
class OCIGatewayManager:
|
| 15 |
+
"""
|
| 16 |
+
Gestore per Oracle Cloud API Gateway.
|
| 17 |
+
Funge da ponte tra il cluster AI e il Nodo E (Milano) per bypassare
|
| 18 |
+
il Network Firewall (Palo Alto) tramite endpoint HTTPS gestiti.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.gateway_id = OCI_GATEWAY_ID
|
| 23 |
+
self.base_url = f"https://{self.gateway_id}.apigateway.eu-milan-1.oci.customer-oci.com"
|
| 24 |
+
self.token = INTERNAL_TOKEN
|
| 25 |
+
|
| 26 |
+
async def get_gateway_status(self) -> Dict[str, Any]:
|
| 27 |
+
"""Verifica lo stato del gateway e la connettivitΓ verso il Nodo E."""
|
| 28 |
+
try:
|
| 29 |
+
async with httpx.AsyncClient() as client:
|
| 30 |
+
# Nota: L'URL reale dipenderΓ dal deployment specifico su OCI
|
| 31 |
+
# Questo Γ¨ un placeholder per la logica di health check via gateway
|
| 32 |
+
response = await client.get(
|
| 33 |
+
f"{self.base_url}/health",
|
| 34 |
+
headers={"Authorization": f"Bearer {self.token}"},
|
| 35 |
+
timeout=10.0
|
| 36 |
+
)
|
| 37 |
+
return {
|
| 38 |
+
"ok": response.status_code == 200,
|
| 39 |
+
"gateway_id": self.gateway_id,
|
| 40 |
+
"status_code": response.status_code,
|
| 41 |
+
"provider": "Oracle Cloud API Gateway"
|
| 42 |
+
}
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error(f"Errore health check gateway: {e}")
|
| 45 |
+
return {"ok": False, "error": str(e)}
|
| 46 |
+
|
| 47 |
+
async def proxy_to_node_e(self, path: str, method: str = "GET", body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 48 |
+
"""Invia una richiesta al Nodo E passando attraverso l'API Gateway."""
|
| 49 |
+
url = f"{self.base_url}{path}"
|
| 50 |
+
try:
|
| 51 |
+
async with httpx.AsyncClient() as client:
|
| 52 |
+
if method == "POST":
|
| 53 |
+
response = await client.post(url, json=body, headers={"Authorization": f"Bearer {self.token}"})
|
| 54 |
+
else:
|
| 55 |
+
response = await client.get(url, headers={"Authorization": f"Bearer {self.token}"})
|
| 56 |
+
|
| 57 |
+
return response.json() if response.status_code == 200 else {"ok": False, "status": response.status_code}
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"Errore proxy gateway: {e}")
|
| 60 |
+
return {"ok": False, "error": str(e)}
|
| 61 |
+
|
| 62 |
+
# Istanza globale
|
| 63 |
+
oci_gateway = OCIGatewayManager()
|
api/oracle_endpoints.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
oracle_endpoints.py β Endpoint API per Gestione Nodo E (Oracle Cloud)
|
| 3 |
+
|
| 4 |
+
Endpoint:
|
| 5 |
+
GET /api/oracle/health β Health check Nodo E
|
| 6 |
+
GET /api/oracle/metrics β Metriche Nodo E
|
| 7 |
+
POST /api/oracle/sync β Sincronizzazione cluster
|
| 8 |
+
POST /api/oracle/restart β Riavvio servizi
|
| 9 |
+
GET /api/oracle/logs β Log backend remoto
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, HTTPException
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
from typing import Optional
|
| 15 |
+
import logging
|
| 16 |
+
|
| 17 |
+
# Import oracle manager
|
| 18 |
+
try:
|
| 19 |
+
from backend.api.oracle_node_manager import (
|
| 20 |
+
initialize_oracle_node,
|
| 21 |
+
get_oracle_health,
|
| 22 |
+
sync_oracle_cluster,
|
| 23 |
+
get_oracle_metrics,
|
| 24 |
+
restart_oracle_services,
|
| 25 |
+
ORACLE_NODE_E_ENABLED,
|
| 26 |
+
ORACLE_NODE_E_IP,
|
| 27 |
+
)
|
| 28 |
+
except ImportError:
|
| 29 |
+
from oracle_node_manager import (
|
| 30 |
+
initialize_oracle_node,
|
| 31 |
+
get_oracle_health,
|
| 32 |
+
sync_oracle_cluster,
|
| 33 |
+
get_oracle_metrics,
|
| 34 |
+
restart_oracle_services,
|
| 35 |
+
ORACLE_NODE_E_ENABLED,
|
| 36 |
+
ORACLE_NODE_E_IP,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
_logger = logging.getLogger("oracle_endpoints")
|
| 40 |
+
|
| 41 |
+
router = APIRouter(prefix="/api/oracle", tags=["oracle"])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ββ Modelli Pydantic ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
class OracleHealthResponse(BaseModel):
|
| 46 |
+
ok: bool
|
| 47 |
+
status: str
|
| 48 |
+
metrics: Optional[dict] = None
|
| 49 |
+
error: Optional[str] = None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class OracleSyncResponse(BaseModel):
|
| 53 |
+
ok: bool
|
| 54 |
+
message: Optional[str] = None
|
| 55 |
+
error: Optional[str] = None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ββ Endpoint: Health Check ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
@router.get("/health", response_model=OracleHealthResponse)
|
| 60 |
+
async def oracle_health():
|
| 61 |
+
"""Ritorna lo stato di salute del Nodo E."""
|
| 62 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 63 |
+
return OracleHealthResponse(
|
| 64 |
+
ok=False,
|
| 65 |
+
status="disabled",
|
| 66 |
+
error="Oracle Node E Γ¨ disabilitato",
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
health = await get_oracle_health()
|
| 71 |
+
return OracleHealthResponse(**health)
|
| 72 |
+
except Exception as exc:
|
| 73 |
+
_logger.error(f"Error getting oracle health: {exc}")
|
| 74 |
+
raise HTTPException(500, "Error getting oracle health")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ββ Endpoint: Metriche ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
@router.get("/metrics")
|
| 79 |
+
async def oracle_metrics():
|
| 80 |
+
"""Ritorna le metriche del Nodo E."""
|
| 81 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 82 |
+
return {
|
| 83 |
+
"ok": False,
|
| 84 |
+
"error": "Oracle Node E Γ¨ disabilitato",
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
metrics = await get_oracle_metrics()
|
| 89 |
+
return {
|
| 90 |
+
"ok": True,
|
| 91 |
+
"data": metrics,
|
| 92 |
+
}
|
| 93 |
+
except Exception as exc:
|
| 94 |
+
_logger.error(f"Error getting oracle metrics: {exc}")
|
| 95 |
+
raise HTTPException(500, "Error getting oracle metrics")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ββ Endpoint: Sincronizzazione βββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
@router.post("/sync", response_model=OracleSyncResponse)
|
| 100 |
+
async def oracle_sync():
|
| 101 |
+
"""Sincronizza il Nodo E con il cluster principale."""
|
| 102 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 103 |
+
return OracleSyncResponse(
|
| 104 |
+
ok=False,
|
| 105 |
+
error="Oracle Node E Γ¨ disabilitato",
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
try:
|
| 109 |
+
result = await sync_oracle_cluster()
|
| 110 |
+
return OracleSyncResponse(
|
| 111 |
+
ok=result.get("ok", False),
|
| 112 |
+
message=result.get("message", result.get("output", "")),
|
| 113 |
+
error=result.get("error"),
|
| 114 |
+
)
|
| 115 |
+
except Exception as exc:
|
| 116 |
+
_logger.error(f"Error syncing oracle cluster: {exc}")
|
| 117 |
+
raise HTTPException(500, "Error syncing oracle cluster")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ββ Endpoint: Riavvio Servizi ββββββββββββββββββββββββββββββββββββββββββββ
|
| 121 |
+
@router.post("/restart")
|
| 122 |
+
async def oracle_restart():
|
| 123 |
+
"""Riavvia i servizi sul Nodo E."""
|
| 124 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 125 |
+
return {
|
| 126 |
+
"ok": False,
|
| 127 |
+
"error": "Oracle Node E Γ¨ disabilitato",
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
result = await restart_oracle_services()
|
| 132 |
+
return {
|
| 133 |
+
"ok": result.get("ok", False),
|
| 134 |
+
"message": result.get("message"),
|
| 135 |
+
"health": result.get("health"),
|
| 136 |
+
"error": result.get("error"),
|
| 137 |
+
}
|
| 138 |
+
except Exception as exc:
|
| 139 |
+
_logger.error(f"Error restarting oracle services: {exc}")
|
| 140 |
+
raise HTTPException(500, "Error restarting oracle services")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ββ Endpoint: Log ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 144 |
+
@router.get("/logs")
|
| 145 |
+
async def oracle_logs(lines: int = 100):
|
| 146 |
+
"""Ritorna i log del backend remoto."""
|
| 147 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 148 |
+
return {
|
| 149 |
+
"ok": False,
|
| 150 |
+
"error": "Oracle Node E Γ¨ disabilitato",
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
from backend.api.oracle_node_manager import _oracle_manager
|
| 155 |
+
|
| 156 |
+
logs = await _oracle_manager.get_logs(lines)
|
| 157 |
+
return {
|
| 158 |
+
"ok": True,
|
| 159 |
+
"logs": logs,
|
| 160 |
+
"lines": lines,
|
| 161 |
+
}
|
| 162 |
+
except Exception as exc:
|
| 163 |
+
_logger.error(f"Error getting oracle logs: {exc}")
|
| 164 |
+
raise HTTPException(500, "Error getting oracle logs")
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ββ Endpoint: Info ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
+
@router.get("/info")
|
| 169 |
+
async def oracle_info():
|
| 170 |
+
"""Ritorna informazioni sul Nodo E."""
|
| 171 |
+
try:
|
| 172 |
+
from backend.api.oracle_node_manager import (
|
| 173 |
+
ORACLE_NODE_E_ROLE,
|
| 174 |
+
ORACLE_NODE_E_REGION,
|
| 175 |
+
ORACLE_NODE_E_PORT,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
"enabled": ORACLE_NODE_E_ENABLED,
|
| 180 |
+
"ip": ORACLE_NODE_E_IP,
|
| 181 |
+
"role": ORACLE_NODE_E_ROLE,
|
| 182 |
+
"region": ORACLE_NODE_E_REGION,
|
| 183 |
+
"port": ORACLE_NODE_E_PORT,
|
| 184 |
+
"node_id": "E",
|
| 185 |
+
"type": "Oracle Cloud (OCI)",
|
| 186 |
+
}
|
| 187 |
+
except Exception as exc:
|
| 188 |
+
_logger.error(f"Error getting oracle info: {exc}")
|
| 189 |
+
raise HTTPException(500, "Error getting oracle info")
|
api/oracle_node_manager.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
oracle_node_manager.py β Gestore Nodo E (Oracle Cloud)
|
| 3 |
+
|
| 4 |
+
FunzionalitΓ :
|
| 5 |
+
- Monitoraggio stato del server Oracle
|
| 6 |
+
- Sincronizzazione automatica con cluster principale
|
| 7 |
+
- Gestione task distribuiti
|
| 8 |
+
- Health check e failover
|
| 9 |
+
- Logging e metriche
|
| 10 |
+
|
| 11 |
+
Utilizzo:
|
| 12 |
+
from backend.api.oracle_node_manager import OracleNodeManager
|
| 13 |
+
manager = OracleNodeManager()
|
| 14 |
+
await manager.sync_with_cluster()
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import asyncio
|
| 19 |
+
import logging
|
| 20 |
+
from .oci_gateway_manager import oci_gateway
|
| 21 |
+
import json
|
| 22 |
+
from typing import Optional, Dict, List, Any
|
| 23 |
+
from datetime import datetime, timedelta
|
| 24 |
+
from enum import Enum
|
| 25 |
+
import httpx
|
| 26 |
+
import paramiko
|
| 27 |
+
|
| 28 |
+
_logger = logging.getLogger("oracle_node_manager")
|
| 29 |
+
|
| 30 |
+
# ββ Configurazione Oracle Node E βββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
ORACLE_NODE_E_ENABLED = os.getenv("ORACLE_NODE_E_ENABLED", "true").lower() == "true"
|
| 32 |
+
ORACLE_NODE_E_IP = os.getenv("ORACLE_NODE_E_IP", "80.225.89.217")
|
| 33 |
+
ORACLE_NODE_E_USER = os.getenv("ORACLE_NODE_E_USER", "opc")
|
| 34 |
+
ORACLE_NODE_E_SSH_KEY_PATH = os.getenv("ORACLE_NODE_E_SSH_KEY_PATH", "/app/secrets/oracle_private_key.pem")
|
| 35 |
+
ORACLE_NODE_E_PORT = int(os.getenv("ORACLE_NODE_E_PORT", "8080"))
|
| 36 |
+
ORACLE_NODE_E_ROLE = os.getenv("ORACLE_NODE_E_ROLE", "Compute/AI Workload")
|
| 37 |
+
ORACLE_NODE_E_REGION = os.getenv("ORACLE_NODE_E_REGION", "Italy Northwest (Milan)")
|
| 38 |
+
|
| 39 |
+
# ββ Enumerazione Stato Nodo ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
class NodeStatus(str, Enum):
|
| 41 |
+
HEALTHY = "healthy"
|
| 42 |
+
DEGRADED = "degraded"
|
| 43 |
+
UNHEALTHY = "unhealthy"
|
| 44 |
+
OFFLINE = "offline"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ββ SSH Client per Oracle ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
+
class OracleSSHClient:
|
| 49 |
+
"""Client SSH per gestire il server Oracle."""
|
| 50 |
+
|
| 51 |
+
def __init__(self, host: str, user: str, key_path: str):
|
| 52 |
+
self.host = host
|
| 53 |
+
self.user = user
|
| 54 |
+
self.key_path = key_path
|
| 55 |
+
self.client = None
|
| 56 |
+
|
| 57 |
+
async def connect(self) -> bool:
|
| 58 |
+
"""Connette al server Oracle via SSH."""
|
| 59 |
+
try:
|
| 60 |
+
self.client = paramiko.SSHClient()
|
| 61 |
+
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
| 62 |
+
|
| 63 |
+
# Carica chiave privata
|
| 64 |
+
if not os.path.exists(self.key_path):
|
| 65 |
+
_logger.error(f"Chiave SSH non trovata: {self.key_path}")
|
| 66 |
+
return False
|
| 67 |
+
|
| 68 |
+
pkey = paramiko.RSAKey.from_private_key_file(self.key_path)
|
| 69 |
+
self.client.connect(self.host, username=self.user, pkey=pkey, timeout=10)
|
| 70 |
+
|
| 71 |
+
_logger.info(f"Connesso a Oracle Node E: {self.host}")
|
| 72 |
+
return True
|
| 73 |
+
except Exception as exc:
|
| 74 |
+
_logger.error(f"Errore connessione SSH: {exc}")
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
async def execute_command(self, cmd: str) -> tuple[bool, str]:
|
| 78 |
+
"""Esegue un comando remoto."""
|
| 79 |
+
try:
|
| 80 |
+
if not self.client:
|
| 81 |
+
return False, "Non connesso"
|
| 82 |
+
|
| 83 |
+
stdin, stdout, stderr = self.client.exec_command(cmd, timeout=30)
|
| 84 |
+
output = stdout.read().decode('utf-8')
|
| 85 |
+
error = stderr.read().decode('utf-8')
|
| 86 |
+
|
| 87 |
+
return True, output if output else error
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
_logger.error(f"Errore esecuzione comando: {exc}")
|
| 90 |
+
return False, str(exc)
|
| 91 |
+
|
| 92 |
+
async def disconnect(self):
|
| 93 |
+
"""Disconnette dal server."""
|
| 94 |
+
if self.client:
|
| 95 |
+
self.client.close()
|
| 96 |
+
_logger.info("Disconnesso da Oracle Node E")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ββ Oracle Node Manager ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
+
class OracleNodeManager:
|
| 101 |
+
"""Gestore del Nodo E (Oracle Cloud)."""
|
| 102 |
+
|
| 103 |
+
def __init__(self):
|
| 104 |
+
self.ssh_client = OracleSSHClient(ORACLE_NODE_E_IP, ORACLE_NODE_E_USER, ORACLE_NODE_E_SSH_KEY_PATH)
|
| 105 |
+
self.status = NodeStatus.OFFLINE
|
| 106 |
+
self._last_sync = None
|
| 107 |
+
self._metrics = {
|
| 108 |
+
"cpu_usage": 0.0,
|
| 109 |
+
"memory_usage": 0.0,
|
| 110 |
+
"disk_usage": 0.0,
|
| 111 |
+
"uptime_seconds": 0,
|
| 112 |
+
"sync_lag_seconds": 0,
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
async def initialize(self) -> bool:
|
| 116 |
+
"""Inizializza la connessione al Nodo E."""
|
| 117 |
+
if not ORACLE_NODE_E_ENABLED:
|
| 118 |
+
_logger.info("Oracle Node E disabilitato")
|
| 119 |
+
return False
|
| 120 |
+
|
| 121 |
+
connected = await self.ssh_client.connect()
|
| 122 |
+
if connected:
|
| 123 |
+
self.status = NodeStatus.HEALTHY
|
| 124 |
+
return True
|
| 125 |
+
else:
|
| 126 |
+
self.status = NodeStatus.OFFLINE
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
async def health_check(self) -> Dict[str, Any]:
|
| 130 |
+
# Tentativo tramite Gateway se la connessione diretta fallisce
|
| 131 |
+
gateway_res = await oci_gateway.get_gateway_status()
|
| 132 |
+
if gateway_res.get("ok"):
|
| 133 |
+
_logger.info("Connessione via OCI API Gateway attiva")
|
| 134 |
+
"""Esegue health check del Nodo E."""
|
| 135 |
+
if not self.ssh_client.client:
|
| 136 |
+
return {
|
| 137 |
+
"ok": False,
|
| 138 |
+
"status": NodeStatus.OFFLINE.value,
|
| 139 |
+
"error": "Non connesso",
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
# Verifica backend
|
| 144 |
+
backend_ok, _ = await self.ssh_client.execute_command(
|
| 145 |
+
f"curl -s http://localhost:{ORACLE_NODE_E_PORT}/api/health || echo 'FAIL'"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
# Verifica risorse
|
| 149 |
+
cpu_ok, cpu_output = await self.ssh_client.execute_command("top -bn1 | grep 'Cpu(s)' | awk '{print $2}'")
|
| 150 |
+
mem_ok, mem_output = await self.ssh_client.execute_command("free | grep Mem | awk '{printf \"%.1f\", ($3/$2)*100}'")
|
| 151 |
+
disk_ok, disk_output = await self.ssh_client.execute_command("df -h / | tail -1 | awk '{print $5}'")
|
| 152 |
+
|
| 153 |
+
# Aggiorna metriche
|
| 154 |
+
self._metrics["cpu_usage"] = float(cpu_output.strip()) if cpu_ok else 0.0
|
| 155 |
+
self._metrics["memory_usage"] = float(mem_output.strip()) if mem_ok else 0.0
|
| 156 |
+
self._metrics["disk_usage"] = float(disk_output.strip().rstrip('%')) if disk_ok else 0.0
|
| 157 |
+
|
| 158 |
+
# Determina stato
|
| 159 |
+
if backend_ok and self._metrics["cpu_usage"] < 80 and self._metrics["memory_usage"] < 85:
|
| 160 |
+
self.status = NodeStatus.HEALTHY
|
| 161 |
+
elif self._metrics["cpu_usage"] < 95 and self._metrics["memory_usage"] < 95:
|
| 162 |
+
self.status = NodeStatus.DEGRADED
|
| 163 |
+
else:
|
| 164 |
+
self.status = NodeStatus.UNHEALTHY
|
| 165 |
+
|
| 166 |
+
return {
|
| 167 |
+
"ok": True,
|
| 168 |
+
"status": self.status.value,
|
| 169 |
+
"metrics": self._metrics,
|
| 170 |
+
"timestamp": datetime.now().isoformat(),
|
| 171 |
+
}
|
| 172 |
+
except Exception as exc:
|
| 173 |
+
_logger.error(f"Errore health check: {exc}")
|
| 174 |
+
self.status = NodeStatus.UNHEALTHY
|
| 175 |
+
return {
|
| 176 |
+
"ok": False,
|
| 177 |
+
"status": NodeStatus.UNHEALTHY.value,
|
| 178 |
+
"error": str(exc),
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
async def sync_with_cluster(self) -> Dict[str, Any]:
|
| 182 |
+
"""Sincronizza il Nodo E con il cluster principale."""
|
| 183 |
+
if not self.ssh_client.client:
|
| 184 |
+
return {"ok": False, "error": "Non connesso"}
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
# Esegui script di sincronizzazione remoto
|
| 188 |
+
sync_cmd = "cd /home/opc/ai-cluster/repo && node scripts/sync-cluster-state.mjs --node E"
|
| 189 |
+
success, output = await self.ssh_client.execute_command(sync_cmd)
|
| 190 |
+
|
| 191 |
+
if success:
|
| 192 |
+
self._last_sync = datetime.now()
|
| 193 |
+
_logger.info(f"Sincronizzazione completata: {output}")
|
| 194 |
+
return {
|
| 195 |
+
"ok": True,
|
| 196 |
+
"message": "Sincronizzazione completata",
|
| 197 |
+
"output": output,
|
| 198 |
+
"timestamp": datetime.now().isoformat(),
|
| 199 |
+
}
|
| 200 |
+
else:
|
| 201 |
+
return {
|
| 202 |
+
"ok": False,
|
| 203 |
+
"error": output,
|
| 204 |
+
}
|
| 205 |
+
except Exception as exc:
|
| 206 |
+
_logger.error(f"Errore sincronizzazione: {exc}")
|
| 207 |
+
return {
|
| 208 |
+
"ok": False,
|
| 209 |
+
"error": str(exc),
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
async def get_logs(self, lines: int = 100) -> str:
|
| 213 |
+
"""Recupera i log del backend remoto."""
|
| 214 |
+
if not self.ssh_client.client:
|
| 215 |
+
return "Non connesso"
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
cmd = f"tail -n {lines} /home/opc/ai-cluster/backend.log"
|
| 219 |
+
success, output = await self.ssh_client.execute_command(cmd)
|
| 220 |
+
return output if success else "Errore lettura log"
|
| 221 |
+
except Exception as exc:
|
| 222 |
+
return f"Errore: {str(exc)}"
|
| 223 |
+
|
| 224 |
+
async def restart_services(self) -> Dict[str, Any]:
|
| 225 |
+
"""Riavvia i servizi sul Nodo E."""
|
| 226 |
+
if not self.ssh_client.client:
|
| 227 |
+
return {"ok": False, "error": "Non connesso"}
|
| 228 |
+
|
| 229 |
+
try:
|
| 230 |
+
# Ferma servizi
|
| 231 |
+
await self.ssh_client.execute_command("pkill -f 'npm start' || true")
|
| 232 |
+
await asyncio.sleep(2)
|
| 233 |
+
|
| 234 |
+
# Avvia servizi
|
| 235 |
+
start_cmd = "cd /home/opc/ai-cluster/repo/backend && nohup npm start > /home/opc/ai-cluster/backend.log 2>&1 &"
|
| 236 |
+
success, output = await self.ssh_client.execute_command(start_cmd)
|
| 237 |
+
|
| 238 |
+
await asyncio.sleep(3)
|
| 239 |
+
|
| 240 |
+
# Verifica health
|
| 241 |
+
health = await self.health_check()
|
| 242 |
+
|
| 243 |
+
return {
|
| 244 |
+
"ok": True,
|
| 245 |
+
"message": "Servizi riavviati",
|
| 246 |
+
"health": health,
|
| 247 |
+
"timestamp": datetime.now().isoformat(),
|
| 248 |
+
}
|
| 249 |
+
except Exception as exc:
|
| 250 |
+
_logger.error(f"Errore riavvio servizi: {exc}")
|
| 251 |
+
return {
|
| 252 |
+
"ok": False,
|
| 253 |
+
"error": str(exc),
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
async def get_metrics(self) -> Dict[str, Any]:
|
| 257 |
+
"""Ritorna metriche del Nodo E."""
|
| 258 |
+
return {
|
| 259 |
+
"node": "E",
|
| 260 |
+
"ip": ORACLE_NODE_E_IP,
|
| 261 |
+
"role": ORACLE_NODE_E_ROLE,
|
| 262 |
+
"region": ORACLE_NODE_E_REGION,
|
| 263 |
+
"status": self.status.value,
|
| 264 |
+
"metrics": self._metrics,
|
| 265 |
+
"last_sync": self._last_sync.isoformat() if self._last_sync else None,
|
| 266 |
+
"timestamp": datetime.now().isoformat(),
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
async def cleanup(self):
|
| 270 |
+
"""Pulizia risorse."""
|
| 271 |
+
await self.ssh_client.disconnect()
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ββ Istanza Globale βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 275 |
+
_oracle_manager = OracleNodeManager()
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ββ Funzioni Pubbliche ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 279 |
+
async def initialize_oracle_node() -> bool:
|
| 280 |
+
"""Inizializza il Nodo E."""
|
| 281 |
+
return await _oracle_manager.initialize()
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
async def get_oracle_health() -> Dict[str, Any]:
|
| 285 |
+
"""Ritorna health check del Nodo E."""
|
| 286 |
+
return await _oracle_manager.health_check()
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
async def sync_oracle_cluster() -> Dict[str, Any]:
|
| 290 |
+
"""Sincronizza il Nodo E con il cluster."""
|
| 291 |
+
return await _oracle_manager.sync_with_cluster()
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
async def get_oracle_metrics() -> Dict[str, Any]:
|
| 295 |
+
"""Ritorna metriche del Nodo E."""
|
| 296 |
+
return await _oracle_manager.get_metrics()
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
async def restart_oracle_services() -> Dict[str, Any]:
|
| 300 |
+
"""Riavvia servizi sul Nodo E."""
|
| 301 |
+
return await _oracle_manager.restart_services()
|
api/prewarm.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/prewarm.py β Predictive Pre-warming (S950)
|
| 3 |
+
Rileva se un task richiederΓ i nodi HANDS (B) o MEMORY (C) e invia segnali di risveglio preventivi.
|
| 4 |
+
Integrato con fire_speculative_tools per latenza zero.
|
| 5 |
+
"""
|
| 6 |
+
import asyncio
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import httpx
|
| 11 |
+
|
| 12 |
+
_logger = logging.getLogger("agente_ai.prewarm")
|
| 13 |
+
|
| 14 |
+
# Configurazione nodi (Sync con daemon-health-autofix.mjs)
|
| 15 |
+
WAKE_CONFIG = {
|
| 16 |
+
"B": {"owner": "Baida07", "name": "ai-backend-collab", "token": "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", "url": "https://baida07-ai-backend-collab.hf.space"},
|
| 17 |
+
"C": {"owner": "Baida07", "name": "ai-memory-backend", "token": "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", "url": "https://baida07-ai-memory-backend.hf.space"}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
# Regex per rilevare necessitΓ di nodi specifici
|
| 21 |
+
RE_HANDS = re.compile(r"\b(crea|fai|scrivi|build|deploy|git|push|commit|shell|terminal|esegui|run|installa|npm|pip|pnpm|setup)\b", re.I)
|
| 22 |
+
RE_MEMORY = re.compile(r"\b(ricorda|memoria|storia|passato|semantica|rag|conoscenza|database|vecchi|precedenti|documenti)\b", re.I)
|
| 23 |
+
|
| 24 |
+
async def _wake_hf_space(node_id: str):
|
| 25 |
+
cfg = WAKE_CONFIG.get(node_id)
|
| 26 |
+
if not cfg: return
|
| 27 |
+
|
| 28 |
+
_logger.info(f"[S950] Pre-warming Nodo {node_id} ({cfg['owner']}/{cfg['name']})...")
|
| 29 |
+
try:
|
| 30 |
+
async with httpx.AsyncClient() as client:
|
| 31 |
+
# 1. Soft Ping
|
| 32 |
+
try: await client.get(f"{cfg['url']}/health", timeout=2.0)
|
| 33 |
+
except: pass
|
| 34 |
+
# 2. Hard Wake (API Restart)
|
| 35 |
+
url = f"https://huggingface.co/api/spaces/{cfg['owner']}/{cfg['name']}/restart"
|
| 36 |
+
headers = {"Authorization": f"Bearer {cfg['token']}"}
|
| 37 |
+
await client.post(url, headers=headers, timeout=5.0)
|
| 38 |
+
except Exception as e:
|
| 39 |
+
_logger.error(f"[S950] Errore pre-warming {node_id}: {e}")
|
| 40 |
+
|
| 41 |
+
async def predictive_prewarm(goal: str):
|
| 42 |
+
tasks = []
|
| 43 |
+
if RE_HANDS.search(goal): tasks.append(_wake_hf_space("B"))
|
| 44 |
+
if RE_MEMORY.search(goal): tasks.append(_wake_hf_space("C"))
|
| 45 |
+
if tasks: await asyncio.gather(*tasks)
|
| 46 |
+
|
| 47 |
+
def fire_predictive_prewarm(goal: str):
|
| 48 |
+
try: asyncio.create_task(predictive_prewarm(goal))
|
| 49 |
+
except Exception as e: _logger.error(f"[S950] Impossibile avviare task prewarm: {e}")
|
api/telegram_callbacks.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/telegram_callbacks.py β Gestione callback_query e inline_query Telegram.
|
| 2 |
+
|
| 3 |
+
Handler:
|
| 4 |
+
_handle_inline β inline query (@bot testo in chat)
|
| 5 |
+
_handle_callback β callback_data dei bottoni inline
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
import asyncio, html, logging, os, time
|
| 9 |
+
|
| 10 |
+
from .telegram_tg_client import (
|
| 11 |
+
_get_bot_token, _tg_reply, _tg_send, _tg_edit,
|
| 12 |
+
_tg_typing, _tg_answer_callback, _fmt_elapsed,
|
| 13 |
+
_log_tg_exc, _BACK_KB,
|
| 14 |
+
)
|
| 15 |
+
from .telegram_keyboards import (
|
| 16 |
+
_MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL,
|
| 17 |
+
)
|
| 18 |
+
from .telegram_cmd_ai import (
|
| 19 |
+
_cmd_do, _cmd_autofix, _cmd_score, _cmd_bench, _cmd_improve,
|
| 20 |
+
_cmd_scan_now, _cmd_telemetry, _cmd_coord, _cmd_git,
|
| 21 |
+
)
|
| 22 |
+
from .telegram_cmd_monitoring import (
|
| 23 |
+
_cmd_help, _cmd_status, _cmd_tasks, _cmd_check,
|
| 24 |
+
_cmd_logs,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
_logger = logging.getLogger("api.telegram_webhook")
|
| 28 |
+
|
| 29 |
+
# ββ callback_query handler β inline buttons da notify_task_done βββββββββββββββ
|
| 30 |
+
|
| 31 |
+
async def _handle_inline(iq: dict, token: str) -> None:
|
| 32 |
+
"""Gestisce inline query: @ARJagent_ap_bot <testo> in qualsiasi chat.
|
| 33 |
+
Abilita tramite @BotFather β /setinline β ARJagent_ap_bot.
|
| 34 |
+
"""
|
| 35 |
+
bot_token = token or _get_bot_token()
|
| 36 |
+
if not bot_token: return
|
| 37 |
+
iq_id = iq.get("id","")
|
| 38 |
+
query = (iq.get("query") or "").strip()
|
| 39 |
+
q60 = query[:60]
|
| 40 |
+
if not query:
|
| 41 |
+
results = [
|
| 42 |
+
{"type":"article","id":"help","title":"π€ Comandi Agente AI",
|
| 43 |
+
"description":"Lista tutti i comandi",
|
| 44 |
+
"input_message_content":{"message_text":
|
| 45 |
+
"π€ <b>Agente AI</b>\n/do <goal> β task streaming\n/ask <q> β risposta veloce\n"
|
| 46 |
+
"/autofix β fix errori auto\n/status β stato\n\nagente-ai.pages.dev","parse_mode":"HTML"}},
|
| 47 |
+
{"type":"article","id":"do","title":"π Nuovo Task AI",
|
| 48 |
+
"description":"/do <goal> β lancia task complesso",
|
| 49 |
+
"input_message_content":{"message_text":"/do "}},
|
| 50 |
+
{"type":"article","id":"ask","title":"π§ Domanda AI",
|
| 51 |
+
"description":"/ask <domanda>",
|
| 52 |
+
"input_message_content":{"message_text":"/ask "}},
|
| 53 |
+
]
|
| 54 |
+
else:
|
| 55 |
+
results = [
|
| 56 |
+
{"type":"article","id":"ask","title":f"π§ Chiedi: {q60}",
|
| 57 |
+
"description":f"/ask {q60}",
|
| 58 |
+
"input_message_content":{"message_text":f"/ask {query}"}},
|
| 59 |
+
{"type":"article","id":"do","title":f"π Task: {q60}",
|
| 60 |
+
"description":f"/do {q60}",
|
| 61 |
+
"input_message_content":{"message_text":f"/do {query}"}},
|
| 62 |
+
{"type":"article","id":"fix","title":f"π§ AutoFix: {q60}",
|
| 63 |
+
"description":f"/autofix {q60}",
|
| 64 |
+
"input_message_content":{"message_text":f"/autofix {query}"}},
|
| 65 |
+
]
|
| 66 |
+
try:
|
| 67 |
+
import httpx as _hx
|
| 68 |
+
async with _hx.AsyncClient(timeout=5.0) as c:
|
| 69 |
+
await c.post(f"https://api.telegram.org/bot{bot_token}/answerInlineQuery",
|
| 70 |
+
json={"inline_query_id":iq_id,"results":results,"cache_time":30,"is_personal":True})
|
| 71 |
+
except Exception as exc:
|
| 72 |
+
_logger.debug("inline answer error: %s", exc)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def _handle_callback(callback_query: dict, token: str) -> None:
|
| 76 |
+
"""Gestisce i callback_data dei bottoni inline (notify_task_done, menu)."""
|
| 77 |
+
cq_id = callback_query.get("id", "")
|
| 78 |
+
chat_id = callback_query.get("message", {}).get("chat", {}).get("id", 0)
|
| 79 |
+
data = (callback_query.get("data") or "").strip()
|
| 80 |
+
|
| 81 |
+
# Risponde subito per chiudere il loading Telegram
|
| 82 |
+
await _tg_answer_callback(cq_id, token=token)
|
| 83 |
+
|
| 84 |
+
if not chat_id or not data:
|
| 85 |
+
return
|
| 86 |
+
|
| 87 |
+
# ββ task_sum:<tid> β riepilogo task da Supabase ββββββββββββββββββββββββββ
|
| 88 |
+
if data.startswith("task_sum:"):
|
| 89 |
+
tid = data[9:].strip()
|
| 90 |
+
try:
|
| 91 |
+
from api.state import _sb
|
| 92 |
+
if _sb:
|
| 93 |
+
res = await asyncio.to_thread(
|
| 94 |
+
lambda: _sb.table("agent_tasks")
|
| 95 |
+
.select("task_id,goal,status,created_at")
|
| 96 |
+
.eq("task_id", tid)
|
| 97 |
+
.limit(1)
|
| 98 |
+
.execute()
|
| 99 |
+
)
|
| 100 |
+
rows = res.data or []
|
| 101 |
+
if rows:
|
| 102 |
+
r = rows[0]
|
| 103 |
+
st = r.get("status", "?")
|
| 104 |
+
gol = html.escape((r.get("goal") or "")[:200])
|
| 105 |
+
em = {"SUCCESS":"β
","ERROR":"β","RUNNING":"βοΈ","QUEUED":"β³"}.get(st,"β’")
|
| 106 |
+
ca = r.get("created_at", 0)
|
| 107 |
+
age = _fmt_elapsed(ca) if isinstance(ca, int) and ca > 1_000_000 else "?"
|
| 108 |
+
await _tg_reply(
|
| 109 |
+
chat_id,
|
| 110 |
+
"π <b>Riepilogo task</b>\n\n"
|
| 111 |
+
"<b>ID:</b> <code>" + html.escape(tid) + "</code>\n"
|
| 112 |
+
"<b>Stato:</b> " + em + " " + st + "\n"
|
| 113 |
+
"<b>Goal:</b> " + gol + "\n"
|
| 114 |
+
"<b>Avviato:</b> " + age,
|
| 115 |
+
token=token,
|
| 116 |
+
)
|
| 117 |
+
return
|
| 118 |
+
await _tg_reply(chat_id, "β οΈ Task <code>" + html.escape(tid) + "</code> non trovato in Supabase.", token=token)
|
| 119 |
+
except Exception as exc:
|
| 120 |
+
await _tg_reply(chat_id, "β Errore: " + html.escape(str(exc)[:200]), token=token)
|
| 121 |
+
|
| 122 |
+
# ββ task_wins:<tid> β quick wins (non persisti β riepilogo generico) βββββ
|
| 123 |
+
elif data.startswith("task_wins:"):
|
| 124 |
+
tid = data[10:].strip()
|
| 125 |
+
await _tg_reply(
|
| 126 |
+
chat_id,
|
| 127 |
+
"π‘ <b>Quick Wins</b> β <code>" + html.escape(tid) + "</code>\n\n"
|
| 128 |
+
"I quick wins vengono inclusi nella notifica del task.\n"
|
| 129 |
+
"Avvia <b>/tasks</b> per vedere i task recenti o usa il pannello web per dettagli completi.\n\n"
|
| 130 |
+
"<a href='https://agente-ai.pages.dev'>Apri Dashboard β</a>",
|
| 131 |
+
token=token,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# ββ agent β nuovo task (quick-pick) ββββββββββββββββββββββββββββββββββββββ
|
| 135 |
+
elif data == "agent":
|
| 136 |
+
await _tg_reply(
|
| 137 |
+
chat_id,
|
| 138 |
+
"π <b>Avvia Task</b> β scegli un template o scrivi il tuo obiettivo:",
|
| 139 |
+
token=token, keyboard=_QUICK_PICK_KB,
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# ββ tgw_status / tgw_tasks / tgw_health / tgw_help β menu comandi ββββββββ
|
| 143 |
+
elif data == "tgw_chart":
|
| 144 |
+
await _tg_reply(chat_id,
|
| 145 |
+
"π <b>Grafici</b>\n\nHeatmap commit, burndown sprint e molto altro nella Dashboard:",
|
| 146 |
+
token=token, keyboard=_WEBAPP_KB)
|
| 147 |
+
elif data == "tgw_ask":
|
| 148 |
+
await _tg_reply(chat_id,
|
| 149 |
+
"π§ <b>Chiedi all'AI</b>\n\n"
|
| 150 |
+
"Scrivi la domanda direttamente o usa:\n"
|
| 151 |
+
"<code>/ask <domanda></code>\n\n"
|
| 152 |
+
"<i>Esempio: /ask come ottimizzare una query PostgreSQL?</i>",
|
| 153 |
+
token=token, keyboard=_BACK_KB)
|
| 154 |
+
elif data == "tgw_bench":
|
| 155 |
+
asyncio.create_task(_cmd_bench(chat_id)).add_done_callback(_log_tg_exc)
|
| 156 |
+
elif data == "tgw_autofix":
|
| 157 |
+
asyncio.create_task(_cmd_autofix(chat_id,"")).add_done_callback(_log_tg_exc)
|
| 158 |
+
elif data == "tgw_logs":
|
| 159 |
+
asyncio.create_task(_cmd_logs(chat_id,"WARNING")).add_done_callback(_log_tg_exc)
|
| 160 |
+
elif data == "tgw_bench_fix":
|
| 161 |
+
# Legge gap dall'ultimo run (cache) e chiede all'agente un fix mirato
|
| 162 |
+
cached = _BENCH_CACHE.get(chat_id, {})
|
| 163 |
+
goal = (
|
| 164 |
+
"Analizza i gap del benchmark-extended (bench.yml) piΓΉ recente. "
|
| 165 |
+
"Identifica le categorie con score piΓΉ basso e genera patch mirate "
|
| 166 |
+
"(prompt rules, retry logic, tool selection) per migliorare ogni area debole. "
|
| 167 |
+
"PrioritΓ : agentic β coding β reasoning. Fai push delle modifiche su GitHub."
|
| 168 |
+
)
|
| 169 |
+
asyncio.create_task(_cmd_do(chat_id, goal)).add_done_callback(_log_tg_exc)
|
| 170 |
+
elif data == "tgw_bench_run":
|
| 171 |
+
# Rilancia bench
|
| 172 |
+
asyncio.create_task(_cmd_bench(chat_id)).add_done_callback(_log_tg_exc)
|
| 173 |
+
elif data == "tgw_score":
|
| 174 |
+
asyncio.create_task(_cmd_score(chat_id)).add_done_callback(_log_tg_exc)
|
| 175 |
+
elif data == "tgw_telemetry":
|
| 176 |
+
asyncio.create_task(_cmd_telemetry(chat_id)).add_done_callback(_log_tg_exc)
|
| 177 |
+
elif data == "tgw_improve":
|
| 178 |
+
asyncio.create_task(_cmd_improve(chat_id)).add_done_callback(_log_tg_exc)
|
| 179 |
+
elif data == "tgw_status":
|
| 180 |
+
await _cmd_status(chat_id)
|
| 181 |
+
elif data == "tgw_tasks":
|
| 182 |
+
await _cmd_tasks(chat_id)
|
| 183 |
+
elif data == "tgw_health":
|
| 184 |
+
await _cmd_scan_now(chat_id)
|
| 185 |
+
elif data == "tgw_check":
|
| 186 |
+
asyncio.create_task(_cmd_check(chat_id)).add_done_callback(_log_tg_exc)
|
| 187 |
+
elif data == "tgw_help":
|
| 188 |
+
await _cmd_help(chat_id)
|
| 189 |
+
elif data == "tgw_coord":
|
| 190 |
+
asyncio.create_task(_cmd_coord(chat_id)).add_done_callback(_log_tg_exc)
|
| 191 |
+
elif data == "tgw_git":
|
| 192 |
+
asyncio.create_task(_cmd_git(chat_id)).add_done_callback(_log_tg_exc)
|
| 193 |
+
|
| 194 |
+
# ββ Sub-menu dispatchers (Reply Keyboard β inline sub-menu) βββββββββββββββββ
|
| 195 |
+
elif data in ("menu_task",):
|
| 196 |
+
await _tg_reply(chat_id, "π <b>Task AI</b> β scegli un'azione:", token=token, keyboard=_TASK_MENU_KB)
|
| 197 |
+
elif data == "menu_status":
|
| 198 |
+
await _cmd_status(chat_id)
|
| 199 |
+
await _tg_reply(chat_id, "π <b>Stato Sistema</b> β altre opzioni:", token=token, keyboard=_STATUS_MENU_KB)
|
| 200 |
+
elif data == "menu_perf":
|
| 201 |
+
await _tg_reply(chat_id, "π <b>Performance</b> β scegli:", token=token, keyboard=_PERF_MENU_KB)
|
| 202 |
+
elif data == "menu_health":
|
| 203 |
+
asyncio.create_task(_cmd_scan_now(chat_id)).add_done_callback(_log_tg_exc)
|
| 204 |
+
elif data == "menu_dev":
|
| 205 |
+
await _tg_reply(chat_id, "π <b>Dev Tools</b> β scegli:", token=token, keyboard=_DEV_MENU_KB)
|
| 206 |
+
elif data == "tgw_home":
|
| 207 |
+
asyncio.create_task(_cmd_help(chat_id)).add_done_callback(_log_tg_exc)
|
| 208 |
+
|
| 209 |
+
# ββ Nuovi callback task/utility ββββββββββββββββββββββββββββββββββββββββββββ
|
| 210 |
+
elif data == "tgw_do":
|
| 211 |
+
await _tg_reply(chat_id,
|
| 212 |
+
"π <b>Nuovo Task AI</b>\n\n"
|
| 213 |
+
"Scrivi il tuo obiettivo nella chat β elaboro tutto come task AI.\n\n"
|
| 214 |
+
"<i>Esempi:</i>\n"
|
| 215 |
+
" <code>analizza bug in backend/api/state.py</code>\n"
|
| 216 |
+
" <code>genera test per providers.py</code>\n"
|
| 217 |
+
" <code>ottimizza le query Supabase piΓΉ lente</code>\n\n"
|
| 218 |
+
"Oppure: <code>/do <goal></code>",
|
| 219 |
+
token=token, keyboard=_BACK_KB)
|
| 220 |
+
elif data == "tgw_briefing":
|
| 221 |
+
asyncio.create_task(_cmd_riepilogo(chat_id)).add_done_callback(_log_tg_exc)
|
| 222 |
+
elif data == "tgw_nota":
|
| 223 |
+
await _tg_reply(chat_id,
|
| 224 |
+
"π <b>Salva Nota</b>\n\n"
|
| 225 |
+
"Usa: <code>/nota <testo da ricordare></code>\n\n"
|
| 226 |
+
"<i>Esempio: /nota domani rivedere il deploy HF Space</i>",
|
| 227 |
+
token=token, keyboard=_BACK_KB)
|
| 228 |
+
elif data == "tgw_cerca":
|
| 229 |
+
await _tg_reply(chat_id,
|
| 230 |
+
"π <b>Cerca sul web</b>\n\n"
|
| 231 |
+
"Usa: <code>/cerca <query></code>\n\n"
|
| 232 |
+
"<i>Esempio: /cerca best practices FastAPI async Python</i>",
|
| 233 |
+
token=token, keyboard=_BACK_KB)
|
| 234 |
+
elif data == "tgw_meteo":
|
| 235 |
+
await _tg_reply(chat_id,
|
| 236 |
+
"π€ <b>Meteo</b>\n\n"
|
| 237 |
+
"Usa: <code>/meteo <cittΓ ></code>\n\n"
|
| 238 |
+
"<i>Esempio: /meteo Milano</i>",
|
| 239 |
+
token=token, keyboard=_BACK_KB)
|
| 240 |
+
elif data == "tgw_providers":
|
| 241 |
+
try:
|
| 242 |
+
from api.providers import _heartbeat_state as _hs_pv
|
| 243 |
+
providers = (_hs_pv or {}).get("providers") or []
|
| 244 |
+
if providers:
|
| 245 |
+
lines = ["π <b>Provider AI</b>\n"]
|
| 246 |
+
best = next((p for p in providers if p.get("ok")), None)
|
| 247 |
+
for p in providers:
|
| 248 |
+
ok = p.get("ok", False)
|
| 249 |
+
icon = "β
" if ok else ("π" if "401" in str(p.get("error","")) else ("π³" if "429" in str(p.get("error","")) else "β"))
|
| 250 |
+
ms = p.get("latency_ms")
|
| 251 |
+
ms_str = f"{ms}ms" if ms else "β"
|
| 252 |
+
name = p.get("name","?")
|
| 253 |
+
star = " β" if (best and p.get("name") == best.get("name")) else ""
|
| 254 |
+
lines.append(f"{icon} <b>{html.escape(name)}</b>{star} β {ms_str}")
|
| 255 |
+
if not ok and p.get("error"):
|
| 256 |
+
lines.append(f" <i>{html.escape(str(p['error'])[:80])}</i>")
|
| 257 |
+
import time as _tpv
|
| 258 |
+
last = (_hs_pv or {}).get("last_run_at",0)
|
| 259 |
+
age = f"{int(_tpv.time()-last)}s fa" if last else "?"
|
| 260 |
+
lines.append(f"\n<i>β± Heartbeat {age}</i>")
|
| 261 |
+
await _tg_reply(chat_id, "\n".join(lines), token=token, keyboard=_HEALTH_MENU_KB)
|
| 262 |
+
else:
|
| 263 |
+
await _tg_reply(chat_id,
|
| 264 |
+
"β οΈ Dati provider non ancora disponibili (~90s al primo heartbeat).",
|
| 265 |
+
token=token, keyboard=_BACK_KB)
|
| 266 |
+
except Exception as exc:
|
| 267 |
+
await _tg_reply(chat_id,
|
| 268 |
+
"β Errore: <code>" + html.escape(str(exc)[:200]) + "</code>",
|
| 269 |
+
token=token, keyboard=_BACK_KB)
|
| 270 |
+
elif data == "tgw_snap":
|
| 271 |
+
try:
|
| 272 |
+
from .integrity_manager import handle_snap_cmd as _hi_snap
|
| 273 |
+
asyncio.create_task(_hi_snap(chat_id, _tg_reply, None)).add_done_callback(_log_tg_exc)
|
| 274 |
+
except Exception:
|
| 275 |
+
await _tg_reply(chat_id, "β οΈ Integrity manager non disponibile.", token=token, keyboard=_BACK_KB)
|
| 276 |
+
elif data == "tgw_verify":
|
| 277 |
+
try:
|
| 278 |
+
from .integrity_manager import handle_verify_cmd as _hi_verify
|
| 279 |
+
asyncio.create_task(_hi_verify(chat_id, _tg_reply, None)).add_done_callback(_log_tg_exc)
|
| 280 |
+
except Exception:
|
| 281 |
+
await _tg_reply(chat_id, "β οΈ Integrity manager non disponibile.", token=token, keyboard=_BACK_KB)
|
| 282 |
+
elif data == "tgw_heal":
|
| 283 |
+
try:
|
| 284 |
+
from .integrity_manager import handle_heal_cmd as _hi_heal
|
| 285 |
+
asyncio.create_task(_hi_heal(chat_id, _tg_reply, None)).add_done_callback(_log_tg_exc)
|
| 286 |
+
except Exception:
|
| 287 |
+
await _tg_reply(chat_id, "β οΈ Integrity manager non disponibile.", token=token, keyboard=_BACK_KB)
|
| 288 |
+
elif data == "tgw_ping":
|
| 289 |
+
ry = os.getenv("RAILWAY_URL","https://ai-production-4c06.up.railway.app")
|
| 290 |
+
try:
|
| 291 |
+
async with httpx.AsyncClient(timeout=8.0) as _hxc:
|
| 292 |
+
r = await _hxc.get(f"{ry}/health")
|
| 293 |
+
j = r.json()
|
| 294 |
+
ok = "β
" if j.get("status")=="ok" else "β οΈ"
|
| 295 |
+
await _tg_reply(chat_id,
|
| 296 |
+
f"π <b>Pong!</b> {ok}\nv{j.get('version','?')} β Railway live",
|
| 297 |
+
token=token, keyboard=_DEV_MENU_KB)
|
| 298 |
+
except Exception as exc:
|
| 299 |
+
await _tg_reply(chat_id,
|
| 300 |
+
"β Railway non raggiungibile\n<code>"+html.escape(str(exc)[:150])+"</code>",
|
| 301 |
+
token=token, keyboard=_BACK_KB)
|
| 302 |
+
|
| 303 |
+
# ββ Quick-pick task templates (MX-QUICKPICK) βββββββββββββββββββββββββββββ
|
| 304 |
+
elif data == "qp_bug":
|
| 305 |
+
asyncio.create_task(_cmd_do(chat_id,
|
| 306 |
+
"Leggi i log di Railway/HF degli ultimi 30 minuti. Identifica gli errori principali, "
|
| 307 |
+
"trova la root cause reale e suggerisci un fix concreto con codice.")).add_done_callback(_log_tg_exc)
|
| 308 |
+
elif data == "qp_db":
|
| 309 |
+
asyncio.create_task(_cmd_do(chat_id,
|
| 310 |
+
"Analizza le query Supabase piΓΉ lente del progetto (agent_tasks, agent_logs, semantic_memory). "
|
| 311 |
+
"Identifica query N+1, indici mancanti, e proponi le ottimizzazioni con SQL concreto.")).add_done_callback(_log_tg_exc)
|
| 312 |
+
elif data == "qp_autofix":
|
| 313 |
+
asyncio.create_task(_cmd_autofix(chat_id, "")).add_done_callback(_log_tg_exc)
|
| 314 |
+
elif data == "qp_commits":
|
| 315 |
+
asyncio.create_task(_cmd_do(chat_id,
|
| 316 |
+
"Leggi gli ultimi 10 commit su Baida98/AI via GitHub API e produci un riepilogo in italiano: "
|
| 317 |
+
"cosa Γ¨ stato fatto, da chi, e qual Γ¨ lo stato attuale del progetto.")).add_done_callback(_log_tg_exc)
|
| 318 |
+
elif data == "qp_docs":
|
| 319 |
+
asyncio.create_task(_cmd_do(chat_id,
|
| 320 |
+
"Genera la documentazione degli endpoint API principali del backend Railway: "
|
| 321 |
+
"/api/telegram, /api/agent, /health. Per ognuno: metodo HTTP, parametri, risposta attesa, esempi.")).add_done_callback(_log_tg_exc)
|
| 322 |
+
elif data == "qp_tests":
|
| 323 |
+
asyncio.create_task(_cmd_do(chat_id,
|
| 324 |
+
"Genera test pytest per i moduli critici del backend: "
|
| 325 |
+
"api/telegram_webhook.py, agents/unified_loop.py, providers. "
|
| 326 |
+
"PrioritΓ : test di integrazione per i path piΓΉ usati e i casi di errore.")).add_done_callback(_log_tg_exc)
|
| 327 |
+
elif data == "qp_custom":
|
| 328 |
+
await _tg_reply(chat_id,
|
| 329 |
+
"βοΈ <b>Scrivi il tuo obiettivo</b>\n"
|
| 330 |
+
"Scrivi liberamente nella chat β lo eseguo come task AI.\n"
|
| 331 |
+
"<i>Esempi:</i>\n"
|
| 332 |
+
" <code>ottimizza le query Supabase lente</code>\n"
|
| 333 |
+
" <code>analizza providers.py e suggerisci fix</code>",
|
| 334 |
+
token=token, keyboard=_BACK_KB)
|
| 335 |
+
# ββ tgw_retry β rilancia l'ultimo task βββββββββββββββββββββββββββββββββββ
|
| 336 |
+
elif data == "tgw_retry":
|
| 337 |
+
last = _LAST_GOAL.get(chat_id)
|
| 338 |
+
if last:
|
| 339 |
+
asyncio.create_task(_cmd_do(chat_id, last)).add_done_callback(_log_tg_exc)
|
| 340 |
+
else:
|
| 341 |
+
await _tg_reply(chat_id,
|
| 342 |
+
"β οΈ Nessun task precedente da ripetere. Avvia un nuovo task:",
|
| 343 |
+
token=token, keyboard=_QUICK_PICK_KB)
|
| 344 |
+
|
| 345 |
+
# ββ m β menu principale βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 346 |
+
elif data == "m":
|
| 347 |
+
await _tg_reply(
|
| 348 |
+
chat_id,
|
| 349 |
+
"π€ <b>Menu principale</b>\n\nScegli un'azione:",
|
| 350 |
+
token=token,
|
| 351 |
+
keyboard=_MAIN_KB,
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
# ββ fallback β command non gestito βββββββββββββββββββββββββββββββββββββββ
|
| 355 |
+
else:
|
| 356 |
+
_logger.debug("callback_query unhandled: %s", data)
|
| 357 |
+
|
| 358 |
+
|
api/telegram_cmd_ai.py
ADDED
|
@@ -0,0 +1,1154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/telegram_cmd_ai.py β Comandi Telegram AI e operativi.
|
| 2 |
+
|
| 3 |
+
Comandi:
|
| 4 |
+
_cmd_do, _cmd_autofix, _cmd_nota, _cmd_cerca, _cmd_meteo,
|
| 5 |
+
_cmd_riepilogo, _cmd_score, _cmd_bench, _cmd_improve
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
import asyncio, html, logging, os, re, time
|
| 9 |
+
|
| 10 |
+
from .telegram_tg_client import (
|
| 11 |
+
_get_bot_token, _tg_reply, _tg_send, _tg_edit,
|
| 12 |
+
_tg_typing, _tg_react, _tg_photo, _fmt_elapsed,
|
| 13 |
+
_log_tg_exc, _BACK_KB,
|
| 14 |
+
)
|
| 15 |
+
from .telegram_keyboards import (
|
| 16 |
+
_MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL,
|
| 17 |
+
_BENCH_ACTION_KB,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
_logger = logging.getLogger("api.telegram_webhook")
|
| 21 |
+
|
| 22 |
+
async def _cmd_do(chat_id: int, goal: str) -> None:
|
| 23 |
+
"""Lancia task AI con streaming progressivo via editMessageText.
|
| 24 |
+
|
| 25 |
+
Flusso: invia msg iniziale β salva message_id β on_step accumula token
|
| 26 |
+
β edit throttled ogni 1.5s β flush finale con output + keyboard.
|
| 27 |
+
Rate-limit sicuro: max ~40 edit/min totali, Telegram consente 20 edit/min/chat.
|
| 28 |
+
"""
|
| 29 |
+
if not goal.strip():
|
| 30 |
+
await _tg_reply(chat_id, "β οΈ Usa il menu per scegliere un task:", keyboard=_QUICK_PICK_KB)
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
_LAST_GOAL[chat_id] = goal # salva per retry
|
| 34 |
+
await _tg_typing(chat_id)
|
| 35 |
+
msg_id = await _tg_send(
|
| 36 |
+
chat_id,
|
| 37 |
+
"π <b>Avvio taskβ¦</b>\n\n"
|
| 38 |
+
"π― <i>" + html.escape(goal[:200]) + "</i>\n\n"
|
| 39 |
+
"β³ <i>Sto elaborando, un momentoβ¦</i>",
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
_buf: list[str] = []
|
| 43 |
+
_last_edit: list[float] = [0.0] # list per mutabilitΓ in closure
|
| 44 |
+
_EDIT_INTERVAL = 1.5
|
| 45 |
+
_MAX_LEN = 3600
|
| 46 |
+
|
| 47 |
+
async def _flush(final: bool = False) -> None:
|
| 48 |
+
if not msg_id:
|
| 49 |
+
return
|
| 50 |
+
content = "".join(_buf).strip()
|
| 51 |
+
if not content:
|
| 52 |
+
return
|
| 53 |
+
now = time.monotonic()
|
| 54 |
+
if not final and (now - _last_edit[0]) < _EDIT_INTERVAL:
|
| 55 |
+
return
|
| 56 |
+
prefix = "π§ <b>Risposta AI</b>\n<b>Goal:</b> " + html.escape(goal[:80]) + "\n\n"
|
| 57 |
+
suffix = "" if final else "\nβ³"
|
| 58 |
+
body = html.escape(content[: _MAX_LEN - len(prefix) - len(suffix)])
|
| 59 |
+
await _tg_edit(chat_id, msg_id, prefix + body + suffix)
|
| 60 |
+
_last_edit[0] = time.monotonic()
|
| 61 |
+
|
| 62 |
+
async def _on_step(event: dict) -> None:
|
| 63 |
+
if event.get("action") == "text_chunk":
|
| 64 |
+
tok = event.get("token", "")
|
| 65 |
+
if tok:
|
| 66 |
+
_buf.append(tok)
|
| 67 |
+
await _flush(final=False)
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 71 |
+
from api.state import _get_ai_client, _get_mem_manager_async, _get_executor, _get_planner
|
| 72 |
+
client = _get_ai_client()
|
| 73 |
+
memory = await _get_mem_manager_async()
|
| 74 |
+
executor = _get_executor()
|
| 75 |
+
planner = _get_planner()
|
| 76 |
+
try:
|
| 77 |
+
from agents.critic import Critic
|
| 78 |
+
from agents.response_verifier import ResponseVerifier
|
| 79 |
+
critic = Critic(llm_client=client)
|
| 80 |
+
verifier = ResponseVerifier()
|
| 81 |
+
except Exception:
|
| 82 |
+
critic = verifier = None
|
| 83 |
+
loop = UnifiedAgentLoop(
|
| 84 |
+
llm_client=client, critic=critic, verifier=verifier,
|
| 85 |
+
memory=memory, executor=executor, planner=planner,
|
| 86 |
+
)
|
| 87 |
+
result = await asyncio.wait_for(
|
| 88 |
+
loop.run(goal=goal, context="", max_steps=8, on_step=_on_step),
|
| 89 |
+
timeout=120.0,
|
| 90 |
+
)
|
| 91 |
+
output = result.get("output", "") if isinstance(result, dict) else str(result)
|
| 92 |
+
await _flush(final=True)
|
| 93 |
+
if msg_id and output:
|
| 94 |
+
_out_str = str(output)
|
| 95 |
+
_out_esc = html.escape(_out_str[:3000])
|
| 96 |
+
# Usa blockquote espandibile per output lunghi (Bot API 7.4+, Jul 2024)
|
| 97 |
+
_out_body = (
|
| 98 |
+
f"<blockquote expandable>{_out_esc}</blockquote>"
|
| 99 |
+
if len(_out_str) > 400 else _out_esc
|
| 100 |
+
)
|
| 101 |
+
await _tg_edit(
|
| 102 |
+
chat_id, msg_id,
|
| 103 |
+
"β
<b>Fatto!</b>\n\n"
|
| 104 |
+
"π― <i>" + html.escape(goal[:100]) + "</i>\n\n"
|
| 105 |
+
+ _out_body,
|
| 106 |
+
keyboard=_after_task_kb(chat_id),
|
| 107 |
+
)
|
| 108 |
+
await _tg_react(chat_id, msg_id, "π")
|
| 109 |
+
try:
|
| 110 |
+
from .telegram_notify import notify_task_done
|
| 111 |
+
task_id = result.get("task_id", "tgw-do") if isinstance(result, dict) else "tgw-do"
|
| 112 |
+
await notify_task_done(str(task_id), goal, str(output))
|
| 113 |
+
except Exception:
|
| 114 |
+
if not msg_id:
|
| 115 |
+
_fb_esc = html.escape(str(output)[:800])
|
| 116 |
+
_fb_body = (
|
| 117 |
+
f"<blockquote expandable>{_fb_esc}</blockquote>"
|
| 118 |
+
if len(str(output)) > 400 else _fb_esc
|
| 119 |
+
)
|
| 120 |
+
await _tg_reply(
|
| 121 |
+
chat_id,
|
| 122 |
+
"β
<b>Fatto!</b>\n\n"
|
| 123 |
+
"π― <i>" + html.escape(goal[:80]) + "</i>\n\n"
|
| 124 |
+
+ _fb_body,
|
| 125 |
+
)
|
| 126 |
+
except asyncio.TimeoutError:
|
| 127 |
+
await _flush(final=True)
|
| 128 |
+
err = ("β± <b>Timeout</b> β task >120s.\n<b>Goal:</b> "
|
| 129 |
+
+ html.escape(goal[:120]) + "\n\n<i>Usa il pannello web.</i>")
|
| 130 |
+
if msg_id: await _tg_edit(chat_id, msg_id, err, keyboard=_MAIN_KB)
|
| 131 |
+
else: await _tg_reply(chat_id, err)
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
await _flush(final=True)
|
| 134 |
+
err = "β <b>Errore</b>\n<code>" + html.escape(str(exc)[:300]) + "</code>"
|
| 135 |
+
if msg_id: await _tg_edit(chat_id, msg_id, err, keyboard=_MAIN_KB)
|
| 136 |
+
else: await _tg_reply(chat_id, err)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
async def _cmd_autofix(chat_id: int, hint: str = "") -> None:
|
| 140 |
+
"""Comando /autofix β legge errori dai log backend, genera patch via AI, pusha su GitHub.
|
| 141 |
+
|
| 142 |
+
Flusso:
|
| 143 |
+
1. GET /api/telegram/logs?level=ERROR β raccoglie ultimi errori
|
| 144 |
+
2. AI loop con streaming (on_step) β analizza e genera patch
|
| 145 |
+
3. Parsa blocco ```autofix\nFILE: path\n---\ncontent``` dall'output AI
|
| 146 |
+
4. Git Data API blob->tree->commit->PATCH ref β push automatico
|
| 147 |
+
5. Riporta commit SHA al chat + link GitHub
|
| 148 |
+
|
| 149 |
+
Env vars Railway: GITHUB_TOKEN (gia' presente), GITHUB_REPO, GITHUB_BRANCH.
|
| 150 |
+
"""
|
| 151 |
+
import httpx, base64 as _b64, json as _json
|
| 152 |
+
await _tg_typing(chat_id, "upload_document")
|
| 153 |
+
|
| 154 |
+
msg_id = await _tg_send(
|
| 155 |
+
chat_id,
|
| 156 |
+
"π§ <b>AutoFix avviato</b>\n\n"
|
| 157 |
+
+ (f"<i>Hint: {html.escape(hint[:100])}</i>\n\n" if hint else "")
|
| 158 |
+
+ "β³ <i>Step 1/4 β Lettura log erroriβ¦</i>",
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
async def _edit(text: str, final: bool = False) -> None:
|
| 162 |
+
if msg_id:
|
| 163 |
+
await _tg_edit(chat_id, msg_id, text, keyboard=_MAIN_KB if final else None)
|
| 164 |
+
|
| 165 |
+
# ββ Step 1: leggi errori dal log endpoint βββββββββββββββββββββββββββββββββββββββββ
|
| 166 |
+
railway_url = os.getenv("RAILWAY_URL","https://ai-production-4c06.up.railway.app").rstrip("/")
|
| 167 |
+
try:
|
| 168 |
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
| 169 |
+
resp = await c.get(f"{railway_url}/api/telegram/logs",
|
| 170 |
+
params={"level": "ERROR", "n": 30})
|
| 171 |
+
log_data = resp.json() if resp.status_code == 200 else {}
|
| 172 |
+
except Exception as e:
|
| 173 |
+
log_data = {}
|
| 174 |
+
_logger.warning("autofix: log fetch: %s", e)
|
| 175 |
+
|
| 176 |
+
records = log_data.get("records", [])
|
| 177 |
+
|
| 178 |
+
if not records and not hint:
|
| 179 |
+
await _edit(
|
| 180 |
+
"β
<b>AutoFix</b>\n\n"
|
| 181 |
+
"π’ Nessun errore nei log recenti!\n\n"
|
| 182 |
+
"<i>Sistema stabile. Usa <code>/autofix <descrizione bug></code> "
|
| 183 |
+
"per fix su un errore specifico.</i>",
|
| 184 |
+
final=True,
|
| 185 |
+
)
|
| 186 |
+
return
|
| 187 |
+
|
| 188 |
+
log_lines = "\n".join(
|
| 189 |
+
f"[{r.get('level','?')}] {r.get('logger','?')}: {r.get('msg','')}"
|
| 190 |
+
for r in records[:15]
|
| 191 |
+
) if records else f"(nessun errore nei log β hint: {hint})"
|
| 192 |
+
|
| 193 |
+
log_preview = "\n".join(
|
| 194 |
+
f"β’ [{r.get('level','?')}] {r.get('logger','?')}: {r.get('msg','')[:80]}"
|
| 195 |
+
for r in records[:5]
|
| 196 |
+
) or f"<i>hint: {html.escape(hint[:100])}</i>"
|
| 197 |
+
|
| 198 |
+
await _edit(
|
| 199 |
+
"π§ <b>AutoFix</b>\n\n"
|
| 200 |
+
"π <b>Errori trovati:</b>\n" + log_preview
|
| 201 |
+
+ "\n\nβ³ <i>Step 2/4 β Analisi AI in corsoβ¦</i>",
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# ββ Step 2: AI loop genera patch βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 205 |
+
BT3 = "```" # triple backtick β non usare literal per evitare syntax issues
|
| 206 |
+
goal = (
|
| 207 |
+
"Sei un senior engineer. Analizza questi errori dal log backend Python "
|
| 208 |
+
"e genera un patch preciso per risolvere il problema principale.\n\n"
|
| 209 |
+
"LOG ERRORI:\n" + log_lines[:2000]
|
| 210 |
+
+ (f"\n\nHINT UTENTE: {hint}" if hint else "")
|
| 211 |
+
+ "\n\n"
|
| 212 |
+
"FORMATO RISPOSTA RICHIESTO (tassativo):\n"
|
| 213 |
+
+ BT3 + "autofix\n"
|
| 214 |
+
+ "FILE: backend/api/<filename>.py\n"
|
| 215 |
+
+ "---\n"
|
| 216 |
+
+ "<contenuto completo del file modificato>\n"
|
| 217 |
+
+ BT3 + "\n\n"
|
| 218 |
+
"Regole:\n"
|
| 219 |
+
"1. Scrivi SOLO il blocco con FILE: e contenuto completo (non un diff)\n"
|
| 220 |
+
"2. Per piu' file includi un blocco per file\n"
|
| 221 |
+
"3. Se non riesci a determinare il file, scrivi FILE: UNKNOWN e spiega"
|
| 222 |
+
)
|
| 223 |
+
context = f"Backend: {railway_url} Repo: {os.getenv('GITHUB_REPO','Baida98/AI')}"
|
| 224 |
+
|
| 225 |
+
_buf: list[str] = []
|
| 226 |
+
_last: list[float] = [0.0]
|
| 227 |
+
|
| 228 |
+
async def _on_step(event: dict) -> None:
|
| 229 |
+
if event.get("action") == "text_chunk":
|
| 230 |
+
tok = event.get("token", "")
|
| 231 |
+
if tok:
|
| 232 |
+
_buf.append(tok)
|
| 233 |
+
now = time.monotonic()
|
| 234 |
+
if now - _last[0] > 2.0:
|
| 235 |
+
preview = "".join(_buf).strip()[-300:]
|
| 236 |
+
await _edit(
|
| 237 |
+
"π§ <b>AutoFix</b> β Step 2/4 AI analizzaβ¦\n\n"
|
| 238 |
+
"<code>" + html.escape(preview) + "</code>\n\nβ³"
|
| 239 |
+
)
|
| 240 |
+
_last[0] = now
|
| 241 |
+
|
| 242 |
+
try:
|
| 243 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 244 |
+
from api.state import _get_ai_client, _get_mem_manager_async, _get_executor, _get_planner
|
| 245 |
+
client = _get_ai_client()
|
| 246 |
+
memory = await _get_mem_manager_async()
|
| 247 |
+
executor = _get_executor()
|
| 248 |
+
planner = _get_planner()
|
| 249 |
+
try:
|
| 250 |
+
from agents.critic import Critic
|
| 251 |
+
from agents.response_verifier import ResponseVerifier
|
| 252 |
+
critic = Critic(llm_client=client)
|
| 253 |
+
verifier = ResponseVerifier()
|
| 254 |
+
except Exception:
|
| 255 |
+
critic = verifier = None
|
| 256 |
+
loop = UnifiedAgentLoop(
|
| 257 |
+
llm_client=client, critic=critic, verifier=verifier,
|
| 258 |
+
memory=memory, executor=executor, planner=planner,
|
| 259 |
+
)
|
| 260 |
+
result = await asyncio.wait_for(
|
| 261 |
+
loop.run(goal=goal, context=context, max_steps=5, on_step=_on_step),
|
| 262 |
+
timeout=150.0,
|
| 263 |
+
)
|
| 264 |
+
ai_output = result.get("output", "") if isinstance(result, dict) else str(result)
|
| 265 |
+
except asyncio.TimeoutError:
|
| 266 |
+
await _edit("β± <b>AutoFix timeout</b> β AI non ha risposto in 150s.\n\n"
|
| 267 |
+
"<i>Riprova con un hint piΓΉ specifico.</i>", final=True)
|
| 268 |
+
return
|
| 269 |
+
except Exception as exc:
|
| 270 |
+
await _edit("β <b>AutoFix β errore AI</b>\n<code>"
|
| 271 |
+
+ html.escape(str(exc)[:300]) + "</code>", final=True)
|
| 272 |
+
return
|
| 273 |
+
|
| 274 |
+
# ββ Step 3: parsa blocco autofix dall'output AI βββββββββββββββββββββββββββββββββββ
|
| 275 |
+
await _edit(
|
| 276 |
+
"π§ <b>AutoFix</b>\n\nβ³ <i>Step 3/4 β Parsing patchβ¦</i>\n\n"
|
| 277 |
+
"<code>" + html.escape(ai_output.strip()[-300:]) + "</code>"
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
import re as _re
|
| 281 |
+
BLOCK_RE = _re.compile(
|
| 282 |
+
r"```autofix\s*\nFILE:\s*(\S+)\s*\n---\s*\n([\s\S]*?)```",
|
| 283 |
+
_re.MULTILINE,
|
| 284 |
+
)
|
| 285 |
+
patches: list[tuple[str, str]] = [
|
| 286 |
+
(m.group(1).strip(), m.group(2))
|
| 287 |
+
for m in BLOCK_RE.finditer(ai_output)
|
| 288 |
+
if m.group(1).strip() not in ("", "UNKNOWN") and m.group(2).strip()
|
| 289 |
+
]
|
| 290 |
+
|
| 291 |
+
if not patches:
|
| 292 |
+
await _edit(
|
| 293 |
+
"β οΈ <b>AutoFix</b> β nessuna patch parsata\n\n"
|
| 294 |
+
"<b>Output AI:</b>\n<code>" + html.escape(ai_output.strip()[:600]) + "</code>\n\n"
|
| 295 |
+
"<i>L'AI non ha prodotto un blocco autofix valido.\n"
|
| 296 |
+
"Prova: <code>/autofix descrizione precisa del bug</code></i>",
|
| 297 |
+
final=True,
|
| 298 |
+
)
|
| 299 |
+
return
|
| 300 |
+
|
| 301 |
+
# ββ Step 4: push su GitHub via Git Data API βββββββββββββββββββββββββββββββββββββββββ
|
| 302 |
+
gh_token = os.getenv("GITHUB_TOKEN", "")
|
| 303 |
+
gh_repo = os.getenv("GITHUB_REPO", "Baida98/AI")
|
| 304 |
+
gh_branch = os.getenv("GITHUB_BRANCH", "main")
|
| 305 |
+
|
| 306 |
+
if not gh_token:
|
| 307 |
+
await _edit(
|
| 308 |
+
"β <b>AutoFix</b> β GITHUB_TOKEN non configurato\n\n"
|
| 309 |
+
"<i>Aggiungi GITHUB_TOKEN nelle Railway env vars.</i>",
|
| 310 |
+
final=True,
|
| 311 |
+
)
|
| 312 |
+
return
|
| 313 |
+
|
| 314 |
+
await _edit(
|
| 315 |
+
"π§ <b>AutoFix</b>\n\n"
|
| 316 |
+
f"β³ <i>Step 4/4 β Push {len(patches)} file su GitHubβ¦</i>\n\n"
|
| 317 |
+
+ "\n".join(f"β’ <code>{p[0]}</code>" for p in patches[:6])
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
gh_hdr = {
|
| 321 |
+
"Authorization": f"token {gh_token}",
|
| 322 |
+
"Accept": "application/vnd.github.v3+json",
|
| 323 |
+
"Content-Type": "application/json",
|
| 324 |
+
"User-Agent": "agente-ai-autofix",
|
| 325 |
+
}
|
| 326 |
+
gh_base = f"https://api.github.com/repos/{gh_repo}"
|
| 327 |
+
|
| 328 |
+
try:
|
| 329 |
+
async with httpx.AsyncClient(timeout=20.0) as c:
|
| 330 |
+
r = await c.get(f"{gh_base}/git/refs/heads/{gh_branch}", headers=gh_hdr)
|
| 331 |
+
r.raise_for_status()
|
| 332 |
+
h_sha = r.json()["object"]["sha"]
|
| 333 |
+
|
| 334 |
+
r = await c.get(f"{gh_base}/git/commits/{h_sha}", headers=gh_hdr)
|
| 335 |
+
r.raise_for_status()
|
| 336 |
+
t_sha = r.json()["tree"]["sha"]
|
| 337 |
+
|
| 338 |
+
tree_items = []
|
| 339 |
+
for fpath, content in patches:
|
| 340 |
+
b64 = _b64.b64encode(content.encode()).decode()
|
| 341 |
+
r = await c.post(f"{gh_base}/git/blobs", headers=gh_hdr,
|
| 342 |
+
json={"content": b64, "encoding": "base64"})
|
| 343 |
+
r.raise_for_status()
|
| 344 |
+
tree_items.append({"path": fpath, "mode": "100644",
|
| 345 |
+
"type": "blob", "sha": r.json()["sha"]})
|
| 346 |
+
|
| 347 |
+
r = await c.post(f"{gh_base}/git/trees", headers=gh_hdr,
|
| 348 |
+
json={"base_tree": t_sha, "tree": tree_items})
|
| 349 |
+
r.raise_for_status()
|
| 350 |
+
new_t = r.json()["sha"]
|
| 351 |
+
|
| 352 |
+
err_summary = ("; ".join(rc.get("msg","")[:60] for rc in records[:2])
|
| 353 |
+
or hint[:60] or "autofix via /autofix command")
|
| 354 |
+
commit_msg = (
|
| 355 |
+
f"fix(autofix): {err_summary}\n\n"
|
| 356 |
+
f"Generato da /autofix β {len(patches)} file patchati\n"
|
| 357 |
+
+ "Patch: " + ", ".join(p[0] for p in patches)
|
| 358 |
+
)
|
| 359 |
+
r = await c.post(f"{gh_base}/git/commits", headers=gh_hdr,
|
| 360 |
+
json={"message": commit_msg, "tree": new_t, "parents": [h_sha]})
|
| 361 |
+
r.raise_for_status()
|
| 362 |
+
c_sha = r.json()["sha"]
|
| 363 |
+
|
| 364 |
+
r = await c.patch(f"{gh_base}/git/refs/heads/{gh_branch}", headers=gh_hdr,
|
| 365 |
+
json={"sha": c_sha, "force": False})
|
| 366 |
+
r.raise_for_status()
|
| 367 |
+
|
| 368 |
+
files_list = "\n".join(f"β’ <code>{p[0]}</code>" for p in patches[:6])
|
| 369 |
+
await _edit(
|
| 370 |
+
"β
<b>AutoFix completato!</b>\n\n"
|
| 371 |
+
f"<b>Commit:</b> <code>{c_sha[:10]}</code>\n"
|
| 372 |
+
f"<b>Branch:</b> <code>{gh_branch}</code>\n"
|
| 373 |
+
f"<b>File patchati:</b>\n{files_list}\n\n"
|
| 374 |
+
"U0001f680 <b>Railway deploy:</b> avviato automaticamente\n"
|
| 375 |
+
f"U0001f517 <a href=\"https://github.com/{gh_repo}/commit/{c_sha}\">Vedi commit</a>",
|
| 376 |
+
final=True,
|
| 377 |
+
)
|
| 378 |
+
_logger.info("autofix: pushed %s β %d files", c_sha[:10], len(patches))
|
| 379 |
+
|
| 380 |
+
except Exception as exc:
|
| 381 |
+
await _edit(
|
| 382 |
+
"β <b>AutoFix β push GitHub fallito</b>\n<code>"
|
| 383 |
+
+ html.escape(str(exc)[:400]) + "</code>\n\n"
|
| 384 |
+
"<i>Verifica GITHUB_TOKEN nelle Railway env vars.</i>",
|
| 385 |
+
final=True,
|
| 386 |
+
)
|
| 387 |
+
_logger.error("autofix: push failed: %s", exc)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
async def _cmd_nota(chat_id: int, text: str) -> None:
|
| 391 |
+
"""Salva nota rapida nella memoria persistente dell'agente."""
|
| 392 |
+
goal = (
|
| 393 |
+
f"Salva questa nota nella tua memoria persistente usando il tool remember: "
|
| 394 |
+
f"Β«{text}Β». Poi confermami con 'β
Nota salvata' e ripeti il testo della nota."
|
| 395 |
+
)
|
| 396 |
+
await _cmd_do(chat_id, goal)
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
async def _cmd_cerca(chat_id: int, query: str) -> None:
|
| 400 |
+
"""Ricerca web + sintesi AI in italiano."""
|
| 401 |
+
goal = (
|
| 402 |
+
f"Cerca su web: {query}. "
|
| 403 |
+
"Usa web_search e sintetizza i risultati piΓΉ rilevanti in 4-5 bullet points concisi "
|
| 404 |
+
"in italiano. Per ogni punto includi la fonte (dominio) tra parentesi."
|
| 405 |
+
)
|
| 406 |
+
await _cmd_do(chat_id, goal)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
async def _cmd_meteo(chat_id: int, city: str) -> None:
|
| 410 |
+
"""Meteo per cittΓ specifica via tool get_weather."""
|
| 411 |
+
goal = (
|
| 412 |
+
f"Usa il tool get_weather per {city} e dimmi: temperatura attuale, "
|
| 413 |
+
"condizioni meteo, umiditΓ , previsioni per le prossime ore. Risposta concisa in italiano."
|
| 414 |
+
)
|
| 415 |
+
await _cmd_do(chat_id, goal)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
async def _cmd_riepilogo(chat_id: int) -> None:
|
| 419 |
+
"""Briefing completo: task recenti + deploy + score + prossimi passi.
|
| 420 |
+
|
| 421 |
+
GAP-TGB: usa benchmark_handler.get_smart_summary per briefing strutturato
|
| 422 |
+
con analisi gap da ultimo report. Fallback: agent-loop se import fallisce.
|
| 423 |
+
"""
|
| 424 |
+
try:
|
| 425 |
+
from .benchmark_handler import get_smart_summary as _gsummary
|
| 426 |
+
text = await _gsummary(chat_id)
|
| 427 |
+
await _tg_reply(chat_id, text, keyboard=_BENCH_ACTION_KB)
|
| 428 |
+
except Exception as _exc:
|
| 429 |
+
_logger.debug("benchmark_handler fallback: %s", _exc)
|
| 430 |
+
goal = (
|
| 431 |
+
"Dammi un briefing completo dello stato attuale come assistente personale. "
|
| 432 |
+
"Struttura ESATTA con questi header ## obbligatori:\n"
|
| 433 |
+
"## π Task recenti (usa recall per recuperare gli ultimi 5)\n"
|
| 434 |
+
"## π Deploy status (CF Pages + Railway + HF Space β dati reali via tool)\n"
|
| 435 |
+
"## π Score AI (ultimo benchmark disponibile)\n"
|
| 436 |
+
"## β οΈ Problemi aperti (errori, timeout, regressioni)\n"
|
| 437 |
+
"## β
Prossimi passi (3 prioritΓ concrete)\n"
|
| 438 |
+
"Usa i tool per dati reali β mai inventare status."
|
| 439 |
+
)
|
| 440 |
+
await _cmd_do(chat_id, goal)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
async def _cmd_scan_now(chat_id: int) -> None:
|
| 444 |
+
"""Health-check via health_full() β AI + Supabase + Telegram."""
|
| 445 |
+
await _tg_reply(chat_id, "π <b>Scan in corsoβ¦</b>")
|
| 446 |
+
try:
|
| 447 |
+
from .providers import health_full
|
| 448 |
+
h = await health_full()
|
| 449 |
+
ai = h.get("ai", {})
|
| 450 |
+
sb = h.get("supabase", {})
|
| 451 |
+
tg = h.get("telegram", {})
|
| 452 |
+
bk = h.get("backend", {})
|
| 453 |
+
ai_ok = "β
" if ai.get("ok") else "β"
|
| 454 |
+
sb_ok = "β
" if sb.get("ok") else "β"
|
| 455 |
+
tg_ok = "β
" if tg.get("ok") else "β"
|
| 456 |
+
over = "β
" if h.get("ok") else "β οΈ"
|
| 457 |
+
sb_msg = "ok" if sb.get("ok") else str(sb.get("error", "?"))[:40]
|
| 458 |
+
tg_msg = ("@" + str(tg.get("username", ""))) if tg.get("username") else str(tg.get("error", "?"))[:40]
|
| 459 |
+
avail = str(ai.get("available", 0)) + "/" + str(ai.get("total", 0))
|
| 460 |
+
best = str(ai.get("best", "?"))
|
| 461 |
+
ntasks = str(bk.get("active_tasks", 0))
|
| 462 |
+
ms_str = str(h.get("elapsed_ms", "?"))
|
| 463 |
+
st_str = str(h.get("status", "?"))
|
| 464 |
+
parts = [
|
| 465 |
+
over + " <b>Health Check</b> β " + st_str,
|
| 466 |
+
"",
|
| 467 |
+
ai_ok + " <b>AI</b> β " + avail + " provider | best: " + best,
|
| 468 |
+
sb_ok + " <b>Supabase</b> β " + sb_msg,
|
| 469 |
+
tg_ok + " <b>Telegram</b> β " + tg_msg,
|
| 470 |
+
"π¦ Task attivi: " + ntasks + " π " + ms_str + "ms",
|
| 471 |
+
]
|
| 472 |
+
await _tg_reply(chat_id, "\n".join(parts), keyboard=_MAIN_KB)
|
| 473 |
+
except Exception as exc:
|
| 474 |
+
await _tg_reply(
|
| 475 |
+
chat_id,
|
| 476 |
+
"β <b>Scan errore</b>\n<code>" + html.escape(str(exc)[:300]) + "</code>",
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
async def _cmd_coord(chat_id: int) -> None:
|
| 481 |
+
"""π /coord β sessioni agent-coord attive e file claimati (sola lettura).
|
| 482 |
+
|
| 483 |
+
Legge la tabella Supabase 'agent_tasks' dove le sessioni agent-coord.mjs
|
| 484 |
+
persistono con goal='__session__<uuid>'. TTL = 5 minuti senza heartbeat.
|
| 485 |
+
Non modifica nulla β pura lettura. Non interferisce con il lavoro agente.
|
| 486 |
+
"""
|
| 487 |
+
import httpx as _hx_c, time as _tc, json as _jc
|
| 488 |
+
SB_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
|
| 489 |
+
SB_KEY = os.getenv("SUPABASE_KEY", "")
|
| 490 |
+
if not SB_KEY:
|
| 491 |
+
await _tg_reply(chat_id, "β οΈ <b>SUPABASE_KEY</b> non configurata su Railway.")
|
| 492 |
+
return
|
| 493 |
+
await _tg_reply(chat_id, "π <b>Agent Coord</b> β lettura sessioniβ¦")
|
| 494 |
+
try:
|
| 495 |
+
async with _hx_c.AsyncClient(timeout=8.0) as _c:
|
| 496 |
+
r = await _c.get(
|
| 497 |
+
f"{SB_URL}/rest/v1/agent_tasks",
|
| 498 |
+
params={"goal": "ilike.*__session__*", "select": "goal,context,updated_at"},
|
| 499 |
+
headers={
|
| 500 |
+
"apikey": SB_KEY,
|
| 501 |
+
"Authorization": f"Bearer {SB_KEY}",
|
| 502 |
+
"Content-Type": "application/json",
|
| 503 |
+
},
|
| 504 |
+
)
|
| 505 |
+
if not r.is_success:
|
| 506 |
+
await _tg_reply(chat_id, f"β Supabase {r.status_code}: {r.text[:200]}")
|
| 507 |
+
return
|
| 508 |
+
rows = r.json() or []
|
| 509 |
+
except Exception as exc:
|
| 510 |
+
await _tg_reply(chat_id,
|
| 511 |
+
"β <code>" + html.escape(str(exc)[:200]) + "</code>")
|
| 512 |
+
return
|
| 513 |
+
|
| 514 |
+
SESSION_TTL_MS = 5 * 60 * 1000 # 5 minuti senza heartbeat = morta
|
| 515 |
+
now_ms = int(_tc.time() * 1000)
|
| 516 |
+
active, stale = [], []
|
| 517 |
+
for row in rows:
|
| 518 |
+
try:
|
| 519 |
+
ctx = _jc.loads(row.get("context") or "{}")
|
| 520 |
+
except Exception:
|
| 521 |
+
continue
|
| 522 |
+
hb = int(ctx.get("lastHeartbeat", 0))
|
| 523 |
+
age_ms = now_ms - hb
|
| 524 |
+
entry = {
|
| 525 |
+
"name": ctx.get("sessionName", "?"),
|
| 526 |
+
"sprint": ctx.get("sprint", "β"),
|
| 527 |
+
"files": ctx.get("claimedFiles", []),
|
| 528 |
+
"age_ms": age_ms,
|
| 529 |
+
}
|
| 530 |
+
(active if age_ms < SESSION_TTL_MS else stale).append(entry)
|
| 531 |
+
|
| 532 |
+
if not rows:
|
| 533 |
+
await _tg_reply(chat_id,
|
| 534 |
+
"π <b>Agent Coord</b>\n\n"
|
| 535 |
+
"βΉοΈ Nessuna sessione registrata.\n<i>Tutti i file sono liberi.</i>",
|
| 536 |
+
keyboard=_BACK_KB)
|
| 537 |
+
return
|
| 538 |
+
|
| 539 |
+
def _age_str(ms: int) -> str:
|
| 540 |
+
s = ms // 1000
|
| 541 |
+
return f"{s // 60}m{s % 60:02d}s" if s >= 60 else f"{s}s"
|
| 542 |
+
|
| 543 |
+
out = ["π <b>Agent Coord</b>", ""]
|
| 544 |
+
if active:
|
| 545 |
+
out.append(f"β
<b>Attive ({len(active)})</b>")
|
| 546 |
+
for s in active:
|
| 547 |
+
fnames = ", ".join(
|
| 548 |
+
"<code>" + html.escape(f.split("/")[-1]) + "</code>"
|
| 549 |
+
for f in s["files"]
|
| 550 |
+
) or "<i>nessun file</i>"
|
| 551 |
+
out.append(
|
| 552 |
+
f" <b>{html.escape(s['name'])}</b> "
|
| 553 |
+
f"[{html.escape(s['sprint'])}] Β· {_age_str(s['age_ms'])} fa"
|
| 554 |
+
)
|
| 555 |
+
out.append(f" π {fnames}")
|
| 556 |
+
else:
|
| 557 |
+
out.append("β
Nessuna sessione attiva β file liberi")
|
| 558 |
+
if stale:
|
| 559 |
+
out.append("")
|
| 560 |
+
out.append(f"β« <b>Inattive >5min ({len(stale)})</b>")
|
| 561 |
+
for s in stale:
|
| 562 |
+
out.append(f" {html.escape(s['name'])} Β· {_age_str(s['age_ms'])} fa")
|
| 563 |
+
|
| 564 |
+
await _tg_reply(chat_id, "\n".join(out), keyboard=_BACK_KB)
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
async def _cmd_git(chat_id: int, n: int = 5) -> None:
|
| 568 |
+
"""π /git [n] β ultimi N commit su main con SHA, messaggio, autore, eta e link diff.
|
| 569 |
+
|
| 570 |
+
Chiama GitHub API (GITHUB_TOKEN Railway). Default: ultimi 5 commit.
|
| 571 |
+
Max: 10. Timeout 6s. Silent fail se GITHUB_TOKEN assente.
|
| 572 |
+
"""
|
| 573 |
+
import httpx as _hx_git2, datetime as _dt2
|
| 574 |
+
_gh_token = os.getenv("GITHUB_TOKEN", "").strip()
|
| 575 |
+
_gh_repo = os.getenv("GITHUB_REPO", "Baida98/AI").strip()
|
| 576 |
+
if not _gh_token:
|
| 577 |
+
await _tg_reply(chat_id,
|
| 578 |
+
"β οΈ <b>GITHUB_TOKEN</b> non configurato su Railway β /git non disponibile.")
|
| 579 |
+
return
|
| 580 |
+
n = max(1, min(n, 10))
|
| 581 |
+
await _tg_reply(chat_id, f"π <b>Git log</b> β ultimi {n} commitβ¦")
|
| 582 |
+
try:
|
| 583 |
+
async with _hx_git2.AsyncClient(timeout=6.0) as _gc:
|
| 584 |
+
_gr = await _gc.get(
|
| 585 |
+
f"https://api.github.com/repos/{_gh_repo}/commits",
|
| 586 |
+
headers={"Authorization": f"Bearer {_gh_token}", "User-Agent": "agente-ai"},
|
| 587 |
+
params={"sha": "main", "per_page": str(n)},
|
| 588 |
+
)
|
| 589 |
+
if _gr.status_code != 200:
|
| 590 |
+
await _tg_reply(chat_id,
|
| 591 |
+
f"β GitHub API {_gr.status_code}: {html.escape(_gr.text[:200])}")
|
| 592 |
+
return
|
| 593 |
+
commits = _gr.json() or []
|
| 594 |
+
except Exception as exc:
|
| 595 |
+
await _tg_reply(chat_id,
|
| 596 |
+
"β <code>" + html.escape(str(exc)[:200]) + "</code>")
|
| 597 |
+
return
|
| 598 |
+
|
| 599 |
+
if not commits:
|
| 600 |
+
await _tg_reply(chat_id, "βΉοΈ Nessun commit trovato.", keyboard=_BACK_KB)
|
| 601 |
+
return
|
| 602 |
+
|
| 603 |
+
_now_utc = _dt2.datetime.now(_dt2.timezone.utc)
|
| 604 |
+
|
| 605 |
+
def _age(iso: str) -> str:
|
| 606 |
+
try:
|
| 607 |
+
_ts = _dt2.datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
| 608 |
+
_secs = int((_now_utc - _ts).total_seconds())
|
| 609 |
+
if _secs < 3600: return f"{_secs // 60}m fa"
|
| 610 |
+
if _secs < 86400: return f"{_secs // 3600}h fa"
|
| 611 |
+
if _secs < 604800: return f"{_secs // 86400}g fa"
|
| 612 |
+
return _ts.strftime("%d/%m")
|
| 613 |
+
except Exception:
|
| 614 |
+
return "?"
|
| 615 |
+
|
| 616 |
+
lines = [f"π <b>Git log β main</b> ({_gh_repo})\n"]
|
| 617 |
+
for c in commits:
|
| 618 |
+
_sha = (c.get("sha") or "")[:7]
|
| 619 |
+
_commit = c.get("commit") or {}
|
| 620 |
+
_msg_raw = (_commit.get("message") or "").split("\n")[0][:52]
|
| 621 |
+
_msg = html.escape(_msg_raw)
|
| 622 |
+
_author = html.escape((_commit.get("author") or {}).get("name", "?")[:18])
|
| 623 |
+
_date = (_commit.get("committer") or {}).get("date", "")
|
| 624 |
+
_when = _age(_date)
|
| 625 |
+
_url = f"https://github.com/{_gh_repo}/commit/{c.get('sha','')}"
|
| 626 |
+
lines.append(
|
| 627 |
+
f"<code>{html.escape(_sha)}</code> {_msg}\n"
|
| 628 |
+
f" <i>{_author} Β· {_when}</i> "
|
| 629 |
+
f'<a href="{_url}">diff β</a>'
|
| 630 |
+
)
|
| 631 |
+
|
| 632 |
+
await _tg_reply(chat_id, "\n".join(lines), keyboard=_BACK_KB)
|
| 633 |
+
|
| 634 |
+
async def _cmd_telemetry(chat_id: int) -> None:
|
| 635 |
+
"""π‘ Metriche runtime live: /api/telemetry + /debug/timing da Railway."""
|
| 636 |
+
import httpx as _hx_t
|
| 637 |
+
rw_url = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/")
|
| 638 |
+
await _tg_reply(chat_id, "β³ <b>Telemetria</b> β interrogo Railwayβ¦")
|
| 639 |
+
try:
|
| 640 |
+
async with _hx_t.AsyncClient(timeout=8.0) as _c:
|
| 641 |
+
tel_r, tim_r = await asyncio.gather(
|
| 642 |
+
_c.get(f"{rw_url}/api/telemetry"),
|
| 643 |
+
_c.get(f"{rw_url}/debug/timing"),
|
| 644 |
+
return_exceptions=True,
|
| 645 |
+
)
|
| 646 |
+
except Exception as e:
|
| 647 |
+
await _tg_reply(chat_id, f"β Railway non raggiungibile\n<code>{html.escape(str(e)[:120])}</code>", keyboard=_BACK_KB)
|
| 648 |
+
return
|
| 649 |
+
|
| 650 |
+
tel_d = tel_r.json() if not isinstance(tel_r, Exception) and tel_r.status_code == 200 else {}
|
| 651 |
+
tim_d = tim_r.json() if not isinstance(tim_r, Exception) and tim_r.status_code == 200 else {}
|
| 652 |
+
|
| 653 |
+
msg = "π‘ <b>Telemetria Runtime</b>\n\n"
|
| 654 |
+
timing = tel_d.get("timing", {})
|
| 655 |
+
if timing:
|
| 656 |
+
msg += "<b>β± Latenze per fase (avg / p90 / n):</b>\n<code>"
|
| 657 |
+
for phase, v in list(timing.items())[:10]:
|
| 658 |
+
avg_v = v.get("avg", 0); p90_v = v.get("p90", 0); n_v = v.get("n", 0)
|
| 659 |
+
icon = "π’" if avg_v < 3000 else "π‘" if avg_v < 10000 else "π΄"
|
| 660 |
+
msg += f"{icon} {phase[:18]:<18} {avg_v:>7}ms p90:{p90_v:>7}ms Γ{n_v}\n"
|
| 661 |
+
msg += "</code>\n"
|
| 662 |
+
else:
|
| 663 |
+
msg += "<i>β οΈ Nessun dato timing β backend idle o prima run.</i>\n"
|
| 664 |
+
repair = tel_d.get("repair", tim_d.get("repair_stats", {}))
|
| 665 |
+
if repair:
|
| 666 |
+
msg += "\n<b>π§ Quality & Repair:</b>\n<code>"
|
| 667 |
+
for k, v in list(repair.items())[:10]:
|
| 668 |
+
msg += f" {k[:22]:<22} {v:>6}\n"
|
| 669 |
+
msg += "</code>\n"
|
| 670 |
+
ts_d = tim_d.get("timing_stats", {})
|
| 671 |
+
if ts_d:
|
| 672 |
+
msg += "\n<b>π Breakdown /debug/timing:</b>\n<code>"
|
| 673 |
+
for label, v in list(ts_d.items())[:8]:
|
| 674 |
+
avg_v = v.get("avg") or 0; icon = "π’" if avg_v < 3000 else "π‘" if avg_v < 10000 else "π΄"
|
| 675 |
+
msg += f"{icon} {label[:18]:<18} {avg_v:>8}ms Γ{v.get('count',0)}\n"
|
| 676 |
+
msg += "</code>"
|
| 677 |
+
if len(msg) > 4000:
|
| 678 |
+
msg = msg[:4000] + "β¦"
|
| 679 |
+
await _tg_reply(chat_id, msg, keyboard=_BACK_KB)
|
| 680 |
+
|
| 681 |
+
async def _cmd_score(chat_id: int) -> None:
|
| 682 |
+
"""π Score card dettagliata β chart + ranking 4 competitor + nodes + gaps + runtime telemetry."""
|
| 683 |
+
import httpx as _hx_sc, base64 as _b64_sc, json as _j_sc, urllib.parse as _ul_sc
|
| 684 |
+
gh_token = os.getenv("GITHUB_TOKEN", "").strip()
|
| 685 |
+
rw_url = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/")
|
| 686 |
+
await _tg_reply(chat_id, "β³ <b>Score</b> β carico report + metriche runtimeβ¦")
|
| 687 |
+
|
| 688 |
+
report: dict | None = None
|
| 689 |
+
if gh_token:
|
| 690 |
+
try:
|
| 691 |
+
async with _hx_sc.AsyncClient(timeout=8.0) as _c:
|
| 692 |
+
_r = await _c.get(
|
| 693 |
+
"https://api.github.com/repos/Baida98/AI/contents/benchmark-report.json?ref=main",
|
| 694 |
+
headers={"Authorization": f"Bearer {gh_token}",
|
| 695 |
+
"Accept": "application/vnd.github.v3+json",
|
| 696 |
+
"User-Agent": "AgenteAI-Bot"},
|
| 697 |
+
)
|
| 698 |
+
if _r.status_code == 200:
|
| 699 |
+
report = _j_sc.loads(_b64_sc.b64decode(_r.json()["content"]).decode())
|
| 700 |
+
except Exception as _e:
|
| 701 |
+
_logger.warning("cmd_score fetch: %s", _e)
|
| 702 |
+
|
| 703 |
+
if not report:
|
| 704 |
+
await _tg_reply(chat_id,
|
| 705 |
+
"β <b>Score</b> β benchmark-report.json non trovato.\n"
|
| 706 |
+
"Avvia prima <code>/bench</code> per generare i dati.", keyboard=_BENCH_ACTION_KB)
|
| 707 |
+
return
|
| 708 |
+
|
| 709 |
+
s = report.get("summary", {})
|
| 710 |
+
tasks = report.get("tasks", [])
|
| 711 |
+
gaps = report.get("gapCards", [])
|
| 712 |
+
nodes = report.get("orchestrationNodeMap", {})
|
| 713 |
+
ts = (report.get("timestamp") or "")[:16].replace("T", " ")
|
| 714 |
+
seed = report.get("seed", "?")
|
| 715 |
+
ver = report.get("version", "?")
|
| 716 |
+
replay = report.get("replayCli", f"node benchmark-extended.mjs --seed {seed}")
|
| 717 |
+
mft = s.get("mft")
|
| 718 |
+
gap_cnt = s.get("gapCount", len(gaps))
|
| 719 |
+
verdict = s.get("verdict", "").replace("_", " ")
|
| 720 |
+
avg_ai = s.get("avgScore", 0)
|
| 721 |
+
avg_rpl = s.get("avgReplit", 57.9)
|
| 722 |
+
avg_cur = s.get("avgCursor", 64.1)
|
| 723 |
+
avg_dev = s.get("avgDevin", 70.1)
|
| 724 |
+
avg_mns = s.get("avgManus", 71.2)
|
| 725 |
+
w_rpl = s.get("wins_replit", "?")
|
| 726 |
+
w_dev = s.get("wins_devin", "?")
|
| 727 |
+
w_mns = s.get("wins_manus", "?")
|
| 728 |
+
|
| 729 |
+
# Runtime telemetria da Railway (best-effort)
|
| 730 |
+
rt_timing: dict = {}
|
| 731 |
+
rt_repair: dict = {}
|
| 732 |
+
try:
|
| 733 |
+
async with _hx_sc.AsyncClient(timeout=5.0) as _c:
|
| 734 |
+
_tr = await _c.get(f"{rw_url}/api/telemetry")
|
| 735 |
+
if _tr.status_code == 200:
|
| 736 |
+
_td = _tr.json()
|
| 737 |
+
rt_timing = _td.get("timing", {})
|
| 738 |
+
rt_repair = _td.get("repair", {})
|
| 739 |
+
except Exception:
|
| 740 |
+
pass
|
| 741 |
+
|
| 742 |
+
# ββ Chart per categoria βββββββββββββββββββββββββββββββββββββββ
|
| 743 |
+
cat_map: dict = {}
|
| 744 |
+
for t in tasks:
|
| 745 |
+
cat = (t.get("cat") or "other").replace("_", " ")[:14]
|
| 746 |
+
cat_map.setdefault(cat, []).append(float(t.get("score", 0)))
|
| 747 |
+
labels_ = list(cat_map.keys())
|
| 748 |
+
scores_ = [round(sum(v)/len(v)) for v in cat_map.values()]
|
| 749 |
+
colors_ = ["#4CAF50" if sc >= avg_rpl else "#FF9800" if sc >= 50 else "#F44336" for sc in scores_]
|
| 750 |
+
chart_cfg = {
|
| 751 |
+
"type": "horizontalBar",
|
| 752 |
+
"data": {"labels": labels_, "datasets": [
|
| 753 |
+
{"label": "Agente AI", "data": scores_, "backgroundColor": colors_, "borderWidth": 1},
|
| 754 |
+
{"label": f"Replit {avg_rpl}",
|
| 755 |
+
"data": [avg_rpl]*len(labels_), "type": "line",
|
| 756 |
+
"borderColor": "#2196F3", "borderDash": [5,3], "pointRadius": 0, "fill": False, "borderWidth": 2},
|
| 757 |
+
{"label": f"Devin {avg_dev}",
|
| 758 |
+
"data": [avg_dev]*len(labels_), "type": "line",
|
| 759 |
+
"borderColor": "#FF9800", "borderDash": [5,3], "pointRadius": 0, "fill": False, "borderWidth": 2},
|
| 760 |
+
{"label": f"Manus {avg_mns}",
|
| 761 |
+
"data": [avg_mns]*len(labels_), "type": "line",
|
| 762 |
+
"borderColor": "#9C27B0", "borderDash": [5,3], "pointRadius": 0, "fill": False, "borderWidth": 2},
|
| 763 |
+
]},
|
| 764 |
+
"options": {
|
| 765 |
+
"title": {"display": True, "text": f"Agente AI {avg_ai}% | Replit {avg_rpl}% | Devin {avg_dev}% | Manus {avg_mns}%"},
|
| 766 |
+
"scales": {"xAxes": [{"ticks": {"min": 0, "max": 100, "stepSize": 20}}]},
|
| 767 |
+
"legend": {"display": True, "position": "bottom"},
|
| 768 |
+
"plugins": {"datalabels": {"display": False}},
|
| 769 |
+
},
|
| 770 |
+
}
|
| 771 |
+
chart_url = ("https://quickchart.io/chart?c=" +
|
| 772 |
+
_ul_sc.quote(_j_sc.dumps(chart_cfg, separators=(",",":"))) +
|
| 773 |
+
"&width=760&height=440&backgroundColor=white")
|
| 774 |
+
|
| 775 |
+
# ββ Caption messaggio 1 (foto, max 1024) βββββββββββββββββββββ
|
| 776 |
+
bar_g = "β" * round(avg_ai/10) + "β" * (10 - round(avg_ai/10))
|
| 777 |
+
d_rpl = round(avg_ai - avg_rpl); s_rpl = ("+" if d_rpl >= 0 else "") + str(d_rpl)
|
| 778 |
+
d_dev = round(avg_ai - avg_dev); s_dev = ("+" if d_dev >= 0 else "") + str(d_dev)
|
| 779 |
+
d_mns = round(avg_ai - avg_mns); s_mns = ("+" if d_mns >= 0 else "") + str(d_mns)
|
| 780 |
+
d_cur = round(avg_ai - avg_cur); s_cur = ("+" if d_cur >= 0 else "") + str(d_cur)
|
| 781 |
+
caption = f"π <b>Score</b> β {ts} UTC <code>v{ver}</code>\n"
|
| 782 |
+
caption += f"<code>{bar_g}</code> <b>{avg_ai}%</b> {verdict}\n\n"
|
| 783 |
+
caption += f"<code>{'Modello':<10} {'Score':>5} {'Ξ':>4} Wins</code>\n"
|
| 784 |
+
caption += f"<code>{'Agente AI':<10} {str(avg_ai)+'%':>5} {'β':>4} β</code>\n"
|
| 785 |
+
caption += f"<code>{'Replit':<10} {str(avg_rpl)+'%':>5} {s_rpl:>4} {w_rpl}/10</code>\n"
|
| 786 |
+
caption += f"<code>{'Cursor':<10} {str(avg_cur)+'%':>5} {s_cur:>4} β</code>\n"
|
| 787 |
+
caption += f"<code>{'Devin':<10} {str(avg_dev)+'%':>5} {s_dev:>4} {w_dev}/10</code>\n"
|
| 788 |
+
caption += f"<code>{'Manus':<10} {str(avg_mns)+'%':>5} {s_mns:>4} {w_mns}/10</code>\n\n"
|
| 789 |
+
for cat, vals in sorted(cat_map.items(), key=lambda x: -sum(x[1])/len(x[1])):
|
| 790 |
+
sc = round(sum(vals)/len(vals))
|
| 791 |
+
bar = "β" * round(sc/10) + "β" * (10 - round(sc/10))
|
| 792 |
+
d = round(sc - avg_rpl)
|
| 793 |
+
vs = ("+" if d >= 0 else "") + str(d)
|
| 794 |
+
icon = "β
" if sc >= avg_rpl else "β οΈ" if sc >= 50 else "β"
|
| 795 |
+
line = f"<code>{cat[:12]:<12} {bar} {sc:>3}% {vs:>4}</code> {icon}\n"
|
| 796 |
+
if len(caption) + len(line) < 1010:
|
| 797 |
+
caption += line
|
| 798 |
+
caption += f"\nπ² Seed <code>{seed}</code> MFT <b>{mft}s</b> Gaps: <b>{gap_cnt}</b>"
|
| 799 |
+
await _tg_photo(chat_id, chart_url, caption=caption[:1024], keyboard=_BENCH_ACTION_KB)
|
| 800 |
+
|
| 801 |
+
# ββ Messaggio 2 β dettaglio completo βββββββββββββββββββββββββ
|
| 802 |
+
det = "π <b>Score β Dettaglio</b>\n\n"
|
| 803 |
+
|
| 804 |
+
# Orchestration nodes
|
| 805 |
+
NODE_ICONS = {"planner":"π§ ","executor":"βοΈ","reasoner":"π¬",
|
| 806 |
+
"recovery_manager":"π‘","robustness_layer":"π","memory_module":"πΎ"}
|
| 807 |
+
if nodes:
|
| 808 |
+
det += "<b>β‘ Orchestration Nodes:</b>\n<code>"
|
| 809 |
+
for nk, nv in nodes.items():
|
| 810 |
+
sr = str(nv.get("success_rate", "?"))
|
| 811 |
+
lat = nv.get("avg_latency_s")
|
| 812 |
+
ntsk = nv.get("tasks", "")
|
| 813 |
+
try:
|
| 814 |
+
icon = "β
" if float(sr.rstrip("%")) >= 60 else "β οΈ" if float(sr.rstrip("%")) >= 30 else "β"
|
| 815 |
+
except Exception:
|
| 816 |
+
icon = "β"
|
| 817 |
+
lat_s = f" {lat}s" if lat is not None else ""
|
| 818 |
+
tsk_s = f" Γ{ntsk}" if ntsk else ""
|
| 819 |
+
det += f"{NODE_ICONS.get(nk,'β’')} {nk[:20]:<20} {icon} {sr:>5}{lat_s}{tsk_s}\n"
|
| 820 |
+
det += "</code>\n"
|
| 821 |
+
|
| 822 |
+
# Runtime telemetria (live da Railway)
|
| 823 |
+
if rt_timing:
|
| 824 |
+
det += "\n<b>β± Runtime Latenze (live):</b>\n<code>"
|
| 825 |
+
for phase, v in list(rt_timing.items())[:8]:
|
| 826 |
+
avg_v = v.get("avg", 0); p90_v = v.get("p90", 0); n_v = v.get("n", 0)
|
| 827 |
+
icon = "π’" if avg_v < 3000 else "π‘" if avg_v < 10000 else "π΄"
|
| 828 |
+
det += f"{icon} {phase[:16]:<16} avg:{avg_v:>7}ms p90:{p90_v:>7}ms Γ{n_v}\n"
|
| 829 |
+
det += "</code>\n"
|
| 830 |
+
if rt_repair:
|
| 831 |
+
det += "<b>π§ Quality counters:</b>\n<code>"
|
| 832 |
+
for k, v in list(rt_repair.items())[:6]:
|
| 833 |
+
det += f" {k[:20]:<20} {v}\n"
|
| 834 |
+
det += "</code>\n"
|
| 835 |
+
else:
|
| 836 |
+
det += "<i>βΉοΈ Telemetria runtime non disponibile (Railway idle)</i>\n"
|
| 837 |
+
|
| 838 |
+
# Top 3 best + Top 3 worst
|
| 839 |
+
sorted_tasks = sorted([t for t in tasks if t.get("score") is not None], key=lambda t: -t["score"])
|
| 840 |
+
if sorted_tasks:
|
| 841 |
+
det += "\n<b>π₯ Migliori task:</b>\n"
|
| 842 |
+
for t in sorted_tasks[:3]:
|
| 843 |
+
ref_r = (t.get("ref") or {}).get("replit", avg_rpl)
|
| 844 |
+
d_ = round(t["score"] - ref_r)
|
| 845 |
+
ds_ = ("+" if d_ >= 0 else "") + str(d_)
|
| 846 |
+
ms_ = f" {round(t['agentMs']/1000)}s" if t.get("agentMs") else ""
|
| 847 |
+
det += f" <code>{(t.get('id') or '?'):>3}</code> <b>{t['score']}%</b> ({ds_} vsRpl){ms_}\n"
|
| 848 |
+
det += f" <i>{(t.get('label') or '')[:60]}</i>\n"
|
| 849 |
+
det += "\n<b>β οΈ Task critici:</b>\n"
|
| 850 |
+
for t in sorted_tasks[-3:]:
|
| 851 |
+
ref_r = (t.get("ref") or {}).get("replit", avg_rpl)
|
| 852 |
+
ref_m = (t.get("ref") or {}).get("manus", avg_mns)
|
| 853 |
+
d_ = round(t["score"] - ref_r)
|
| 854 |
+
dm_ = round(t["score"] - ref_m)
|
| 855 |
+
ds_ = ("+" if d_ >= 0 else "") + str(d_)
|
| 856 |
+
dms_ = ("+" if dm_ >= 0 else "") + str(dm_)
|
| 857 |
+
det += f" <code>{(t.get('id') or '?'):>3}</code> <b>{t['score']}%</b> vsRpl {ds_} vsManus {dms_}\n"
|
| 858 |
+
det += f" <i>{(t.get('label') or '')[:60]}</i>\n"
|
| 859 |
+
|
| 860 |
+
# Gap cards top 3
|
| 861 |
+
if gaps:
|
| 862 |
+
det += "\n<b>π₯ Gap da correggere:</b>\n"
|
| 863 |
+
for g in gaps[:3]:
|
| 864 |
+
gicon = "π΄" if g.get("gravita") == "critical" else "π‘"
|
| 865 |
+
det += f"{gicon} <b>{(g.get('modulo_coinvolto') or '').replace('_',' ')}</b> β {(g.get('causa_radice') or '')[:55]}\n"
|
| 866 |
+
det += f" Fix: <i>{(g.get('fix_immediato') or '')[:65]}</i>\n"
|
| 867 |
+
det += f" Atteso: <b>{(g.get('beneficio_atteso') or '')[:40]}</b>\n"
|
| 868 |
+
|
| 869 |
+
det += f"\n<code>{replay}</code>"
|
| 870 |
+
det += f"\nπ <a href=\"https://agente-ai.pages.dev\">Dashboard β</a>"
|
| 871 |
+
|
| 872 |
+
_TG_MAX = 4000
|
| 873 |
+
if len(det) <= _TG_MAX:
|
| 874 |
+
await _tg_reply(chat_id, det, keyboard=_BENCH_ACTION_KB)
|
| 875 |
+
else:
|
| 876 |
+
await _tg_reply(chat_id, det[:_TG_MAX])
|
| 877 |
+
await _tg_reply(chat_id, det[_TG_MAX:_TG_MAX*2][:_TG_MAX], keyboard=_BENCH_ACTION_KB)
|
| 878 |
+
|
| 879 |
+
|
| 880 |
+
async def _cmd_bench(chat_id: int, mode: str = "default") -> None:
|
| 881 |
+
"""π Benchmark via bench.yml (benchmark-extended.mjs) + quickchart.io.
|
| 882 |
+
|
| 883 |
+
GAP-TGB: workflow_dispatch su bench.yml β usa benchmark-extended.mjs
|
| 884 |
+
(20 categorie, seed canonico 1337, tutte le fix v5).
|
| 885 |
+
Risultati inviati via Telegram da ab-bench.mjs --notify al completamento.
|
| 886 |
+
"""
|
| 887 |
+
gh_token = os.getenv("GITHUB_TOKEN", "").strip()
|
| 888 |
+
|
| 889 |
+
# ββ Tenta fetch ultimo run completato da GitHub Actions artifact βββββββββ
|
| 890 |
+
last_report: dict | None = None
|
| 891 |
+
if gh_token:
|
| 892 |
+
try:
|
| 893 |
+
import httpx as _hx
|
| 894 |
+
async with _hx.AsyncClient(timeout=8.0) as _c:
|
| 895 |
+
_r = await _c.get(
|
| 896 |
+
"https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/runs"
|
| 897 |
+
"?status=completed&per_page=1",
|
| 898 |
+
headers={"Authorization": f"Bearer {gh_token}",
|
| 899 |
+
"Accept": "application/vnd.github.v3+json",
|
| 900 |
+
"User-Agent": "AgenteAI-Bot"},
|
| 901 |
+
)
|
| 902 |
+
if _r.status_code == 200:
|
| 903 |
+
_runs = _r.json().get("workflow_runs", [])
|
| 904 |
+
if _runs:
|
| 905 |
+
last_report = {
|
| 906 |
+
"run_id": _runs[0]["id"],
|
| 907 |
+
"run_url": _runs[0]["html_url"],
|
| 908 |
+
"conclusion":_runs[0].get("conclusion","?"),
|
| 909 |
+
"updated": _runs[0].get("updated_at",""),
|
| 910 |
+
}
|
| 911 |
+
except Exception as _exc:
|
| 912 |
+
_logger.debug("bench fetch last run: %s", _exc)
|
| 913 |
+
|
| 914 |
+
# ββ Trigger nuovo run via workflow_dispatch βββββββββββββββββββββββββββββββ
|
| 915 |
+
run_url = "https://github.com/Baida98/AI/actions/workflows/bench.yml"
|
| 916 |
+
if gh_token:
|
| 917 |
+
try:
|
| 918 |
+
import httpx as _hx
|
| 919 |
+
async with _hx.AsyncClient(timeout=10.0) as _c:
|
| 920 |
+
_r = await _c.post(
|
| 921 |
+
"https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/dispatches",
|
| 922 |
+
json={"ref": "main", "inputs": {
|
| 923 |
+
"mode": mode,
|
| 924 |
+
"run_improve": "false",
|
| 925 |
+
"force_update_baseline": "false",
|
| 926 |
+
}},
|
| 927 |
+
headers={"Authorization": f"Bearer {gh_token}",
|
| 928 |
+
"Accept": "application/vnd.github.v3+json",
|
| 929 |
+
"User-Agent": "AgenteAI-Bot"},
|
| 930 |
+
)
|
| 931 |
+
if _r.status_code == 204:
|
| 932 |
+
_logger.info("[bench] workflow_dispatch OK (mode=%s)", mode)
|
| 933 |
+
# Attendi 2s e leggi il run ID appena creato
|
| 934 |
+
await asyncio.sleep(2.0)
|
| 935 |
+
async with _hx.AsyncClient(timeout=8.0) as _c2:
|
| 936 |
+
_r2 = await _c2.get(
|
| 937 |
+
"https://api.github.com/repos/Baida98/AI/actions/workflows/"
|
| 938 |
+
"bench.yml/runs?per_page=1",
|
| 939 |
+
headers={"Authorization": f"Bearer {gh_token}",
|
| 940 |
+
"Accept": "application/vnd.github.v3+json",
|
| 941 |
+
"User-Agent": "AgenteAI-Bot"},
|
| 942 |
+
)
|
| 943 |
+
if _r2.status_code == 200:
|
| 944 |
+
_rr = _r2.json().get("workflow_runs", [])
|
| 945 |
+
if _rr:
|
| 946 |
+
run_url = _rr[0]["html_url"]
|
| 947 |
+
else:
|
| 948 |
+
_logger.warning("[bench] workflow_dispatch status=%d", _r.status_code)
|
| 949 |
+
except Exception as _exc:
|
| 950 |
+
_logger.warning("[bench] workflow_dispatch error: %s", _exc)
|
| 951 |
+
|
| 952 |
+
# ββ Costruisci messaggio con quickchart dell'ultimo run (se disponibile) ββ
|
| 953 |
+
_BENCH_CACHE[chat_id] = {"mode": mode, "run_url": run_url}
|
| 954 |
+
|
| 955 |
+
# ββ Fetch benchmark-report.json dal repo per quickchart reale ββββββββββββββ
|
| 956 |
+
bench_report: dict | None = None
|
| 957 |
+
if gh_token:
|
| 958 |
+
try:
|
| 959 |
+
import httpx as _hx, base64 as _b64, json as _json
|
| 960 |
+
async with _hx.AsyncClient(timeout=8.0) as _c:
|
| 961 |
+
_br = await _c.get(
|
| 962 |
+
"https://api.github.com/repos/Baida98/AI/contents/benchmark-report.json?ref=main",
|
| 963 |
+
headers={"Authorization": f"Bearer {gh_token}",
|
| 964 |
+
"Accept": "application/vnd.github.v3+json",
|
| 965 |
+
"User-Agent": "AgenteAI-Bot"},
|
| 966 |
+
)
|
| 967 |
+
if _br.status_code == 200:
|
| 968 |
+
_content = _b64.b64decode(_br.json()["content"]).decode()
|
| 969 |
+
bench_report = _json.loads(_content)
|
| 970 |
+
except Exception as _exc:
|
| 971 |
+
_logger.debug("bench fetch benchmark-report.json: %s", _exc)
|
| 972 |
+
|
| 973 |
+
chart_url: str | None = None
|
| 974 |
+
|
| 975 |
+
def _build_quickchart(report: dict) -> str:
|
| 976 |
+
"""Costruisce URL quickchart.io da benchmark-report.json."""
|
| 977 |
+
import json as _j, urllib.parse as _ul
|
| 978 |
+
tasks = report.get("tasks", [])
|
| 979 |
+
summary = report.get("summary", {})
|
| 980 |
+
avg_ai = summary.get("avgScore", 0)
|
| 981 |
+
avg_rpl = summary.get("avgReplit", 57.9)
|
| 982 |
+
avg_mns = summary.get("avgManus", 71.2)
|
| 983 |
+
cat_map: dict[str, list[float]] = {}
|
| 984 |
+
for t in tasks:
|
| 985 |
+
cat = (t.get("cat") or "other").replace("_", " ")[:14]
|
| 986 |
+
cat_map.setdefault(cat, []).append(t.get("score", 0))
|
| 987 |
+
if not cat_map:
|
| 988 |
+
return ""
|
| 989 |
+
labels = list(cat_map.keys())
|
| 990 |
+
scores = [round(sum(v)/len(v)) for v in cat_map.values()]
|
| 991 |
+
colors = ["#4CAF50" if s >= avg_rpl else "#FF9800" if s >= 50 else "#F44336" for s in scores]
|
| 992 |
+
cfg = {
|
| 993 |
+
"type": "horizontalBar",
|
| 994 |
+
"data": {
|
| 995 |
+
"labels": labels,
|
| 996 |
+
"datasets": [
|
| 997 |
+
{"label": "Agente AI", "data": scores,
|
| 998 |
+
"backgroundColor": colors, "borderColor": colors, "borderWidth": 1},
|
| 999 |
+
{"label": f"Replit {avg_rpl}",
|
| 1000 |
+
"data": [avg_rpl]*len(labels),
|
| 1001 |
+
"type": "line", "borderColor": "#2196F3", "borderDash": [5,3],
|
| 1002 |
+
"pointRadius": 0, "fill": False, "borderWidth": 2},
|
| 1003 |
+
{"label": f"Manus {avg_mns}",
|
| 1004 |
+
"data": [avg_mns]*len(labels),
|
| 1005 |
+
"type": "line", "borderColor": "#9C27B0", "borderDash": [5,3],
|
| 1006 |
+
"pointRadius": 0, "fill": False, "borderWidth": 2},
|
| 1007 |
+
],
|
| 1008 |
+
},
|
| 1009 |
+
"options": {
|
| 1010 |
+
"title": {"display": True,
|
| 1011 |
+
"text": f"Agente AI {avg_ai}% | Replit {avg_rpl}% | Manus {avg_mns}%"},
|
| 1012 |
+
"scales": {"xAxes": [{"ticks": {"min": 0, "max": 100, "stepSize": 20}}]},
|
| 1013 |
+
"legend": {"display": True, "position": "bottom"},
|
| 1014 |
+
"plugins": {"datalabels": {"display": False}},
|
| 1015 |
+
},
|
| 1016 |
+
}
|
| 1017 |
+
return ("https://quickchart.io/chart?c=" +
|
| 1018 |
+
_ul.quote(_j.dumps(cfg, separators=(",",":"))) +
|
| 1019 |
+
"&width=720&height=420&backgroundColor=white")
|
| 1020 |
+
|
| 1021 |
+
if bench_report:
|
| 1022 |
+
chart_url = _build_quickchart(bench_report)
|
| 1023 |
+
|
| 1024 |
+
summary = (bench_report or {}).get("summary", {})
|
| 1025 |
+
avg_ai = summary.get("avgScore")
|
| 1026 |
+
avg_rpl = summary.get("avgReplit")
|
| 1027 |
+
avg_mns = summary.get("avgManus")
|
| 1028 |
+
# ββ Tabella ASCII con barre per caption Telegram ββββββββββββββββββββββββββ
|
| 1029 |
+
def _text_table_bench(report: dict, rpl: float) -> str:
|
| 1030 |
+
tasks = report.get("tasks", [])
|
| 1031 |
+
cat_map: dict[str, list[float]] = {}
|
| 1032 |
+
for t in tasks:
|
| 1033 |
+
cat = (t.get("cat") or "other").replace("_", " ")[:12]
|
| 1034 |
+
cat_map.setdefault(cat, []).append(float(t.get("score", 0)))
|
| 1035 |
+
if not cat_map:
|
| 1036 |
+
return ""
|
| 1037 |
+
rows = []
|
| 1038 |
+
for cat, vals in sorted(cat_map.items(), key=lambda x: -sum(x[1]) / len(x[1])):
|
| 1039 |
+
sc = round(sum(vals) / len(vals))
|
| 1040 |
+
bar = "β" * round(sc / 10) + "β" * (10 - round(sc / 10))
|
| 1041 |
+
delta_rpl = sc - rpl
|
| 1042 |
+
vs = ("+" if delta_rpl >= 0 else "") + str(round(delta_rpl)) + "vsRpl"
|
| 1043 |
+
rows.append(f"{cat:<12} {bar} {sc:>3}% {vs}")
|
| 1044 |
+
return "\n".join(rows)
|
| 1045 |
+
|
| 1046 |
+
text_table = ""
|
| 1047 |
+
if bench_report and avg_rpl is not None:
|
| 1048 |
+
text_table = _text_table_bench(bench_report, float(avg_rpl))
|
| 1049 |
+
|
| 1050 |
+
score_line = ""
|
| 1051 |
+
if avg_ai is not None:
|
| 1052 |
+
score_line = (
|
| 1053 |
+
f"\nπ <b>Score:</b> AI <b>{avg_ai}%</b>"
|
| 1054 |
+
+ (f" | Replit {avg_rpl}%" if avg_rpl else "")
|
| 1055 |
+
+ (f" | Manus {avg_mns}%" if avg_mns else "")
|
| 1056 |
+
+ "\n"
|
| 1057 |
+
)
|
| 1058 |
+
|
| 1059 |
+
def _build_bench_caption(header: str) -> str:
|
| 1060 |
+
tbl = ("\n<code>" + text_table + "</code>") if text_table else ""
|
| 1061 |
+
link = f'\nπ <a href="{html.escape(run_url)}">GitHub Actions</a>'
|
| 1062 |
+
full = header + score_line + tbl + link
|
| 1063 |
+
if len(full) > 1020 and text_table:
|
| 1064 |
+
avail = max(0, 1020 - len(header) - len(score_line) - len(link) - 14)
|
| 1065 |
+
tbl = "\n<code>" + text_table[:avail] + "β¦</code>"
|
| 1066 |
+
full = header + score_line + tbl + link
|
| 1067 |
+
return full[:1024]
|
| 1068 |
+
|
| 1069 |
+
if last_report:
|
| 1070 |
+
_conclusion = last_report.get("conclusion", "?")
|
| 1071 |
+
_em = "β
" if _conclusion == "success" else ("β" if _conclusion == "failure" else "β οΈ")
|
| 1072 |
+
_upd = last_report.get("updated", "")[:16].replace("T", " ")
|
| 1073 |
+
header = f"π <b>Benchmark avviato</b> β {_em} {_conclusion}\nπ {_upd} UTC"
|
| 1074 |
+
else:
|
| 1075 |
+
header = "π <b>Benchmark avviato</b> (benchmark-extended.mjs)"
|
| 1076 |
+
|
| 1077 |
+
caption = _build_bench_caption(header)
|
| 1078 |
+
|
| 1079 |
+
if chart_url:
|
| 1080 |
+
await _tg_photo(chat_id, chart_url, caption=caption, keyboard=_BENCH_ACTION_KB)
|
| 1081 |
+
else:
|
| 1082 |
+
await _tg_reply(chat_id, caption, keyboard=_BENCH_ACTION_KB)
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
+
|
| 1086 |
+
# ββ _cmd_improve β ciclo miglioramento: bench β gap β patch βββββββββββββββββ
|
| 1087 |
+
|
| 1088 |
+
async def _cmd_improve(chat_id: int) -> None:
|
| 1089 |
+
"""βοΈ Ciclo di miglioramento: bench.yml con --improve β gap β patch automatica.
|
| 1090 |
+
|
| 1091 |
+
Triggera bench.yml con run_improve=true (benchmark-extended.mjs --improve).
|
| 1092 |
+
Il ciclo: few-shot retry sui task falliti β identifica pattern di miglioramento
|
| 1093 |
+
β genera regole β propone patch via notify Telegram.
|
| 1094 |
+
"""
|
| 1095 |
+
gh_token = os.getenv("GITHUB_TOKEN", "").strip()
|
| 1096 |
+
|
| 1097 |
+
await _tg_reply(chat_id,
|
| 1098 |
+
"βοΈ <b>Ciclo miglioramento avviato</b>\n\n"
|
| 1099 |
+
"β’ Avvio <code>benchmark-extended.mjs --improve</code>\n"
|
| 1100 |
+
"β’ Identifica gap Β· genera regole migliorate Β· propone patch\n"
|
| 1101 |
+
"<i>Riceverai notifica Telegram al completamento (~10-25 min).</i>",
|
| 1102 |
+
keyboard=_BACK_KB)
|
| 1103 |
+
|
| 1104 |
+
run_url = "https://github.com/Baida98/AI/actions/workflows/bench.yml"
|
| 1105 |
+
if not gh_token:
|
| 1106 |
+
await _tg_reply(chat_id,
|
| 1107 |
+
"β οΈ GITHUB_TOKEN non configurato β impossibile avviare workflow.",
|
| 1108 |
+
keyboard=_BACK_KB)
|
| 1109 |
+
return
|
| 1110 |
+
|
| 1111 |
+
try:
|
| 1112 |
+
import httpx as _hx
|
| 1113 |
+
async with _hx.AsyncClient(timeout=10.0) as _c:
|
| 1114 |
+
_r = await _c.post(
|
| 1115 |
+
"https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/dispatches",
|
| 1116 |
+
json={"ref": "main", "inputs": {
|
| 1117 |
+
"mode": "default",
|
| 1118 |
+
"run_improve": "true",
|
| 1119 |
+
"force_update_baseline": "false",
|
| 1120 |
+
}},
|
| 1121 |
+
headers={"Authorization": f"Bearer {gh_token}",
|
| 1122 |
+
"Accept": "application/vnd.github.v3+json",
|
| 1123 |
+
"User-Agent": "AgenteAI-Bot"},
|
| 1124 |
+
)
|
| 1125 |
+
if _r.status_code == 204:
|
| 1126 |
+
_logger.info("[improve] workflow_dispatch OK")
|
| 1127 |
+
await asyncio.sleep(2.0)
|
| 1128 |
+
async with _hx.AsyncClient(timeout=8.0) as _c2:
|
| 1129 |
+
_r2 = await _c2.get(
|
| 1130 |
+
"https://api.github.com/repos/Baida98/AI/actions/workflows/"
|
| 1131 |
+
"bench.yml/runs?per_page=1",
|
| 1132 |
+
headers={"Authorization": f"Bearer {gh_token}",
|
| 1133 |
+
"Accept": "application/vnd.github.v3+json",
|
| 1134 |
+
"User-Agent": "AgenteAI-Bot"},
|
| 1135 |
+
)
|
| 1136 |
+
if _r2.status_code == 200:
|
| 1137 |
+
_rr = _r2.json().get("workflow_runs", [])
|
| 1138 |
+
if _rr:
|
| 1139 |
+
run_url = _rr[0]["html_url"]
|
| 1140 |
+
await _tg_reply(chat_id,
|
| 1141 |
+
f"β
Ciclo miglioramento avviato\n"
|
| 1142 |
+
f'π <a href="{html.escape(run_url)}">Segui su GitHub Actions</a>',
|
| 1143 |
+
keyboard=_BENCH_ACTION_KB)
|
| 1144 |
+
else:
|
| 1145 |
+
await _tg_reply(chat_id,
|
| 1146 |
+
f"β οΈ GitHub API status {_r.status_code} β riprova tra poco.",
|
| 1147 |
+
keyboard=_BACK_KB)
|
| 1148 |
+
except Exception as _exc:
|
| 1149 |
+
_logger.warning("[improve] error: %s", _exc)
|
| 1150 |
+
await _tg_reply(chat_id,
|
| 1151 |
+
f"β Errore: <code>{html.escape(str(_exc)[:150])}</code>",
|
| 1152 |
+
keyboard=_BACK_KB)
|
| 1153 |
+
|
| 1154 |
+
|
api/telegram_cmd_monitoring.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/telegram_cmd_monitoring.py β Comandi Telegram di monitoraggio e stato.
|
| 2 |
+
|
| 3 |
+
Comandi:
|
| 4 |
+
_cmd_help, _cmd_logs, _cmd_status, _cmd_commit_summary,
|
| 5 |
+
_cmd_check, _cmd_tasks, _cmd_git, _cmd_coord,
|
| 6 |
+
_cmd_scan_now, _cmd_telemetry
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
import asyncio, html, logging, os, re, time
|
| 10 |
+
|
| 11 |
+
from .telegram_tg_client import (
|
| 12 |
+
_get_bot_token, _tg_reply, _tg_send, _tg_edit,
|
| 13 |
+
_tg_typing, _tg_react, _tg_photo, _fmt_elapsed,
|
| 14 |
+
_BACK_KB,
|
| 15 |
+
)
|
| 16 |
+
from .telegram_keyboards import (
|
| 17 |
+
_MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
_logger = logging.getLogger("api.telegram_webhook")
|
| 21 |
+
|
| 22 |
+
# ββ Command handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
|
| 24 |
+
async def _cmd_help(chat_id: int) -> None:
|
| 25 |
+
"""Menu principale: un messaggio pulito + inline keyboard essenziale."""
|
| 26 |
+
await _tg_typing(chat_id)
|
| 27 |
+
welcome = (
|
| 28 |
+
"π€ <b>Agente AI</b>\n"
|
| 29 |
+
"<i>Assistente autonomo per lo sviluppo software</i>\n\n"
|
| 30 |
+
"π <b>Come usarmi:</b>\n"
|
| 31 |
+
"Scrivi qualsiasi obiettivo β lo eseguo autonomamente:\n"
|
| 32 |
+
" β’ <code>analizza i bug in providers.py</code>\n"
|
| 33 |
+
" β’ <code>ottimizza le query Supabase piΓΉ lente</code>\n"
|
| 34 |
+
" β’ <code>fai autofix degli errori nel log</code>\n\n"
|
| 35 |
+
"π <b>Comandi rapidi:</b>\n"
|
| 36 |
+
" /avvia β lancia un task AI\n"
|
| 37 |
+
" /stato β vedi cosa sta facendo\n"
|
| 38 |
+
" /salute β controllo sistema\n"
|
| 39 |
+
" /chiedi β domanda veloce all\'AI\n\n"
|
| 40 |
+
"β¬οΈ <b>O scegli dal menu:</b>"
|
| 41 |
+
)
|
| 42 |
+
await _tg_reply(chat_id, welcome, keyboard=_MAIN_KB)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
async def _cmd_logs(chat_id: int, level: str = "WARNING") -> None:
|
| 46 |
+
"""Mostra ultimi log dal backend Railway filtrando per livello."""
|
| 47 |
+
import httpx as _hx
|
| 48 |
+
railway_url = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/")
|
| 49 |
+
await _tg_reply(chat_id,
|
| 50 |
+
f"π <b>Log Railway</b> β <code>{level.upper()}</code>\nβ³ <i>Fetchingβ¦</i>")
|
| 51 |
+
try:
|
| 52 |
+
async with _hx.AsyncClient(timeout=10.0) as c:
|
| 53 |
+
r = await c.get(f"{railway_url}/api/telegram/logs",
|
| 54 |
+
params={"level": level.upper(), "n": 20})
|
| 55 |
+
data = r.json() if r.status_code == 200 else {}
|
| 56 |
+
except Exception as exc:
|
| 57 |
+
await _tg_reply(chat_id,
|
| 58 |
+
"β <b>Log non disponibili</b>\n<code>" + html.escape(str(exc)[:200]) + "</code>\n"
|
| 59 |
+
"<i>Controlla Railway dashboard.</i>", keyboard=_BACK_KB)
|
| 60 |
+
return
|
| 61 |
+
records = data.get("records", [])
|
| 62 |
+
if not records:
|
| 63 |
+
msg = ("β
<b>Nessun " + level.upper() + " nei log!</b>\n<i>Sistema stabile.</i>"
|
| 64 |
+
if level.upper() in ("WARNING","ERROR")
|
| 65 |
+
else "π <b>Log vuoti</b> β nessun record disponibile")
|
| 66 |
+
await _tg_reply(chat_id, msg, keyboard=_BACK_KB)
|
| 67 |
+
return
|
| 68 |
+
import datetime as _dt
|
| 69 |
+
lines = [f"π <b>Log</b> ({data.get('count',0)} rec β {level.upper()})\n"]
|
| 70 |
+
for rec in records[:15]:
|
| 71 |
+
ts = _dt.datetime.fromtimestamp(rec.get("ts",0), tz=_dt.timezone.utc).strftime("%H:%M:%S")
|
| 72 |
+
lvl = rec.get("level","?")
|
| 73 |
+
lgr = rec.get("logger","").split(".")[-1][:18]
|
| 74 |
+
msg = html.escape(str(rec.get("msg",""))[:100])
|
| 75 |
+
icon = "π΄" if lvl=="ERROR" else "π‘" if lvl=="WARNING" else "βͺ"
|
| 76 |
+
lines.append(f"{icon} <code>{ts}</code> [{lgr}] {msg}")
|
| 77 |
+
await _tg_reply(chat_id, "\n".join(lines), keyboard=_BACK_KB)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def _cmd_status(chat_id: int) -> None:
|
| 81 |
+
await _tg_typing(chat_id)
|
| 82 |
+
try:
|
| 83 |
+
from api.state import _agent_tasks, _loop_registry # noqa: F401
|
| 84 |
+
total = len(_agent_tasks)
|
| 85 |
+
running = sum(1 for t in _agent_tasks.values() if t.get("status") == "RUNNING")
|
| 86 |
+
success = sum(1 for t in _agent_tasks.values() if t.get("status") == "SUCCESS")
|
| 87 |
+
error = sum(1 for t in _agent_tasks.values() if t.get("status") == "ERROR")
|
| 88 |
+
queued = sum(1 for t in _agent_tasks.values() if t.get("status") == "QUEUED")
|
| 89 |
+
|
| 90 |
+
# Supabase fallback: se in-memory Γ¨ vuoto (restart backend) legge dal DB
|
| 91 |
+
sb_line = ""
|
| 92 |
+
if total == 0:
|
| 93 |
+
try:
|
| 94 |
+
from api.state import _sb
|
| 95 |
+
if _sb:
|
| 96 |
+
res = await asyncio.to_thread(
|
| 97 |
+
lambda: _sb.table("agent_tasks")
|
| 98 |
+
.select("status")
|
| 99 |
+
.order("created_at", desc=True)
|
| 100 |
+
.limit(50)
|
| 101 |
+
.execute()
|
| 102 |
+
)
|
| 103 |
+
rows = res.data or []
|
| 104 |
+
if rows:
|
| 105 |
+
db_run = sum(1 for r in rows if r.get("status") == "RUNNING")
|
| 106 |
+
db_done = sum(1 for r in rows if r.get("status") == "SUCCESS")
|
| 107 |
+
db_err = sum(1 for r in rows if r.get("status") == "ERROR")
|
| 108 |
+
sb_line = (
|
| 109 |
+
"\nπ¦ <b>Supabase (ultimi 50):</b> "
|
| 110 |
+
+ str(db_run) + " in corso / "
|
| 111 |
+
+ str(db_done) + " ok / "
|
| 112 |
+
+ str(db_err) + " err"
|
| 113 |
+
+ " <i>(backend riavviato)</i>"
|
| 114 |
+
)
|
| 115 |
+
except Exception as _exc:
|
| 116 |
+
_logger.debug("[telegram_webhook] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 117 |
+
|
| 118 |
+
from api.scheduler import _tasks as sched_tasks, _loop_task
|
| 119 |
+
sched_ok = _loop_task is not None and not _loop_task.done()
|
| 120 |
+
sched_pending = sum(1 for t in sched_tasks.values() if t.get("status") == "pending")
|
| 121 |
+
sched_label = "β
attivo" if sched_ok else "β fermo"
|
| 122 |
+
|
| 123 |
+
ts_now = time.strftime("%Y-%m-%d %H:%M:%S")
|
| 124 |
+
railway_url = os.getenv("RAILWAY_URL","https://ai-production-4c06.up.railway.app")
|
| 125 |
+
ry_line = ""
|
| 126 |
+
try:
|
| 127 |
+
import httpx as _hx
|
| 128 |
+
async with _hx.AsyncClient(timeout=4.0) as c:
|
| 129 |
+
rv = await c.get(f"{railway_url}/api/info")
|
| 130 |
+
if rv.status_code == 200:
|
| 131 |
+
rj = rv.json()
|
| 132 |
+
ry_line = ("\nπ <b>Railway:</b> v" + rj.get("version","?")
|
| 133 |
+
+ " β " + rj.get("sprint",""))
|
| 134 |
+
except Exception:
|
| 135 |
+
pass
|
| 136 |
+
|
| 137 |
+
# ββ NEW-1: HEAD git + ultimo commit ββββββββββββββββββββββββββββββββββ
|
| 138 |
+
# Chiama GitHub API con GITHUB_TOKEN (Railway env) β timeout 4s, silent fail.
|
| 139 |
+
# Mostra: sha corto + prima riga commit message + etΓ ("3h fa").
|
| 140 |
+
git_line = ""
|
| 141 |
+
try:
|
| 142 |
+
import httpx as _hx_g, datetime as _dt
|
| 143 |
+
_gh_token = os.getenv("GITHUB_TOKEN", "").strip()
|
| 144 |
+
_gh_repo = os.getenv("GITHUB_REPO", "Baida98/AI").strip()
|
| 145 |
+
if _gh_token and _gh_repo:
|
| 146 |
+
async with _hx_g.AsyncClient(timeout=4.0) as _gc:
|
| 147 |
+
_gr = await _gc.get(
|
| 148 |
+
f"https://api.github.com/repos/{_gh_repo}/commits/main",
|
| 149 |
+
headers={"Authorization": f"Bearer {_gh_token}", "User-Agent": "agente-ai"},
|
| 150 |
+
params={"per_page": 1},
|
| 151 |
+
)
|
| 152 |
+
if _gr.status_code == 200:
|
| 153 |
+
_cj = _gr.json()
|
| 154 |
+
_sha = (_cj.get("sha") or "")[:7]
|
| 155 |
+
_cmsg = ((_cj.get("commit") or {}).get("message") or "").split("\n")[0][:45]
|
| 156 |
+
_date = ((_cj.get("commit") or {}).get("committer") or {}).get("date", "")
|
| 157 |
+
_age = ""
|
| 158 |
+
if _date:
|
| 159 |
+
_ts = _dt.datetime.fromisoformat(_date.replace("Z", "+00:00"))
|
| 160 |
+
_secs = int((_dt.datetime.now(_dt.timezone.utc) - _ts).total_seconds())
|
| 161 |
+
if _secs < 3600: _age = f"{_secs // 60}m fa"
|
| 162 |
+
elif _secs < 86400: _age = f"{_secs // 3600}h fa"
|
| 163 |
+
else: _age = f"{_secs // 86400}g fa"
|
| 164 |
+
git_line = (
|
| 165 |
+
f"\nπ <b>HEAD:</b> <code>{html.escape(_sha)}</code>"
|
| 166 |
+
f" {html.escape(_cmsg)} <i>({_age})</i>"
|
| 167 |
+
)
|
| 168 |
+
except Exception:
|
| 169 |
+
pass
|
| 170 |
+
|
| 171 |
+
# ββ NEW-2: task live β goal + step corrente dal loop_registry ββββββββ
|
| 172 |
+
# Per il primo task RUNNING: mostra goal + azione corrente (dal buffer SSE)
|
| 173 |
+
# + tempo trascorso. Zero overhead se non c'Γ¨ task in corso.
|
| 174 |
+
live_line = ""
|
| 175 |
+
try:
|
| 176 |
+
_running_list = [t for t in _agent_tasks.values() if t.get("status") == "RUNNING"]
|
| 177 |
+
if _running_list:
|
| 178 |
+
import json as _lj
|
| 179 |
+
_rt = _running_list[0]
|
| 180 |
+
_tid = _rt.get("id") or _rt.get("task_id") or ""
|
| 181 |
+
_goal_s = html.escape((_rt.get("goal") or "")[:38])
|
| 182 |
+
_elapsed = ""
|
| 183 |
+
_ca = _rt.get("created_at", 0)
|
| 184 |
+
if isinstance(_ca, int) and _ca > 0:
|
| 185 |
+
_es = int(time.time() * 1000 - _ca) // 1000
|
| 186 |
+
_elapsed = f" Β· {_es // 60}m{_es % 60:02d}s" if _es >= 60 else f" Β· {_es}s"
|
| 187 |
+
# Legge ultimo evento SSE dal buffer (action/type corrente)
|
| 188 |
+
_act = ""
|
| 189 |
+
_ebuf = (_loop_registry.get(_tid) or {}).get("event_buffer", [])
|
| 190 |
+
for _ev in reversed(_ebuf[-30:]):
|
| 191 |
+
try:
|
| 192 |
+
_raw = _ev[6:] if _ev.startswith("data: ") else _ev
|
| 193 |
+
_ed = _lj.loads(_raw)
|
| 194 |
+
_a = _ed.get("action") or _ed.get("type") or ""
|
| 195 |
+
if _a and _a not in ("ping", "connected", "keepalive"):
|
| 196 |
+
_act = f" β <code>{html.escape(str(_a)[:20])}</code>"
|
| 197 |
+
break
|
| 198 |
+
except Exception:
|
| 199 |
+
pass
|
| 200 |
+
live_line = f"\nβοΈ <b>Live:</b> {_goal_s}{_act}{_elapsed}"
|
| 201 |
+
except Exception:
|
| 202 |
+
pass
|
| 203 |
+
|
| 204 |
+
# ββ NEW-3: coord mini β sessioni agent-coord attive ββββββββββββββββββ
|
| 205 |
+
# 1 riga: chi sta lavorando, su quale sprint, su quali file.
|
| 206 |
+
# Timeout aggressivo 3s β /status deve essere veloce.
|
| 207 |
+
coord_line = ""
|
| 208 |
+
try:
|
| 209 |
+
import httpx as _hx_c2, json as _jc2, time as _tc2
|
| 210 |
+
_SB_URL2 = os.getenv("SUPABASE_URL", "").rstrip("/")
|
| 211 |
+
_SB_KEY2 = os.getenv("SUPABASE_KEY", "")
|
| 212 |
+
if _SB_KEY2:
|
| 213 |
+
async with _hx_c2.AsyncClient(timeout=3.0) as _cc:
|
| 214 |
+
_cr = await _cc.get(
|
| 215 |
+
f"{_SB_URL2}/rest/v1/agent_tasks",
|
| 216 |
+
params={"goal": "ilike.*__session__*", "select": "context"},
|
| 217 |
+
headers={"apikey": _SB_KEY2, "Authorization": f"Bearer {_SB_KEY2}"},
|
| 218 |
+
)
|
| 219 |
+
if _cr.is_success:
|
| 220 |
+
_now_ms = int(_tc2.time() * 1000)
|
| 221 |
+
_active = []
|
| 222 |
+
for _row in (_cr.json() or []):
|
| 223 |
+
try:
|
| 224 |
+
_ctx = _jc2.loads(_row.get("context") or "{}")
|
| 225 |
+
if _now_ms - int(_ctx.get("lastHeartbeat", 0)) < 300_000:
|
| 226 |
+
_active.append(_ctx)
|
| 227 |
+
except Exception:
|
| 228 |
+
pass
|
| 229 |
+
if _active:
|
| 230 |
+
_s = _active[0]
|
| 231 |
+
_files = [f.split("/")[-1] for f in _s.get("claimedFiles", [])]
|
| 232 |
+
_fstr = ", ".join(_files[:3]) or "β"
|
| 233 |
+
_extra = f" (+{len(_active)-1})" if len(_active) > 1 else ""
|
| 234 |
+
coord_line = (
|
| 235 |
+
f"\nπ <b>Coord:</b> {html.escape(_s.get('sessionName','?'))}"
|
| 236 |
+
f" [{html.escape(_s.get('sprint','β'))}]"
|
| 237 |
+
f" Β· <code>{html.escape(_fstr)}</code>{_extra}"
|
| 238 |
+
)
|
| 239 |
+
else:
|
| 240 |
+
coord_line = "\nπ <b>Coord:</b> <i>nessuna sessione attiva</i>"
|
| 241 |
+
except Exception:
|
| 242 |
+
pass
|
| 243 |
+
|
| 244 |
+
# ββ Icona salute sistema ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 245 |
+
if running > 0:
|
| 246 |
+
_sys_icon, _sys_label = "βοΈ", f"{running} task in esecuzione"
|
| 247 |
+
elif error > 0 and success == 0 and total > 0:
|
| 248 |
+
_sys_icon, _sys_label = "π΄", "ultimi task terminati con errore"
|
| 249 |
+
elif total == 0:
|
| 250 |
+
_sys_icon, _sys_label = "π€", "nessun task recente"
|
| 251 |
+
else:
|
| 252 |
+
_sys_icon, _sys_label = "β
", "tutto operativo"
|
| 253 |
+
|
| 254 |
+
parts = [f"π <b>Sistema</b> {_sys_icon} β <i>{_sys_label}</i>", ""]
|
| 255 |
+
if running or queued:
|
| 256 |
+
parts.append(f"βοΈ <b>In esecuzione:</b> {running} Β· <b>In coda:</b> {queued}")
|
| 257 |
+
if success or error or total:
|
| 258 |
+
parts.append(f"β
Completati: {success} Β· β Errori: {error} Β· Totale: {total}")
|
| 259 |
+
for _extra_line in [sb_line, ry_line, git_line, live_line, coord_line]:
|
| 260 |
+
if _extra_line:
|
| 261 |
+
parts.append(_extra_line)
|
| 262 |
+
parts += [
|
| 263 |
+
"",
|
| 264 |
+
f"π <b>Scheduler:</b> {sched_label}" + (f" Β· {sched_pending} in coda" if sched_pending else ""),
|
| 265 |
+
"",
|
| 266 |
+
f"<i>π {ts_now} UTC</i>",
|
| 267 |
+
]
|
| 268 |
+
await _tg_reply(chat_id, "\n".join(p for p in parts if p is not None),
|
| 269 |
+
keyboard=_MAIN_KB)
|
| 270 |
+
except Exception as exc:
|
| 271 |
+
await _tg_reply(chat_id, "β οΈ Errore lettura stato: " + html.escape(str(exc)[:200]))
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
async def _cmd_commit_summary(chat_id: int) -> None:
|
| 275 |
+
"""Riepilogo humanizzato degli ultimi commit via GitHub + AI leggera."""
|
| 276 |
+
import json as _json
|
| 277 |
+
GH_API = "https://api.github.com/repos/Baida98/AI/commits?per_page=8"
|
| 278 |
+
SKIP_RE = re.compile(r"^(π|π|acquire push lock|release push lock)", re.I)
|
| 279 |
+
try:
|
| 280 |
+
import httpx
|
| 281 |
+
async with httpx.AsyncClient(timeout=10) as cli:
|
| 282 |
+
r = await cli.get(GH_API, headers={"Authorization": f"Bearer {_gh_token}",
|
| 283 |
+
"Accept": "application/vnd.github+json"})
|
| 284 |
+
commits = r.json() if r.status_code == 200 else []
|
| 285 |
+
except Exception:
|
| 286 |
+
commits = []
|
| 287 |
+
|
| 288 |
+
# Filtra commit di lock/chore puro
|
| 289 |
+
commits = [c for c in commits
|
| 290 |
+
if not SKIP_RE.match((c.get("commit", {}).get("message") or "").split("\n")[0])][:6]
|
| 291 |
+
|
| 292 |
+
if not commits:
|
| 293 |
+
await _tg_reply(chat_id, "π Nessun commit recente trovato.", keyboard=_BACK_KB)
|
| 294 |
+
return
|
| 295 |
+
|
| 296 |
+
# Costruisci breve sintesi statica (no AI) con traduzione tipo
|
| 297 |
+
_TYPE_IT = {
|
| 298 |
+
"feat": "Nuova funzione", "fix": "Correzione bug", "docs": "Documentazione",
|
| 299 |
+
"refactor": "Refactor", "chore": "Manutenzione", "test": "Test",
|
| 300 |
+
"perf": "Performance", "ux": "Esperienza utente", "style": "Stile",
|
| 301 |
+
"ci": "CI/CD", "build": "Build",
|
| 302 |
+
}
|
| 303 |
+
_TYPE_ICON = {
|
| 304 |
+
"feat": "β¨", "fix": "π§", "docs": "π", "refactor": "β»οΈ",
|
| 305 |
+
"chore": "π©", "test": "π§ͺ", "perf": "β‘", "ux": "π¨",
|
| 306 |
+
"ci": "βοΈ", "build": "π¦",
|
| 307 |
+
}
|
| 308 |
+
lines = ["π <b>Ultimi commit</b>\n"]
|
| 309 |
+
for c in commits:
|
| 310 |
+
raw_msg = (c.get("commit", {}).get("message") or "").split("\n")[0]
|
| 311 |
+
sha = (c.get("sha") or "")[:7]
|
| 312 |
+
date = (c.get("commit", {}).get("author", {}).get("date") or "")
|
| 313 |
+
time_s = date[11:16] if len(date) >= 16 else "??:??"
|
| 314 |
+
# Estrai tipo e corpo
|
| 315 |
+
m = re.match(r"^(feat|fix|docs|refactor|chore|test|perf|ux|style|ci|build)(?:\([^)]+\))?:\s*(.+)$", raw_msg)
|
| 316 |
+
if m:
|
| 317 |
+
tipo, corpo = m.group(1), m.group(2)
|
| 318 |
+
icon = _TYPE_ICON.get(tipo, "π")
|
| 319 |
+
tipo_it = _TYPE_IT.get(tipo, tipo)
|
| 320 |
+
# Humanizza la descrizione: rimuovi jargon tecnico comune
|
| 321 |
+
corpo_h = corpo.replace("_", " ").replace("-", " ")
|
| 322 |
+
corpo_h = re.sub(r"(impl|add|implement|update|refactor|fix|use|remove|clean)", "", corpo_h, flags=re.I).strip()
|
| 323 |
+
corpo_h = corpo_h[:60] or corpo[:60]
|
| 324 |
+
lines.append(f"{icon} <b>{tipo_it}</b> β {html.escape(corpo_h)}\n <i>{time_s} Β· <code>{sha}</code></i>")
|
| 325 |
+
else:
|
| 326 |
+
lines.append(f"π {html.escape(raw_msg[:65])}\n <i>{time_s} Β· <code>{sha}</code></i>")
|
| 327 |
+
|
| 328 |
+
await _tg_reply(
|
| 329 |
+
chat_id,
|
| 330 |
+
"\n\n".join(lines),
|
| 331 |
+
keyboard={
|
| 332 |
+
"inline_keyboard": [
|
| 333 |
+
[{"text": "π Aggiorna", "callback_data": "qp_commits"},
|
| 334 |
+
{"text": "π Nuovo Task", "callback_data": "agent"}],
|
| 335 |
+
[{"text": "π Menu", "callback_data": "tgw_help"}],
|
| 336 |
+
]
|
| 337 |
+
}
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
async def _cmd_check(chat_id: int) -> None:
|
| 342 |
+
"""π Health check live: Railway, HF Space A+B, versione vs GH HEAD."""
|
| 343 |
+
import time as _time
|
| 344 |
+
await _tg_typing(chat_id)
|
| 345 |
+
await _tg_reply(chat_id, "π <b>Check infrastruttura...</b>", keyboard=None)
|
| 346 |
+
|
| 347 |
+
async def _probe(label: str, url: str, timeout: int = 7) -> str:
|
| 348 |
+
t0 = _time.monotonic()
|
| 349 |
+
try:
|
| 350 |
+
async with httpx.AsyncClient(timeout=timeout) as cli:
|
| 351 |
+
r = await cli.get(url)
|
| 352 |
+
ms = round((_time.monotonic() - t0) * 1000)
|
| 353 |
+
icon = "β
" if r.status_code == 200 else "β οΈ"
|
| 354 |
+
return f"{icon} <b>{label}</b> β <code>{r.status_code}</code> <i>{ms}ms</i>"
|
| 355 |
+
except Exception as exc:
|
| 356 |
+
ms = round((_time.monotonic() - t0) * 1000)
|
| 357 |
+
return f"β <b>{label}</b> β <code>{str(exc)[:55]}</code> <i>{ms}ms</i>"
|
| 358 |
+
|
| 359 |
+
probes = await asyncio.gather(
|
| 360 |
+
_probe("Railway backend", "https://ai-production-4c06.up.railway.app/health"),
|
| 361 |
+
_probe("HF Space A", "https://arjanit98-terminal.hf.space/api/version"),
|
| 362 |
+
_probe("HF Space B", "https://baida00-ai-backend-collab.hf.space/api/version"),
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
# GH HEAD vs HF Space version
|
| 366 |
+
gh_sha, hf_build = "?", "?"
|
| 367 |
+
try:
|
| 368 |
+
async with httpx.AsyncClient(timeout=5) as cli:
|
| 369 |
+
ref_r = await cli.get(
|
| 370 |
+
"https://api.github.com/repos/Baida98/AI/git/refs/heads/main",
|
| 371 |
+
headers={"Authorization": f"Bearer {_gh_token}", "Accept": "application/vnd.github+json"},
|
| 372 |
+
)
|
| 373 |
+
if ref_r.status_code == 200:
|
| 374 |
+
gh_sha = (ref_r.json().get("object", {}).get("sha") or "?")[:10]
|
| 375 |
+
except Exception:
|
| 376 |
+
pass
|
| 377 |
+
try:
|
| 378 |
+
async with httpx.AsyncClient(timeout=5) as cli:
|
| 379 |
+
ver_r = await cli.get("https://arjanit98-terminal.hf.space/api/version")
|
| 380 |
+
if ver_r.status_code == 200:
|
| 381 |
+
d = ver_r.json()
|
| 382 |
+
hf_build = f"{d.get('version','?')} ({d.get('build_date','?')})"
|
| 383 |
+
except Exception:
|
| 384 |
+
pass
|
| 385 |
+
|
| 386 |
+
sync_icon = "β
" if gh_sha != "?" and gh_sha[:8] in hf_build else "β οΈ"
|
| 387 |
+
lines_out = ["π <b>Infrastructure Check</b>"]
|
| 388 |
+
lines_out.extend(probes)
|
| 389 |
+
lines_out.append(f"<code>GH HEAD: {gh_sha}</code>")
|
| 390 |
+
lines_out.append(f"<code>HF build: {hf_build}</code>")
|
| 391 |
+
lines_out.append(f"{sync_icon} <i>GHβHF {'in sync' if sync_icon=='β
' else 'OUT OF SYNC β usa π Sync HF'}</i>")
|
| 392 |
+
await _tg_reply(chat_id, "\n".join(lines_out), keyboard=_BACK_KB)
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
async def _cmd_tasks(chat_id: int) -> None:
|
| 396 |
+
await _tg_typing(chat_id)
|
| 397 |
+
STATUS_EMOJI = {
|
| 398 |
+
"SUCCESS": "β
", "ERROR": "β", "RUNNING": "βοΈ",
|
| 399 |
+
"QUEUED": "β³", "CANCELLED": "π«",
|
| 400 |
+
}
|
| 401 |
+
try:
|
| 402 |
+
from api.state import _agent_tasks
|
| 403 |
+
mem_tasks = sorted(
|
| 404 |
+
_agent_tasks.values(),
|
| 405 |
+
key=lambda t: t.get("created_at", 0),
|
| 406 |
+
reverse=True,
|
| 407 |
+
)[:8]
|
| 408 |
+
|
| 409 |
+
# Supabase fallback se memoria vuota
|
| 410 |
+
if not mem_tasks:
|
| 411 |
+
try:
|
| 412 |
+
from api.state import _sb
|
| 413 |
+
if _sb:
|
| 414 |
+
res = await asyncio.to_thread(
|
| 415 |
+
lambda: _sb.table("agent_tasks")
|
| 416 |
+
.select("task_id,goal,status,created_at")
|
| 417 |
+
.order("created_at", desc=True)
|
| 418 |
+
.limit(8)
|
| 419 |
+
.execute()
|
| 420 |
+
)
|
| 421 |
+
rows = res.data or []
|
| 422 |
+
if rows:
|
| 423 |
+
lines = ["π <b>Ultimi task (Supabase)</b> <i>β οΈ backend riavviato</i>\n"]
|
| 424 |
+
for r in rows:
|
| 425 |
+
em = STATUS_EMOJI.get(r.get("status", ""), "β’")
|
| 426 |
+
gol = html.escape((r.get("goal") or "")[:55])
|
| 427 |
+
tid = html.escape(str(r.get("task_id") or "")[:8])
|
| 428 |
+
ts = r.get("created_at", 0)
|
| 429 |
+
age = _fmt_elapsed(ts) if isinstance(ts, int) and ts > 1_000_000 else "?"
|
| 430 |
+
lines.append(em + " <code>" + tid + "</code> " + gol + " <i>" + age + "</i>")
|
| 431 |
+
return await _tg_reply(chat_id, "\n".join(lines))
|
| 432 |
+
except Exception as _exc:
|
| 433 |
+
_logger.debug("[telegram_webhook] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 434 |
+
return await _tg_reply(chat_id, "π Nessun task recente in memoria.")
|
| 435 |
+
|
| 436 |
+
_ST_LABEL = {
|
| 437 |
+
"SUCCESS": "Fatto", "ERROR": "Fallito β οΈ", "RUNNING": "In corsoβ¦",
|
| 438 |
+
"QUEUED": "In coda", "CANCELLED": "Annullato",
|
| 439 |
+
}
|
| 440 |
+
_ST_FLAIR = {
|
| 441 |
+
"SUCCESS": "β
", "ERROR": "β", "RUNNING": "βοΈ",
|
| 442 |
+
"QUEUED": "β³", "CANCELLED": "π«",
|
| 443 |
+
}
|
| 444 |
+
lines = ["<b>Cosa ha fatto l'agente:</b>\n"]
|
| 445 |
+
for t in mem_tasks:
|
| 446 |
+
st = t.get("status", "?")
|
| 447 |
+
em = _ST_FLAIR.get(st, "β’")
|
| 448 |
+
gol = html.escape((t.get("goal") or "β")[:70])
|
| 449 |
+
ca = t.get("created_at", 0)
|
| 450 |
+
age = _fmt_elapsed(ca) if isinstance(ca, int) and ca > 1_000_000 else "poco fa"
|
| 451 |
+
lbl = _ST_LABEL.get(st, st)
|
| 452 |
+
lines.append(f"{em} {gol}\n <i>{lbl} Β· {age}</i>")
|
| 453 |
+
await _tg_reply(chat_id, "\n\n".join(lines), keyboard=_BACK_KB)
|
| 454 |
+
except Exception as exc:
|
| 455 |
+
await _tg_reply(chat_id, "β οΈ " + html.escape(str(exc)[:200]))
|
| 456 |
+
|
| 457 |
+
|
api/telegram_keyboards.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/telegram_keyboards.py β Inline keyboards, menu e costanti UI Telegram.
|
| 2 |
+
|
| 3 |
+
Costanti:
|
| 4 |
+
_QUICK_PICK_KB β quick-pick task templates
|
| 5 |
+
_MAIN_KB β tastiera principale (reply keyboard)
|
| 6 |
+
_LAST_GOAL β dict chat_id β ultimo goal (per retry)
|
| 7 |
+
_BENCH_CACHE β cache benchmark
|
| 8 |
+
|
| 9 |
+
Funzioni:
|
| 10 |
+
_after_task_kb(chat_id) β keyboard post-task con retry
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
# ββ Quick-pick task templates (MX-QUICKPICK) ββββββββββββββββββββββββββββββββββ
|
| 14 |
+
_QUICK_PICK_KB = {
|
| 15 |
+
"inline_keyboard": [
|
| 16 |
+
[{"text": "π Analizza bug", "callback_data": "qp_bug"},
|
| 17 |
+
{"text": "β‘ Ottimizza DB", "callback_data": "qp_db"}],
|
| 18 |
+
[{"text": "π§ AutoFix log", "callback_data": "qp_autofix"},
|
| 19 |
+
{"text": "π Riassumi commit", "callback_data": "qp_commits"}],
|
| 20 |
+
[{"text": "π Genera docs", "callback_data": "qp_docs"},
|
| 21 |
+
{"text": "π§ͺ Genera test", "callback_data": "qp_tests"}],
|
| 22 |
+
[{"text": "βοΈ Scrivi obiettivo...", "callback_data": "qp_custom"}],
|
| 23 |
+
[{"text": "π Menu", "callback_data": "tgw_help"}],
|
| 24 |
+
]
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# ββ After-task keyboard (retry + navigazione) βββββββββββββββββββββββββββββββββ
|
| 28 |
+
# Ultimo goal per chat_id β usato da π Rifai
|
| 29 |
+
_LAST_GOAL: dict[int, str] = {}
|
| 30 |
+
|
| 31 |
+
def _after_task_kb(chat_id: int) -> dict:
|
| 32 |
+
"""Keyboard mostrata dopo ogni task completato."""
|
| 33 |
+
return {
|
| 34 |
+
"inline_keyboard": [
|
| 35 |
+
[{"text": "π Rifai", "callback_data": "tgw_retry"},
|
| 36 |
+
{"text": "π AttivitΓ ", "callback_data": "tgw_tasks"}],
|
| 37 |
+
[{"text": "π Nuovo Task", "callback_data": "agent"},
|
| 38 |
+
{"text": "π Menu", "callback_data": "tgw_help"}],
|
| 39 |
+
]
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ββ Main reply keyboard βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
_MAIN_KB = {
|
| 46 |
+
"inline_keyboard": [
|
| 47 |
+
[{"text": "π Nuovo Task", "callback_data": "agent"},
|
| 48 |
+
{"text": "π AttivitΓ ", "callback_data": "tgw_tasks"}],
|
| 49 |
+
[{"text": "π©Ί Salute", "callback_data": "tgw_health"},
|
| 50 |
+
{"text": "π§ AutoFix", "callback_data": "tgw_autofix"}],
|
| 51 |
+
[{"text": "π¬ Chiedi all'AI","callback_data": "tgw_ask"},
|
| 52 |
+
{"text": "π Stato", "callback_data": "tgw_status"}],
|
| 53 |
+
[{"text": "π Dashboard β", "url": "https://agente-ai.pages.dev"}],
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# ββ Bench cache + keyboard (GAP-TGB) βββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
# Salva l'ultimo run bench per chat_id β usato dai callback tgw_bench_fix/run
|
| 59 |
+
_BENCH_CACHE: dict[int, dict] = {}
|
| 60 |
+
|
| 61 |
+
_BENCH_ACTION_KB = {
|
| 62 |
+
"inline_keyboard": [
|
| 63 |
+
[{"text": "π§ Applica Fix", "callback_data": "tgw_bench_fix"},
|
| 64 |
+
{"text": "π Riesegui", "callback_data": "tgw_bench_run"}],
|
| 65 |
+
[{"text": "βοΈ Migliora", "callback_data": "tgw_improve"}],
|
| 66 |
+
[{"text": "π Menu", "callback_data": "tgw_help"}],
|
| 67 |
+
]
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# ββ ReplyKeyboardRemove β rimuove tastiera persistente da versioni precedenti ββ
|
| 71 |
+
_REPLY_KB_REMOVE = {"remove_keyboard": True}
|
| 72 |
+
|
| 73 |
+
# ββ Sub-menu inline keyboards βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
+
_TASK_MENU_KB = {
|
| 75 |
+
"inline_keyboard": [
|
| 76 |
+
[{"text": "π€ Nuovo Task", "callback_data": "tgw_do"},
|
| 77 |
+
{"text": "π§ AutoFix", "callback_data": "tgw_autofix"}],
|
| 78 |
+
[{"text": "βοΈ Migliora AI", "callback_data": "tgw_improve"},
|
| 79 |
+
{"text": "π Task recenti", "callback_data": "tgw_tasks"}],
|
| 80 |
+
[{"text": "π§ Briefing", "callback_data": "tgw_briefing"},
|
| 81 |
+
{"text": "π Salva Nota", "callback_data": "tgw_nota"}],
|
| 82 |
+
[{"text": "π Cerca web", "callback_data": "tgw_cerca"},
|
| 83 |
+
{"text": "π€ Meteo", "callback_data": "tgw_meteo"}],
|
| 84 |
+
]
|
| 85 |
+
}
|
| 86 |
+
_STATUS_MENU_KB = {
|
| 87 |
+
"inline_keyboard": [
|
| 88 |
+
[{"text": "π Daemon+Task", "callback_data": "tgw_status"},
|
| 89 |
+
{"text": "π Provider AI", "callback_data": "tgw_providers"}],
|
| 90 |
+
[{"text": "π Coord sessioni", "callback_data": "tgw_coord"},
|
| 91 |
+
{"text": "π Git log", "callback_data": "tgw_git"}],
|
| 92 |
+
[{"text": "π Dashboard", "url": "https://agente-ai.pages.dev"}],
|
| 93 |
+
]
|
| 94 |
+
}
|
| 95 |
+
_PERF_MENU_KB = {
|
| 96 |
+
"inline_keyboard": [
|
| 97 |
+
[{"text": "π Benchmark", "callback_data": "tgw_bench"},
|
| 98 |
+
{"text": "π Score", "callback_data": "tgw_score"}],
|
| 99 |
+
[{"text": "π‘ Telemetria", "callback_data": "tgw_telemetry"},
|
| 100 |
+
{"text": "βοΈ Migliora", "callback_data": "tgw_improve"}],
|
| 101 |
+
[{"text": "π§ Fix gap bench", "callback_data": "tgw_bench_fix"}],
|
| 102 |
+
]
|
| 103 |
+
}
|
| 104 |
+
_HEALTH_MENU_KB = {
|
| 105 |
+
"inline_keyboard": [
|
| 106 |
+
[{"text": "π Scan completo", "callback_data": "tgw_health"},
|
| 107 |
+
{"text": "π Log errori", "callback_data": "tgw_logs"}],
|
| 108 |
+
[{"text": "π Status", "callback_data": "tgw_status"},
|
| 109 |
+
{"text": "π Provider AI", "callback_data": "tgw_providers"}],
|
| 110 |
+
]
|
| 111 |
+
}
|
| 112 |
+
_DEV_MENU_KB = {
|
| 113 |
+
"inline_keyboard": [
|
| 114 |
+
[{"text": "πΈ Snapshot", "callback_data": "tgw_snap"},
|
| 115 |
+
{"text": "β
Verify", "callback_data": "tgw_verify"}],
|
| 116 |
+
[{"text": "π Heal", "callback_data": "tgw_heal"},
|
| 117 |
+
{"text": "π Log", "callback_data": "tgw_logs"}],
|
| 118 |
+
[{"text": "π Git commits", "callback_data": "tgw_git"},
|
| 119 |
+
{"text": "π Ping", "callback_data": "tgw_ping"}],
|
| 120 |
+
]
|
| 121 |
+
}
|
| 122 |
+
|
api/telegram_tg_client.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/telegram_tg_client.py β Telegram Bot API helpers.
|
| 2 |
+
|
| 3 |
+
Livello piΓΉ basso dell'integrazione Telegram: funzioni pure per inviare
|
| 4 |
+
messaggi, modificarli, reazioni, typing indicator, foto.
|
| 5 |
+
Non ha dipendenze da altri moduli interni β importabile ovunque.
|
| 6 |
+
|
| 7 |
+
Esportati:
|
| 8 |
+
_get_bot_token, _log_tg_exc, _fmt_elapsed
|
| 9 |
+
_tg_reply, _tg_send, _tg_edit, _tg_photo, _tg_typing, _tg_react, _tg_answer_callback
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
import asyncio, html, logging, os, time
|
| 13 |
+
import httpx # top-level β era lazy in 20+ funzioni
|
| 14 |
+
|
| 15 |
+
_logger = logging.getLogger("api.telegram_webhook") # unico logger (duplicato rimosso)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _log_tg_exc(task: "asyncio.Task[None]") -> None:
|
| 19 |
+
"""Gap-2.6: log exceptions from fire-and-forget tasks."""
|
| 20 |
+
try:
|
| 21 |
+
exc = task.exception()
|
| 22 |
+
if exc:
|
| 23 |
+
_logger.warning("tg_webhook bg task error: %s: %s", type(exc).__name__, exc)
|
| 24 |
+
except (asyncio.CancelledError, asyncio.InvalidStateError):
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
|
| 31 |
+
def _get_bot_token() -> str:
|
| 32 |
+
return os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def _tg_reply(chat_id: str | int, text: str, token: str | None = None,
|
| 36 |
+
keyboard: dict | None = None) -> None:
|
| 37 |
+
"""Invia risposta al chat_id con HTML + opzionale inline keyboard."""
|
| 38 |
+
bot_token = token or _get_bot_token()
|
| 39 |
+
if not bot_token:
|
| 40 |
+
return
|
| 41 |
+
payload: dict = {
|
| 42 |
+
"chat_id": chat_id,
|
| 43 |
+
"text": text,
|
| 44 |
+
"parse_mode": "HTML",
|
| 45 |
+
"link_preview_options": {"is_disabled": True},
|
| 46 |
+
}
|
| 47 |
+
if keyboard:
|
| 48 |
+
payload["reply_markup"] = keyboard
|
| 49 |
+
try:
|
| 50 |
+
import httpx
|
| 51 |
+
async with httpx.AsyncClient(timeout=8.0) as c:
|
| 52 |
+
await c.post(
|
| 53 |
+
f"https://api.telegram.org/bot{bot_token}/sendMessage",
|
| 54 |
+
json=payload,
|
| 55 |
+
)
|
| 56 |
+
except Exception as exc:
|
| 57 |
+
_logger.warning("tg_reply error: %s", exc)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def _tg_answer_callback(callback_query_id: str, text: str = "", token: str | None = None) -> None:
|
| 61 |
+
"""Risponde a un callback_query (obbligatorio per chiudere il loading sui buttons)."""
|
| 62 |
+
bot_token = token or _get_bot_token()
|
| 63 |
+
if not bot_token:
|
| 64 |
+
return
|
| 65 |
+
try:
|
| 66 |
+
import httpx
|
| 67 |
+
async with httpx.AsyncClient(timeout=5.0) as c:
|
| 68 |
+
await c.post(
|
| 69 |
+
f"https://api.telegram.org/bot{bot_token}/answerCallbackQuery",
|
| 70 |
+
json={"callback_query_id": callback_query_id, "text": text, "show_alert": False},
|
| 71 |
+
)
|
| 72 |
+
except Exception as exc:
|
| 73 |
+
_logger.debug("answer_callback error: %s", exc)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def _tg_send(chat_id: str | int, text: str, token: str | None = None,
|
| 77 |
+
keyboard: dict | None = None) -> str | None:
|
| 78 |
+
"""Invia messaggio e ritorna il message_id (per editMessageText streaming)."""
|
| 79 |
+
bot_token = token or _get_bot_token()
|
| 80 |
+
if not bot_token:
|
| 81 |
+
return None
|
| 82 |
+
payload: dict = {
|
| 83 |
+
"chat_id": chat_id,
|
| 84 |
+
"text": text,
|
| 85 |
+
"parse_mode": "HTML",
|
| 86 |
+
"link_preview_options": {"is_disabled": True},
|
| 87 |
+
}
|
| 88 |
+
if keyboard:
|
| 89 |
+
payload["reply_markup"] = keyboard
|
| 90 |
+
try:
|
| 91 |
+
import httpx
|
| 92 |
+
async with httpx.AsyncClient(timeout=8.0) as c:
|
| 93 |
+
r = await c.post(
|
| 94 |
+
f"https://api.telegram.org/bot{bot_token}/sendMessage",
|
| 95 |
+
json=payload,
|
| 96 |
+
)
|
| 97 |
+
j = r.json()
|
| 98 |
+
return str(j.get("result", {}).get("message_id", "")) if j.get("ok") else None
|
| 99 |
+
except Exception as exc:
|
| 100 |
+
_logger.warning("tg_send error: %s", exc)
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
async def _tg_edit(chat_id: str | int, message_id: str, text: str,
|
| 105 |
+
token: str | None = None, keyboard: dict | None = None) -> bool:
|
| 106 |
+
"""Aggiorna messaggio esistente β streaming live via editMessageText.
|
| 107 |
+
Ritorna True se successo. Rate-limit: max 20 edit/min per chat Telegram."""
|
| 108 |
+
bot_token = token or _get_bot_token()
|
| 109 |
+
if not bot_token or not message_id:
|
| 110 |
+
return False
|
| 111 |
+
payload: dict = {
|
| 112 |
+
"chat_id": chat_id,
|
| 113 |
+
"message_id": int(message_id),
|
| 114 |
+
"text": text[:4000],
|
| 115 |
+
"parse_mode": "HTML",
|
| 116 |
+
"link_preview_options": {"is_disabled": True},
|
| 117 |
+
}
|
| 118 |
+
if keyboard:
|
| 119 |
+
payload["reply_markup"] = keyboard
|
| 120 |
+
try:
|
| 121 |
+
import httpx
|
| 122 |
+
async with httpx.AsyncClient(timeout=8.0) as c:
|
| 123 |
+
r = await c.post(
|
| 124 |
+
f"https://api.telegram.org/bot{bot_token}/editMessageText",
|
| 125 |
+
json=payload,
|
| 126 |
+
)
|
| 127 |
+
return r.json().get("ok", False)
|
| 128 |
+
except Exception as exc:
|
| 129 |
+
_logger.debug("tg_edit error: %s", exc)
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
async def _tg_photo(
|
| 134 |
+
chat_id: str | int,
|
| 135 |
+
photo_url: str,
|
| 136 |
+
caption: str = "",
|
| 137 |
+
token: str | None = None,
|
| 138 |
+
keyboard: dict | None = None,
|
| 139 |
+
) -> None:
|
| 140 |
+
"""Invia foto/chart via sendPhoto Telegram.
|
| 141 |
+
|
| 142 |
+
Strategia anti URL-lungo:
|
| 143 |
+
1. POST a quickchart.io β scarica PNG bytes β multipart sendPhoto (no limite URL).
|
| 144 |
+
2. Fallback: invia URL direttamente (funziona se URL < ~2000 chars).
|
| 145 |
+
"""
|
| 146 |
+
bot_token = token or _get_bot_token()
|
| 147 |
+
if not bot_token:
|
| 148 |
+
return
|
| 149 |
+
caption_safe = (caption or "")[:1024]
|
| 150 |
+
|
| 151 |
+
import httpx as _hx_p, json as _j_p, urllib.parse as _ul_p, re as _re_p
|
| 152 |
+
|
| 153 |
+
png_bytes: bytes | None = None
|
| 154 |
+
if "quickchart.io/chart" in photo_url:
|
| 155 |
+
try:
|
| 156 |
+
m = _re_p.search(r"[?&]c=([^&]+)", photo_url)
|
| 157 |
+
if m:
|
| 158 |
+
cfg_dict = _j_p.loads(_ul_p.unquote(m.group(1)))
|
| 159 |
+
async with _hx_p.AsyncClient(timeout=20.0) as c:
|
| 160 |
+
qr = await c.post(
|
| 161 |
+
"https://quickchart.io/chart",
|
| 162 |
+
json={"chart": cfg_dict, "width": 720, "height": 420,
|
| 163 |
+
"backgroundColor": "white", "format": "png"},
|
| 164 |
+
)
|
| 165 |
+
if qr.status_code == 200 and qr.headers.get("content-type", "").startswith("image/"):
|
| 166 |
+
png_bytes = qr.content
|
| 167 |
+
_logger.debug("tg_photo: quickchart POST ok, %d bytes", len(png_bytes))
|
| 168 |
+
except Exception as exc:
|
| 169 |
+
_logger.debug("tg_photo: quickchart POST fallback: %s", exc)
|
| 170 |
+
|
| 171 |
+
try:
|
| 172 |
+
import httpx as _hx_s
|
| 173 |
+
async with _hx_s.AsyncClient(timeout=15.0) as c:
|
| 174 |
+
if png_bytes:
|
| 175 |
+
import json as _j_s
|
| 176 |
+
data: dict = {"chat_id": str(chat_id), "parse_mode": "HTML"}
|
| 177 |
+
if caption_safe:
|
| 178 |
+
data["caption"] = caption_safe
|
| 179 |
+
if keyboard:
|
| 180 |
+
data["reply_markup"] = _j_s.dumps(keyboard)
|
| 181 |
+
files = {"photo": ("chart.png", png_bytes, "image/png")}
|
| 182 |
+
await c.post(f"https://api.telegram.org/bot{bot_token}/sendPhoto",
|
| 183 |
+
data=data, files=files)
|
| 184 |
+
else:
|
| 185 |
+
payload: dict = {"chat_id": chat_id, "photo": photo_url, "parse_mode": "HTML"}
|
| 186 |
+
if caption_safe:
|
| 187 |
+
payload["caption"] = caption_safe
|
| 188 |
+
if keyboard:
|
| 189 |
+
payload["reply_markup"] = keyboard
|
| 190 |
+
await c.post(f"https://api.telegram.org/bot{bot_token}/sendPhoto", json=payload)
|
| 191 |
+
except Exception as exc:
|
| 192 |
+
_logger.warning("tg_photo error: %s", exc)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
async def _tg_typing(chat_id: str | int, action: str = "typing", token: str | None = None) -> None:
|
| 196 |
+
"""Invia sendChatAction β mostra 'β¨οΈ digitandoβ¦' prima di operazioni pesanti.
|
| 197 |
+
|
| 198 |
+
Dura 5 secondi o fino al prossimo messaggio del bot.
|
| 199 |
+
Azioni: typing, upload_photo, upload_document, find_location, record_video_note.
|
| 200 |
+
"""
|
| 201 |
+
bot_token = token or _get_bot_token()
|
| 202 |
+
if not bot_token:
|
| 203 |
+
return
|
| 204 |
+
try:
|
| 205 |
+
async with httpx.AsyncClient(timeout=3.0) as c:
|
| 206 |
+
await c.post(
|
| 207 |
+
f"https://api.telegram.org/bot{bot_token}/sendChatAction",
|
| 208 |
+
json={"chat_id": chat_id, "action": action},
|
| 209 |
+
)
|
| 210 |
+
except Exception:
|
| 211 |
+
pass
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
async def _tg_react(
|
| 215 |
+
chat_id: str | int,
|
| 216 |
+
message_id: int | str,
|
| 217 |
+
emoji: str = "π",
|
| 218 |
+
token: str | None = None,
|
| 219 |
+
) -> None:
|
| 220 |
+
"""Aggiunge reazione emoji a un messaggio (Bot API 7.1+, Feb 2024).
|
| 221 |
+
|
| 222 |
+
Emoji supportate: π π β€ π₯ π₯° π π π€ π€― π± π π€© π β
π― β‘ π π―
|
| 223 |
+
"""
|
| 224 |
+
bot_token = token or _get_bot_token()
|
| 225 |
+
if not bot_token or not message_id:
|
| 226 |
+
return
|
| 227 |
+
try:
|
| 228 |
+
async with httpx.AsyncClient(timeout=3.0) as c:
|
| 229 |
+
await c.post(
|
| 230 |
+
f"https://api.telegram.org/bot{bot_token}/setMessageReaction",
|
| 231 |
+
json={
|
| 232 |
+
"chat_id": chat_id,
|
| 233 |
+
"message_id": int(message_id),
|
| 234 |
+
"reaction": [{"type": "emoji", "emoji": emoji}],
|
| 235 |
+
"is_big": False,
|
| 236 |
+
},
|
| 237 |
+
)
|
| 238 |
+
except Exception:
|
| 239 |
+
pass
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _fmt_elapsed(created_at_ms: int) -> str:
|
| 243 |
+
"""Formatta elapsed time da un timestamp ms β stringa leggibile."""
|
| 244 |
+
diff = int(time.time() * 1000) - created_at_ms
|
| 245 |
+
s = diff // 1000
|
| 246 |
+
if s < 60:
|
| 247 |
+
return f"{s}s fa"
|
| 248 |
+
if s < 3600:
|
| 249 |
+
return f"{s // 60}m{s % 60:02d}s fa"
|
| 250 |
+
return f"{s // 3600}h{(s % 3600) // 60:02d}m fa"
|
| 251 |
+
|
| 252 |
+
_WEBAPP_KB = {
|
| 253 |
+
"inline_keyboard": [
|
| 254 |
+
[{"text": "π Apri Dashboard", "web_app": {"url": "https://agente-ai.pages.dev"}}],
|
| 255 |
+
[{"text": "π Menu", "callback_data": "tgw_help"}],
|
| 256 |
+
]
|
| 257 |
+
}
|
| 258 |
+
_BACK_KB = {
|
| 259 |
+
"inline_keyboard": [
|
| 260 |
+
[{"text": "π Menu", "callback_data": "tgw_help"},
|
| 261 |
+
{"text": "π Stato", "callback_data": "tgw_status"}],
|
| 262 |
+
]
|
| 263 |
+
}
|
| 264 |
+
|
api/telegram_webhook.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
memory/distiller.py
CHANGED
|
@@ -1,68 +1,123 @@
|
|
| 1 |
"""
|
| 2 |
-
distiller.py
|
| 3 |
Sintetizza i log grezzi delle sessioni in lezioni apprese e concetti chiave.
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
import logging
|
| 7 |
import json
|
| 8 |
-
|
|
|
|
| 9 |
from datetime import datetime, timezone
|
| 10 |
|
| 11 |
_logger = logging.getLogger("agente_ai.memory.distiller")
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
class MemoryDistiller:
|
| 14 |
def __init__(self, ai_client=None):
|
| 15 |
self.ai_client = ai_client
|
| 16 |
|
| 17 |
async def distill_session(self, messages: List[Dict[str, Any]], goal: str) -> Dict[str, Any]:
|
| 18 |
"""
|
| 19 |
-
|
|
|
|
| 20 |
"""
|
| 21 |
if not messages:
|
| 22 |
return {}
|
| 23 |
|
| 24 |
-
# 1. Pre-processing:
|
| 25 |
cleaned_msgs = []
|
| 26 |
for msg in messages:
|
| 27 |
content = msg.get("content", "")
|
| 28 |
if len(content) > 2000:
|
| 29 |
-
content = content[:1000] + "... [TRUNCATED
|
| 30 |
-
cleaned_msgs.append({"role": msg.get("role"), "content": content})
|
| 31 |
|
| 32 |
-
# 2.
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
4. **Stato Finale**: Il task Γ¨ stato completato? Se no, perchΓ©?
|
| 44 |
-
|
| 45 |
-
FORMATO: JSON compresso.
|
| 46 |
-
"""
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
"
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
def compress_for_long_term(self, distilled_data: Dict[str, Any]) -> str:
|
| 65 |
-
"""
|
| 66 |
-
|
| 67 |
-
"""
|
| 68 |
-
return json.dumps(distilled_data)
|
|
|
|
| 1 |
"""
|
| 2 |
+
distiller.py β S-SESTO-SENSO-V2: Memory Cognitive Compressive.
|
| 3 |
Sintetizza i log grezzi delle sessioni in lezioni apprese e concetti chiave.
|
| 4 |
+
|
| 5 |
+
C1-FIX (2026-07-01): chiamata LLM reale implementata β il distillatore produceva
|
| 6 |
+
solo placeholder vuoto (lessons: [], patterns: [], facts: []) invece di estrarre
|
| 7 |
+
conoscenza reale. Ora chiama ai_client quando disponibile; fallback euristico
|
| 8 |
+
se il client Γ¨ None o se la chiamata fallisce.
|
| 9 |
"""
|
| 10 |
import logging
|
| 11 |
import json
|
| 12 |
+
import re
|
| 13 |
+
from typing import List, Dict, Any, Optional
|
| 14 |
from datetime import datetime, timezone
|
| 15 |
|
| 16 |
_logger = logging.getLogger("agente_ai.memory.distiller")
|
| 17 |
|
| 18 |
+
_DISTILL_SYSTEM = (
|
| 19 |
+
"Sei un Memory Distiller per un agente AI avanzato. "
|
| 20 |
+
"Analizza la sessione e restituisci SOLO un oggetto JSON valido, "
|
| 21 |
+
"senza markdown, senza spiegazioni. Formato esatto:\n"
|
| 22 |
+
'{"lessons":["..."],"patterns":["..."],"facts":["..."],'
|
| 23 |
+
'"completed":true|false,"completion_note":"..."}'
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
def _build_distill_prompt(goal: str, cleaned_msgs: List[Dict]) -> str:
|
| 27 |
+
transcript = "\n".join(
|
| 28 |
+
f"[{m['role'].upper()}] {m['content'][:300]}" for m in cleaned_msgs[-20:]
|
| 29 |
+
)
|
| 30 |
+
return (
|
| 31 |
+
f"OBIETTIVO: {goal}\n\n"
|
| 32 |
+
f"TRASCRIZIONE (ultimi {len(cleaned_msgs[-20:])} messaggi):\n{transcript}\n\n"
|
| 33 |
+
"Estrai lessons (errori + soluzioni), patterns (architetture fragili o ricorrenti), "
|
| 34 |
+
"facts (info tecniche stabili). Rispondi SOLO con il JSON."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
def _heuristic_distill(messages: List[Dict], goal: str) -> Dict[str, Any]:
|
| 38 |
+
"""Fallback euristico β estrae pattern semplici dal testo."""
|
| 39 |
+
errors_found = []
|
| 40 |
+
facts_found = []
|
| 41 |
+
combined = " ".join(m.get("content", "") for m in messages).lower()
|
| 42 |
+
if "errore" in combined or "error" in combined or "exception" in combined:
|
| 43 |
+
errors_found.append("Errori rilevati nella sessione β dettagli nel transcript.")
|
| 44 |
+
if "completato" in combined or "done" in combined or "success" in combined:
|
| 45 |
+
facts_found.append("Task marcato come completato nel transcript.")
|
| 46 |
+
return {
|
| 47 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 48 |
+
"goal": goal,
|
| 49 |
+
"message_count": len(messages),
|
| 50 |
+
"lessons": errors_found,
|
| 51 |
+
"patterns": [],
|
| 52 |
+
"facts": facts_found,
|
| 53 |
+
"completed": "completato" in combined or "done" in combined,
|
| 54 |
+
"completion_note": "Distillazione euristica (LLM non disponibile).",
|
| 55 |
+
"source": "heuristic",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
class MemoryDistiller:
|
| 59 |
def __init__(self, ai_client=None):
|
| 60 |
self.ai_client = ai_client
|
| 61 |
|
| 62 |
async def distill_session(self, messages: List[Dict[str, Any]], goal: str) -> Dict[str, Any]:
|
| 63 |
"""
|
| 64 |
+
C1-FIX: chiama l'LLM reale per estrarre lezioni/pattern/fatti.
|
| 65 |
+
Fallback euristico se ai_client Γ¨ None o la chiamata fallisce.
|
| 66 |
"""
|
| 67 |
if not messages:
|
| 68 |
return {}
|
| 69 |
|
| 70 |
+
# 1. Pre-processing: tronca contenuti pesanti
|
| 71 |
cleaned_msgs = []
|
| 72 |
for msg in messages:
|
| 73 |
content = msg.get("content", "")
|
| 74 |
if len(content) > 2000:
|
| 75 |
+
content = content[:1000] + "... [TRUNCATED] ..." + content[-400:]
|
| 76 |
+
cleaned_msgs.append({"role": msg.get("role", "user"), "content": content})
|
| 77 |
|
| 78 |
+
# 2. Tentativo LLM reale
|
| 79 |
+
if self.ai_client is not None:
|
| 80 |
+
try:
|
| 81 |
+
result = await self._distill_with_llm(cleaned_msgs, goal)
|
| 82 |
+
if result:
|
| 83 |
+
return result
|
| 84 |
+
except Exception as e:
|
| 85 |
+
_logger.warning("[Distiller] LLM call failed (%s) β fallback euristico", e)
|
| 86 |
+
|
| 87 |
+
# 3. Fallback euristico
|
| 88 |
+
return _heuristic_distill(cleaned_msgs, goal)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
+
async def _distill_with_llm(
|
| 91 |
+
self, cleaned_msgs: List[Dict], goal: str
|
| 92 |
+
) -> Optional[Dict[str, Any]]:
|
| 93 |
+
"""Chiamata LLM reale via ai_client (qualsiasi provider con interfaccia openai-compat)."""
|
| 94 |
+
prompt = _build_distill_prompt(goal, cleaned_msgs)
|
| 95 |
+
response = await self.ai_client.chat.completions.create(
|
| 96 |
+
model=getattr(self.ai_client, "_distill_model", "llama-3.1-8b-instant"),
|
| 97 |
+
messages=[
|
| 98 |
+
{"role": "system", "content": _DISTILL_SYSTEM},
|
| 99 |
+
{"role": "user", "content": prompt},
|
| 100 |
+
],
|
| 101 |
+
max_tokens=600,
|
| 102 |
+
temperature=0.1,
|
| 103 |
+
)
|
| 104 |
+
raw = response.choices[0].message.content or ""
|
| 105 |
+
# Estrai JSON dalla risposta (puΓ² avere testo attorno)
|
| 106 |
+
match = re.search(r"\{[\s\S]*\}", raw)
|
| 107 |
+
if not match:
|
| 108 |
+
_logger.warning("[Distiller] risposta LLM non contiene JSON valido: %.100s", raw)
|
| 109 |
+
return None
|
| 110 |
+
parsed = json.loads(match.group(0))
|
| 111 |
+
parsed.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
|
| 112 |
+
parsed.setdefault("goal", goal)
|
| 113 |
+
parsed.setdefault("message_count", len(cleaned_msgs))
|
| 114 |
+
parsed["source"] = "llm"
|
| 115 |
+
_logger.info(
|
| 116 |
+
"[Distiller] distillazione LLM completata: %d lessons, %d facts | goal: %.50s",
|
| 117 |
+
len(parsed.get("lessons", [])), len(parsed.get("facts", [])), goal,
|
| 118 |
+
)
|
| 119 |
+
return parsed
|
| 120 |
|
| 121 |
def compress_for_long_term(self, distilled_data: Dict[str, Any]) -> str:
|
| 122 |
+
"""Converte i dati distillati in stringa ottimizzata per pgvector/semantic memory."""
|
| 123 |
+
return json.dumps(distilled_data, ensure_ascii=False)
|
|
|
|
|
|
memory/evolutionary.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/memory/evolutionary.py β Evolutionary Memory (S960)
|
| 3 |
+
Distilla preferenze utente, stili di codice e regole operative dalle sessioni.
|
| 4 |
+
Alimenta il layer 'Reflection' con conoscenze di alto livello (Long-term Evolution).
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import List, Dict, Any
|
| 12 |
+
|
| 13 |
+
_logger = logging.getLogger("memory.evolutionary")
|
| 14 |
+
|
| 15 |
+
# Directory per i dati evolutivi (Sync con reflection.py)
|
| 16 |
+
_DATA_DIR = os.getenv('CHROMA_DATA_DIR') or ('/data' if Path('/data').exists() else '.')
|
| 17 |
+
EVO_PATH = Path(_DATA_DIR) / 'evolutionary_rules.json'
|
| 18 |
+
|
| 19 |
+
class EvolutionaryMemory:
|
| 20 |
+
def __init__(self, ai_client=None):
|
| 21 |
+
self.ai_client = ai_client
|
| 22 |
+
self.rules: Dict[str, Any] = {
|
| 23 |
+
"user_preferences": {}, # es. "language": "python", "style": "functional"
|
| 24 |
+
"operational_rules": [], # es. "Usa sempre pnpm invece di npm"
|
| 25 |
+
"domain_knowledge": {}, # es. "path/to/project": "description"
|
| 26 |
+
"last_updated": None
|
| 27 |
+
}
|
| 28 |
+
self._load()
|
| 29 |
+
|
| 30 |
+
def _load(self):
|
| 31 |
+
if EVO_PATH.exists():
|
| 32 |
+
try:
|
| 33 |
+
self.rules = json.loads(EVO_PATH.read_text())
|
| 34 |
+
except Exception as e:
|
| 35 |
+
_logger.error(f"[S960] Load error: {e}")
|
| 36 |
+
|
| 37 |
+
def _save(self):
|
| 38 |
+
try:
|
| 39 |
+
EVO_PATH.write_text(json.dumps(self.rules, indent=2, ensure_ascii=False))
|
| 40 |
+
except Exception as e:
|
| 41 |
+
_logger.error(f"[S960] Save error: {e}")
|
| 42 |
+
|
| 43 |
+
async def distill_and_evolve(self, session_summary: Dict[str, Any]):
|
| 44 |
+
"""
|
| 45 |
+
Prende un sommario distillato (dal MemoryDistiller) e aggiorna le regole evolutive.
|
| 46 |
+
"""
|
| 47 |
+
# 1. Estrazione euristica (in attesa di LLM integration)
|
| 48 |
+
# Se il sommario contiene fatti chiave, li integriamo
|
| 49 |
+
facts = session_summary.get("facts", [])
|
| 50 |
+
for fact in facts:
|
| 51 |
+
if ":" in fact:
|
| 52 |
+
k, v = fact.split(":", 1)
|
| 53 |
+
self.rules["domain_knowledge"][k.strip()] = v.strip()
|
| 54 |
+
|
| 55 |
+
# 2. Rilevamento preferenze (es. linguaggi usati con successo)
|
| 56 |
+
lessons = session_summary.get("lessons", [])
|
| 57 |
+
for lesson in lessons:
|
| 58 |
+
if lesson.get("type") == "success":
|
| 59 |
+
# Esempio: "Usato FastAPI con successo" -> preferenza per FastAPI
|
| 60 |
+
pass
|
| 61 |
+
|
| 62 |
+
self.rules["last_updated"] = datetime.now().isoformat()
|
| 63 |
+
self._save()
|
| 64 |
+
_logger.info("[S960] Memoria evolutiva aggiornata.")
|
| 65 |
+
|
| 66 |
+
def get_evolutionary_context(self) -> str:
|
| 67 |
+
"""
|
| 68 |
+
Ritorna una stringa formattata da iniettare nel System Prompt.
|
| 69 |
+
"""
|
| 70 |
+
if not self.rules["user_preferences"] and not self.rules["operational_rules"] and not self.rules["domain_knowledge"]:
|
| 71 |
+
return ""
|
| 72 |
+
|
| 73 |
+
context = "\n[MEMORIA EVOLUTIVA - REGOLE APPRESE]\n"
|
| 74 |
+
|
| 75 |
+
if self.rules["user_preferences"]:
|
| 76 |
+
context += "Preferenze Utente:\n"
|
| 77 |
+
for k, v in self.rules["user_preferences"].items():
|
| 78 |
+
context += f"- {k}: {v}\n"
|
| 79 |
+
|
| 80 |
+
if self.rules["operational_rules"]:
|
| 81 |
+
context += "Regole Operative:\n"
|
| 82 |
+
for rule in self.rules["operational_rules"]:
|
| 83 |
+
context += f"- {rule}\n"
|
| 84 |
+
|
| 85 |
+
if self.rules["domain_knowledge"]:
|
| 86 |
+
context += "Conoscenza Dominio:\n"
|
| 87 |
+
for k, v in self.rules["domain_knowledge"].items():
|
| 88 |
+
context += f"- {k}: {v}\n"
|
| 89 |
+
|
| 90 |
+
return context
|
| 91 |
+
|
| 92 |
+
# Singleton
|
| 93 |
+
evo_memory = EvolutionaryMemory()
|
memory/manager.py
CHANGED
|
@@ -8,6 +8,7 @@ from .working import WorkingMemory
|
|
| 8 |
from .episodic import EpisodicMemory
|
| 9 |
from .semantic import SemanticMemory
|
| 10 |
from .reflection import ReflectionMemory
|
|
|
|
| 11 |
|
| 12 |
import logging
|
| 13 |
_logger = logging.getLogger("memory.manager")
|
|
@@ -89,6 +90,9 @@ class MemoryManager:
|
|
| 89 |
# ββ get_context β algoritmo Waterfall TAM ββββββββββββββββββββββββββββββββββ
|
| 90 |
|
| 91 |
async def get_context(self, query: str, code_length: int = 0) -> str:
|
|
|
|
|
|
|
|
|
|
| 92 |
"""Assembla il contesto dai 4 layer con Waterfall Token Budget.
|
| 93 |
|
| 94 |
TAM β Token-Aware Memory:
|
|
@@ -284,3 +288,4 @@ class MemoryManager:
|
|
| 284 |
if self.episodic._db:
|
| 285 |
self.episodic._db.execute("DELETE FROM episodes")
|
| 286 |
self.episodic._db.commit()
|
|
|
|
|
|
| 8 |
from .episodic import EpisodicMemory
|
| 9 |
from .semantic import SemanticMemory
|
| 10 |
from .reflection import ReflectionMemory
|
| 11 |
+
from .evolutionary import evo_memory # S960
|
| 12 |
|
| 13 |
import logging
|
| 14 |
_logger = logging.getLogger("memory.manager")
|
|
|
|
| 90 |
# ββ get_context β algoritmo Waterfall TAM ββββββββββββββββββββββββββββββββββ
|
| 91 |
|
| 92 |
async def get_context(self, query: str, code_length: int = 0) -> str:
|
| 93 |
+
# S960: Recupero regole evolutive apprese
|
| 94 |
+
evo_context = evo_memory.get_evolutionary_context()
|
| 95 |
+
|
| 96 |
"""Assembla il contesto dai 4 layer con Waterfall Token Budget.
|
| 97 |
|
| 98 |
TAM β Token-Aware Memory:
|
|
|
|
| 288 |
if self.episodic._db:
|
| 289 |
self.episodic._db.execute("DELETE FROM episodes")
|
| 290 |
self.episodic._db.commit()
|
| 291 |
+
|
sql/cache_schema.sql
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ============================================================================
|
| 2 |
+
-- cache_schema.sql β Schema Tabella Cache per Supabase A
|
| 3 |
+
--
|
| 4 |
+
-- Eseguire su Supabase A (nodo analytics/cache) per creare la tabella cache
|
| 5 |
+
-- ============================================================================
|
| 6 |
+
|
| 7 |
+
-- Tabella Cache Entries
|
| 8 |
+
CREATE TABLE IF NOT EXISTS cache_entries (
|
| 9 |
+
id BIGSERIAL PRIMARY KEY,
|
| 10 |
+
cache_key VARCHAR(32) NOT NULL UNIQUE,
|
| 11 |
+
strategy VARCHAR(50) NOT NULL,
|
| 12 |
+
identifier TEXT NOT NULL,
|
| 13 |
+
value TEXT NOT NULL,
|
| 14 |
+
ttl_seconds INTEGER DEFAULT 3600,
|
| 15 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
| 16 |
+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
| 17 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
| 18 |
+
|
| 19 |
+
-- Indici per performance
|
| 20 |
+
CONSTRAINT cache_key_idx UNIQUE (cache_key),
|
| 21 |
+
CONSTRAINT strategy_idx ON strategy,
|
| 22 |
+
CONSTRAINT expires_at_idx ON expires_at
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
-- Indice su expires_at per pulizia automatica
|
| 26 |
+
CREATE INDEX IF NOT EXISTS idx_cache_expires_at ON cache_entries(expires_at);
|
| 27 |
+
|
| 28 |
+
-- Indice su strategy per query veloci per strategia
|
| 29 |
+
CREATE INDEX IF NOT EXISTS idx_cache_strategy ON cache_entries(strategy);
|
| 30 |
+
|
| 31 |
+
-- Indice composito per query veloci (strategy + identifier)
|
| 32 |
+
CREATE INDEX IF NOT EXISTS idx_cache_strategy_identifier ON cache_entries(strategy, identifier);
|
| 33 |
+
|
| 34 |
+
-- Tabella Statistiche Cache
|
| 35 |
+
CREATE TABLE IF NOT EXISTS cache_stats (
|
| 36 |
+
id BIGSERIAL PRIMARY KEY,
|
| 37 |
+
hits BIGINT DEFAULT 0,
|
| 38 |
+
misses BIGINT DEFAULT 0,
|
| 39 |
+
sets BIGINT DEFAULT 0,
|
| 40 |
+
deletes BIGINT DEFAULT 0,
|
| 41 |
+
evictions BIGINT DEFAULT 0,
|
| 42 |
+
recorded_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
| 43 |
+
|
| 44 |
+
CONSTRAINT cache_stats_pkey PRIMARY KEY (id)
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
-- Tabella Audit Cache (opzionale)
|
| 48 |
+
CREATE TABLE IF NOT EXISTS cache_audit (
|
| 49 |
+
id BIGSERIAL PRIMARY KEY,
|
| 50 |
+
action VARCHAR(50) NOT NULL, -- 'GET', 'SET', 'DELETE', 'EVICT'
|
| 51 |
+
strategy VARCHAR(50) NOT NULL,
|
| 52 |
+
identifier TEXT NOT NULL,
|
| 53 |
+
cache_key VARCHAR(32),
|
| 54 |
+
status VARCHAR(20) NOT NULL, -- 'HIT', 'MISS', 'SUCCESS', 'FAILURE'
|
| 55 |
+
duration_ms INTEGER,
|
| 56 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 57 |
+
);
|
| 58 |
+
|
| 59 |
+
-- Indice su created_at per query audit veloci
|
| 60 |
+
CREATE INDEX IF NOT EXISTS idx_cache_audit_created_at ON cache_audit(created_at DESC);
|
| 61 |
+
|
| 62 |
+
-- Indice su action per filtrare per tipo operazione
|
| 63 |
+
CREATE INDEX IF NOT EXISTS idx_cache_audit_action ON cache_audit(action);
|
| 64 |
+
|
| 65 |
+
-- ============================================================================
|
| 66 |
+
-- Funzione: Pulizia automatica entry scaduti
|
| 67 |
+
-- ============================================================================
|
| 68 |
+
CREATE OR REPLACE FUNCTION cleanup_expired_cache()
|
| 69 |
+
RETURNS TABLE(deleted_count INTEGER) AS $$
|
| 70 |
+
BEGIN
|
| 71 |
+
DELETE FROM cache_entries WHERE expires_at < NOW();
|
| 72 |
+
RETURN QUERY SELECT COUNT(*)::INTEGER FROM cache_entries WHERE expires_at < NOW();
|
| 73 |
+
END;
|
| 74 |
+
$$ LANGUAGE plpgsql;
|
| 75 |
+
|
| 76 |
+
-- ============================================================================
|
| 77 |
+
-- Trigger: Aggiorna updated_at su UPDATE
|
| 78 |
+
-- ============================================================================
|
| 79 |
+
CREATE OR REPLACE FUNCTION update_cache_updated_at()
|
| 80 |
+
RETURNS TRIGGER AS $$
|
| 81 |
+
BEGIN
|
| 82 |
+
NEW.updated_at = NOW();
|
| 83 |
+
RETURN NEW;
|
| 84 |
+
END;
|
| 85 |
+
$$ LANGUAGE plpgsql;
|
| 86 |
+
|
| 87 |
+
CREATE TRIGGER cache_entries_updated_at_trigger
|
| 88 |
+
BEFORE UPDATE ON cache_entries
|
| 89 |
+
FOR EACH ROW
|
| 90 |
+
EXECUTE FUNCTION update_cache_updated_at();
|
| 91 |
+
|
| 92 |
+
-- ============================================================================
|
| 93 |
+
-- View: Statistiche Cache Attuali
|
| 94 |
+
-- ============================================================================
|
| 95 |
+
CREATE OR REPLACE VIEW cache_stats_view AS
|
| 96 |
+
SELECT
|
| 97 |
+
COUNT(*) as total_entries,
|
| 98 |
+
COUNT(CASE WHEN expires_at > NOW() THEN 1 END) as valid_entries,
|
| 99 |
+
COUNT(CASE WHEN expires_at <= NOW() THEN 1 END) as expired_entries,
|
| 100 |
+
COUNT(DISTINCT strategy) as unique_strategies,
|
| 101 |
+
MIN(created_at) as oldest_entry,
|
| 102 |
+
MAX(created_at) as newest_entry,
|
| 103 |
+
AVG(ttl_seconds) as avg_ttl_seconds
|
| 104 |
+
FROM cache_entries;
|
| 105 |
+
|
| 106 |
+
-- ============================================================================
|
| 107 |
+
-- View: Hit Rate (ultimi 24 ore)
|
| 108 |
+
-- ============================================================================
|
| 109 |
+
CREATE OR REPLACE VIEW cache_hit_rate_24h AS
|
| 110 |
+
SELECT
|
| 111 |
+
COUNT(CASE WHEN status = 'HIT' THEN 1 END) as hits,
|
| 112 |
+
COUNT(CASE WHEN status = 'MISS' THEN 1 END) as misses,
|
| 113 |
+
COUNT(*) as total_requests,
|
| 114 |
+
ROUND(
|
| 115 |
+
COUNT(CASE WHEN status = 'HIT' THEN 1 END)::NUMERIC /
|
| 116 |
+
NULLIF(COUNT(*), 0) * 100,
|
| 117 |
+
2
|
| 118 |
+
) as hit_rate_percent
|
| 119 |
+
FROM cache_audit
|
| 120 |
+
WHERE created_at > NOW() - INTERVAL '24 hours';
|
| 121 |
+
|
| 122 |
+
-- ============================================================================
|
| 123 |
+
-- Policy RLS (Row Level Security) β Opzionale
|
| 124 |
+
-- ============================================================================
|
| 125 |
+
-- Abilita RLS sulla tabella cache_entries
|
| 126 |
+
ALTER TABLE cache_entries ENABLE ROW LEVEL SECURITY;
|
| 127 |
+
|
| 128 |
+
-- Policy: Tutti possono leggere cache (read-only per A)
|
| 129 |
+
CREATE POLICY "Allow read access to cache" ON cache_entries
|
| 130 |
+
FOR SELECT USING (true);
|
| 131 |
+
|
| 132 |
+
-- Policy: Solo service role puΓ² scrivere/modificare cache
|
| 133 |
+
CREATE POLICY "Allow write access to cache (service role only)" ON cache_entries
|
| 134 |
+
FOR INSERT WITH CHECK (current_user = 'postgres');
|
| 135 |
+
|
| 136 |
+
CREATE POLICY "Allow update access to cache (service role only)" ON cache_entries
|
| 137 |
+
FOR UPDATE USING (current_user = 'postgres');
|
| 138 |
+
|
| 139 |
+
CREATE POLICY "Allow delete access to cache (service role only)" ON cache_entries
|
| 140 |
+
FOR DELETE USING (current_user = 'postgres');
|
| 141 |
+
|
| 142 |
+
-- ============================================================================
|
| 143 |
+
-- Commenti Documentazione
|
| 144 |
+
-- ============================================================================
|
| 145 |
+
COMMENT ON TABLE cache_entries IS 'Archivio cache distribuito su Supabase A (read-heavy, non-critical)';
|
| 146 |
+
COMMENT ON COLUMN cache_entries.cache_key IS 'Chiave cache univoca (SHA256 hash di strategy:identifier)';
|
| 147 |
+
COMMENT ON COLUMN cache_entries.strategy IS 'Strategia cache: query, memory, embedding, conversation, analytics';
|
| 148 |
+
COMMENT ON COLUMN cache_entries.identifier IS 'Identificatore univoco per il valore (es. session_id, query_hash)';
|
| 149 |
+
COMMENT ON COLUMN cache_entries.value IS 'Valore memorizzato in cache (JSON serializzato)';
|
| 150 |
+
COMMENT ON COLUMN cache_entries.ttl_seconds IS 'Time-to-live in secondi (tempo di scadenza)';
|
| 151 |
+
COMMENT ON COLUMN cache_entries.expires_at IS 'Timestamp di scadenza (created_at + ttl_seconds)';
|
| 152 |
+
|
| 153 |
+
COMMENT ON TABLE cache_stats IS 'Statistiche aggregate del cache layer';
|
| 154 |
+
COMMENT ON TABLE cache_audit IS 'Audit log di tutte le operazioni cache (GET, SET, DELETE, EVICT)';
|
| 155 |
+
|
| 156 |
+
COMMENT ON FUNCTION cleanup_expired_cache() IS 'Rimuove entry scaduti dalla cache';
|
| 157 |
+
COMMENT ON VIEW cache_stats_view IS 'Statistiche attuali della cache (totale, valide, scadute, ecc.)';
|
| 158 |
+
COMMENT ON VIEW cache_hit_rate_24h IS 'Hit rate del cache nelle ultime 24 ore';
|
tests/test_telegram_commands.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""tests/test_telegram_commands.py β Test funzionale dei command handler Telegram.
|
| 3 |
+
|
| 4 |
+
Cosa testa
|
| 5 |
+
----------
|
| 6 |
+
Ogni command handler (19 comandi + 2 callback dispatcher) viene eseguito
|
| 7 |
+
davvero, ma tutte le chiamate HTTP verso api.telegram.org sono intercettate.
|
| 8 |
+
|
| 9 |
+
Nessun messaggio viene inviato su Telegram.
|
| 10 |
+
Nessuna env var richiesta.
|
| 11 |
+
Nessuna connessione di rete necessaria.
|
| 12 |
+
|
| 13 |
+
Come funziona
|
| 14 |
+
-------------
|
| 15 |
+
1. Le dipendenze esterne (fastapi, pydantic, httpx, api.*) vengono stubbate.
|
| 16 |
+
2. Le funzioni _tg_reply / _tg_send / _tg_edit / _tg_typing / _tg_react
|
| 17 |
+
vengono rimpiazzate da AsyncMock PRIMA di ogni test, nel namespace del
|
| 18 |
+
modulo importato (import_module caching garantisce coerenza).
|
| 19 |
+
3. Il test verifica che ogni handler abbia chiamato almeno una funzione TG.
|
| 20 |
+
|
| 21 |
+
Uso
|
| 22 |
+
---
|
| 23 |
+
cd backend && python -m pytest tests/test_telegram_commands.py -v
|
| 24 |
+
cd backend && python tests/test_telegram_commands.py # standalone
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
import asyncio, importlib, sys, types, unittest
|
| 28 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 29 |
+
|
| 30 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
# 0. Stub dipendenze esterne (identico a test_telegram_imports.py)
|
| 32 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
def _stub(name: str, **attrs) -> types.ModuleType:
|
| 34 |
+
m = types.ModuleType(name)
|
| 35 |
+
for k, v in attrs.items():
|
| 36 |
+
setattr(m, k, v)
|
| 37 |
+
sys.modules[name] = m
|
| 38 |
+
return m
|
| 39 |
+
|
| 40 |
+
_fa = _stub("fastapi")
|
| 41 |
+
_fa.APIRouter = type("APIRouter", (), {
|
| 42 |
+
"__init__": lambda self, **kw: None,
|
| 43 |
+
"post": lambda self, *a, **kw: (lambda f: f),
|
| 44 |
+
"get": lambda self, *a, **kw: (lambda f: f),
|
| 45 |
+
})
|
| 46 |
+
_fa.Request = type("Request", (), {})
|
| 47 |
+
_fa.HTTPException = type("HTTPException", (Exception,), {})
|
| 48 |
+
_fa.BackgroundTasks = type("BackgroundTasks", (), {})
|
| 49 |
+
_stub("fastapi.responses", JSONResponse=type("JSONResponse", (), {}))
|
| 50 |
+
_stub("fastapi.routing")
|
| 51 |
+
_stub("starlette.requests", Request=_fa.Request)
|
| 52 |
+
_stub("pydantic", BaseModel=type("BaseModel", (), {}))
|
| 53 |
+
|
| 54 |
+
# httpx β il client viene moccato per intercettare tutte le chiamate TG
|
| 55 |
+
class _FakeResponse:
|
| 56 |
+
status_code = 200
|
| 57 |
+
def json(self): return {"ok": True, "result": {"message_id": 999}}
|
| 58 |
+
async def aread(self): return b'{"ok":true,"result":{"message_id":999}}'
|
| 59 |
+
|
| 60 |
+
class _FakeAsyncClient:
|
| 61 |
+
def __init__(self, *a, **kw): pass
|
| 62 |
+
async def __aenter__(self): return self
|
| 63 |
+
async def __aexit__(self, *a): pass
|
| 64 |
+
async def post(self, url, **kw): return _FakeResponse()
|
| 65 |
+
async def get(self, url, **kw): return _FakeResponse()
|
| 66 |
+
|
| 67 |
+
_httpx = _stub("httpx", AsyncClient=_FakeAsyncClient, Response=_FakeResponse)
|
| 68 |
+
_stub("httpcore")
|
| 69 |
+
_stub("anyio")
|
| 70 |
+
|
| 71 |
+
# Backend interno β stub per evitare import circolari
|
| 72 |
+
for _m in [
|
| 73 |
+
"api.agent", "api.providers", "api.state", "api.unified_api",
|
| 74 |
+
"api.structured_log", "api.supabase_client", "api.exec",
|
| 75 |
+
"agents.unified_loop_fallback", "agents.goal_verifier",
|
| 76 |
+
"agents.memory_manager", "agents.audit_semantic_l2",
|
| 77 |
+
]:
|
| 78 |
+
_stub(_m)
|
| 79 |
+
|
| 80 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
# 1. Import moduli (dopo aver stubbato le dipendenze)
|
| 82 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 83 |
+
def _imp(name: str):
|
| 84 |
+
full = f"api.{name}"
|
| 85 |
+
return sys.modules.get(full) or importlib.import_module(full)
|
| 86 |
+
|
| 87 |
+
# Import nell'ordine delle dipendenze
|
| 88 |
+
_tg_client = _imp("telegram_tg_client")
|
| 89 |
+
_keyboards = _imp("telegram_keyboards")
|
| 90 |
+
_mon = _imp("telegram_cmd_monitoring")
|
| 91 |
+
_ai = _imp("telegram_cmd_ai")
|
| 92 |
+
_cb = _imp("telegram_callbacks")
|
| 93 |
+
|
| 94 |
+
CHAT_ID = 123456789 # chat_id fittizio β non viene mai usato realmente
|
| 95 |
+
|
| 96 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 97 |
+
# 2. Helper β patch di tutte le _tg_* nel modulo target
|
| 98 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
TG_FUNS = ["_tg_reply", "_tg_send", "_tg_edit", "_tg_typing",
|
| 100 |
+
"_tg_react", "_tg_photo", "_tg_answer_callback"]
|
| 101 |
+
|
| 102 |
+
class TGCapture:
|
| 103 |
+
"""Context manager: patcha le funzioni TG in 'mod', registra le chiamate."""
|
| 104 |
+
def __init__(self, *mods):
|
| 105 |
+
self.mods = mods
|
| 106 |
+
self.mocks = {} # "mod.fun" β AsyncMock
|
| 107 |
+
self._patches = []
|
| 108 |
+
|
| 109 |
+
def __enter__(self):
|
| 110 |
+
for mod in self.mods:
|
| 111 |
+
for fun in TG_FUNS:
|
| 112 |
+
if hasattr(mod, fun):
|
| 113 |
+
m = AsyncMock(return_value=999)
|
| 114 |
+
p = patch.object(mod, fun, m)
|
| 115 |
+
p.start()
|
| 116 |
+
self._patches.append(p)
|
| 117 |
+
self.mocks[f"{mod.__name__}.{fun}"] = m
|
| 118 |
+
return self
|
| 119 |
+
|
| 120 |
+
def __exit__(self, *a):
|
| 121 |
+
for p in self._patches:
|
| 122 |
+
p.stop()
|
| 123 |
+
|
| 124 |
+
def called_any(self) -> bool:
|
| 125 |
+
return any(m.called for m in self.mocks.values())
|
| 126 |
+
|
| 127 |
+
def calls(self) -> list[str]:
|
| 128 |
+
return [k.split(".")[-1] for k,m in self.mocks.items() if m.called]
|
| 129 |
+
|
| 130 |
+
def run(coro):
|
| 131 |
+
return asyncio.get_event_loop().run_until_complete(coro)
|
| 132 |
+
|
| 133 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 134 |
+
# 3. Test suite
|
| 135 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
+
class TestTelegramCommands(unittest.TestCase):
|
| 137 |
+
|
| 138 |
+
# ββ Monitoring βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 139 |
+
def test_cmd_help(self):
|
| 140 |
+
with TGCapture(_mon) as cap:
|
| 141 |
+
run(_mon._cmd_help(CHAT_ID))
|
| 142 |
+
self.assertTrue(cap.called_any(), f"_cmd_help non ha chiamato nulla. Mocks: {cap.calls()}")
|
| 143 |
+
|
| 144 |
+
def test_cmd_logs(self):
|
| 145 |
+
with TGCapture(_mon) as cap:
|
| 146 |
+
run(_mon._cmd_logs(CHAT_ID))
|
| 147 |
+
self.assertTrue(cap.called_any(), f"_cmd_logs: {cap.calls()}")
|
| 148 |
+
|
| 149 |
+
def test_cmd_logs_level(self):
|
| 150 |
+
"""_cmd_logs accetta livello custom."""
|
| 151 |
+
with TGCapture(_mon) as cap:
|
| 152 |
+
run(_mon._cmd_logs(CHAT_ID, level="ERROR"))
|
| 153 |
+
self.assertTrue(cap.called_any())
|
| 154 |
+
|
| 155 |
+
def test_cmd_status(self):
|
| 156 |
+
with TGCapture(_mon) as cap:
|
| 157 |
+
run(_mon._cmd_status(CHAT_ID))
|
| 158 |
+
self.assertTrue(cap.called_any(), f"_cmd_status: {cap.calls()}")
|
| 159 |
+
|
| 160 |
+
def test_cmd_commit_summary(self):
|
| 161 |
+
with TGCapture(_mon) as cap:
|
| 162 |
+
run(_mon._cmd_commit_summary(CHAT_ID))
|
| 163 |
+
self.assertTrue(cap.called_any(), f"_cmd_commit_summary: {cap.calls()}")
|
| 164 |
+
|
| 165 |
+
def test_cmd_check(self):
|
| 166 |
+
with TGCapture(_mon) as cap:
|
| 167 |
+
run(_mon._cmd_check(CHAT_ID))
|
| 168 |
+
self.assertTrue(cap.called_any(), f"_cmd_check: {cap.calls()}")
|
| 169 |
+
|
| 170 |
+
def test_cmd_tasks(self):
|
| 171 |
+
with TGCapture(_mon) as cap:
|
| 172 |
+
run(_mon._cmd_tasks(CHAT_ID))
|
| 173 |
+
self.assertTrue(cap.called_any(), f"_cmd_tasks: {cap.calls()}")
|
| 174 |
+
|
| 175 |
+
# ββ AI commands ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
def test_cmd_do_empty_goal(self):
|
| 177 |
+
"""_cmd_do con goal vuoto deve rispondere con la keyboard quick-pick."""
|
| 178 |
+
with TGCapture(_ai) as cap:
|
| 179 |
+
run(_ai._cmd_do(CHAT_ID, goal=""))
|
| 180 |
+
self.assertTrue(cap.called_any(), f"_cmd_do(goal=''): {cap.calls()}")
|
| 181 |
+
|
| 182 |
+
def test_cmd_do_with_goal(self):
|
| 183 |
+
"""_cmd_do con goal reale avvia lo streaming β la prima chiamata TG Γ¨ _tg_send."""
|
| 184 |
+
with TGCapture(_ai) as cap:
|
| 185 |
+
run(_ai._cmd_do(CHAT_ID, goal="analizza i log"))
|
| 186 |
+
self.assertTrue(cap.called_any(), f"_cmd_do(goal): {cap.calls()}")
|
| 187 |
+
|
| 188 |
+
def test_cmd_do_saves_last_goal(self):
|
| 189 |
+
"""_cmd_do salva il goal in _LAST_GOAL per il tasto Rifai."""
|
| 190 |
+
with TGCapture(_ai):
|
| 191 |
+
run(_ai._cmd_do(CHAT_ID, goal="test retry goal"))
|
| 192 |
+
from api.telegram_keyboards import _LAST_GOAL
|
| 193 |
+
self.assertEqual(_LAST_GOAL.get(CHAT_ID), "test retry goal")
|
| 194 |
+
|
| 195 |
+
def test_cmd_autofix(self):
|
| 196 |
+
with TGCapture(_ai) as cap:
|
| 197 |
+
run(_ai._cmd_autofix(CHAT_ID))
|
| 198 |
+
self.assertTrue(cap.called_any(), f"_cmd_autofix: {cap.calls()}")
|
| 199 |
+
|
| 200 |
+
def test_cmd_autofix_with_hint(self):
|
| 201 |
+
with TGCapture(_ai) as cap:
|
| 202 |
+
run(_ai._cmd_autofix(CHAT_ID, hint="TypeError in providers.py"))
|
| 203 |
+
self.assertTrue(cap.called_any())
|
| 204 |
+
|
| 205 |
+
def test_cmd_nota(self):
|
| 206 |
+
with TGCapture(_ai) as cap:
|
| 207 |
+
run(_ai._cmd_nota(CHAT_ID, text="ricordati di aggiornare il token"))
|
| 208 |
+
self.assertTrue(cap.called_any(), f"_cmd_nota: {cap.calls()}")
|
| 209 |
+
|
| 210 |
+
def test_cmd_cerca(self):
|
| 211 |
+
with TGCapture(_ai) as cap:
|
| 212 |
+
run(_ai._cmd_cerca(CHAT_ID, query="FastAPI async streaming"))
|
| 213 |
+
self.assertTrue(cap.called_any(), f"_cmd_cerca: {cap.calls()}")
|
| 214 |
+
|
| 215 |
+
def test_cmd_meteo(self):
|
| 216 |
+
with TGCapture(_ai) as cap:
|
| 217 |
+
run(_ai._cmd_meteo(CHAT_ID, city="Milano"))
|
| 218 |
+
self.assertTrue(cap.called_any(), f"_cmd_meteo: {cap.calls()}")
|
| 219 |
+
|
| 220 |
+
def test_cmd_riepilogo(self):
|
| 221 |
+
with TGCapture(_ai) as cap:
|
| 222 |
+
run(_ai._cmd_riepilogo(CHAT_ID))
|
| 223 |
+
self.assertTrue(cap.called_any(), f"_cmd_riepilogo: {cap.calls()}")
|
| 224 |
+
|
| 225 |
+
def test_cmd_scan_now(self):
|
| 226 |
+
with TGCapture(_ai) as cap:
|
| 227 |
+
run(_ai._cmd_scan_now(CHAT_ID))
|
| 228 |
+
self.assertTrue(cap.called_any(), f"_cmd_scan_now: {cap.calls()}")
|
| 229 |
+
|
| 230 |
+
def test_cmd_coord(self):
|
| 231 |
+
with TGCapture(_ai) as cap:
|
| 232 |
+
run(_ai._cmd_coord(CHAT_ID))
|
| 233 |
+
self.assertTrue(cap.called_any(), f"_cmd_coord: {cap.calls()}")
|
| 234 |
+
|
| 235 |
+
def test_cmd_git(self):
|
| 236 |
+
with TGCapture(_ai) as cap:
|
| 237 |
+
run(_ai._cmd_git(CHAT_ID))
|
| 238 |
+
self.assertTrue(cap.called_any(), f"_cmd_git: {cap.calls()}")
|
| 239 |
+
|
| 240 |
+
def test_cmd_git_custom_n(self):
|
| 241 |
+
with TGCapture(_ai) as cap:
|
| 242 |
+
run(_ai._cmd_git(CHAT_ID, n=10))
|
| 243 |
+
self.assertTrue(cap.called_any())
|
| 244 |
+
|
| 245 |
+
def test_cmd_telemetry(self):
|
| 246 |
+
with TGCapture(_ai) as cap:
|
| 247 |
+
run(_ai._cmd_telemetry(CHAT_ID))
|
| 248 |
+
self.assertTrue(cap.called_any(), f"_cmd_telemetry: {cap.calls()}")
|
| 249 |
+
|
| 250 |
+
def test_cmd_score(self):
|
| 251 |
+
with TGCapture(_ai) as cap:
|
| 252 |
+
run(_ai._cmd_score(CHAT_ID))
|
| 253 |
+
self.assertTrue(cap.called_any(), f"_cmd_score: {cap.calls()}")
|
| 254 |
+
|
| 255 |
+
def test_cmd_bench(self):
|
| 256 |
+
with TGCapture(_ai) as cap:
|
| 257 |
+
run(_ai._cmd_bench(CHAT_ID))
|
| 258 |
+
self.assertTrue(cap.called_any(), f"_cmd_bench: {cap.calls()}")
|
| 259 |
+
|
| 260 |
+
def test_cmd_improve(self):
|
| 261 |
+
with TGCapture(_ai) as cap:
|
| 262 |
+
run(_ai._cmd_improve(CHAT_ID))
|
| 263 |
+
self.assertTrue(cap.called_any(), f"_cmd_improve: {cap.calls()}")
|
| 264 |
+
|
| 265 |
+
# ββ Callback dispatcher ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 266 |
+
def _make_cb(self, data: str) -> dict:
|
| 267 |
+
return {
|
| 268 |
+
"id": "cb_001",
|
| 269 |
+
"from": {"id": CHAT_ID, "first_name": "Test"},
|
| 270 |
+
"message": {"message_id": 42, "chat": {"id": CHAT_ID}},
|
| 271 |
+
"chat_instance": "ci",
|
| 272 |
+
"data": data,
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
def test_handle_callback_help(self):
|
| 276 |
+
with TGCapture(_mon, _ai, _cb) as cap:
|
| 277 |
+
run(_cb._handle_callback(self._make_cb("tgw_help"), token="fake"))
|
| 278 |
+
self.assertTrue(cap.called_any(), "callback tgw_help: nessuna chiamata TG")
|
| 279 |
+
|
| 280 |
+
def test_handle_callback_status(self):
|
| 281 |
+
with TGCapture(_mon, _ai, _cb) as cap:
|
| 282 |
+
run(_cb._handle_callback(self._make_cb("tgw_status"), token="fake"))
|
| 283 |
+
self.assertTrue(cap.called_any(), "callback tgw_status")
|
| 284 |
+
|
| 285 |
+
def test_handle_callback_tasks(self):
|
| 286 |
+
with TGCapture(_mon, _ai, _cb) as cap:
|
| 287 |
+
run(_cb._handle_callback(self._make_cb("tgw_tasks"), token="fake"))
|
| 288 |
+
self.assertTrue(cap.called_any(), "callback tgw_tasks")
|
| 289 |
+
|
| 290 |
+
def test_handle_callback_do(self):
|
| 291 |
+
with TGCapture(_mon, _ai, _cb) as cap:
|
| 292 |
+
run(_cb._handle_callback(self._make_cb("agent"), token="fake"))
|
| 293 |
+
self.assertTrue(cap.called_any(), "callback agent (β _cmd_do)")
|
| 294 |
+
|
| 295 |
+
def test_handle_callback_autofix(self):
|
| 296 |
+
with TGCapture(_mon, _ai, _cb) as cap:
|
| 297 |
+
run(_cb._handle_callback(self._make_cb("tgw_autofix"), token="fake"))
|
| 298 |
+
self.assertTrue(cap.called_any(), "callback tgw_autofix")
|
| 299 |
+
|
| 300 |
+
def test_handle_callback_bench(self):
|
| 301 |
+
with TGCapture(_mon, _ai, _cb) as cap:
|
| 302 |
+
run(_cb._handle_callback(self._make_cb("tgw_bench"), token="fake"))
|
| 303 |
+
self.assertTrue(cap.called_any(), "callback tgw_bench")
|
| 304 |
+
|
| 305 |
+
def test_handle_callback_score(self):
|
| 306 |
+
with TGCapture(_mon, _ai, _cb) as cap:
|
| 307 |
+
run(_cb._handle_callback(self._make_cb("tgw_score"), token="fake"))
|
| 308 |
+
self.assertTrue(cap.called_any(), "callback tgw_score")
|
| 309 |
+
|
| 310 |
+
def test_handle_callback_improve(self):
|
| 311 |
+
with TGCapture(_mon, _ai, _cb) as cap:
|
| 312 |
+
run(_cb._handle_callback(self._make_cb("tgw_improve"), token="fake"))
|
| 313 |
+
self.assertTrue(cap.called_any(), "callback tgw_improve")
|
| 314 |
+
|
| 315 |
+
def test_handle_callback_retry(self):
|
| 316 |
+
"""tgw_retry rilegge _LAST_GOAL β deve funzionare anche se vuoto."""
|
| 317 |
+
with TGCapture(_mon, _ai, _cb) as cap:
|
| 318 |
+
run(_cb._handle_callback(self._make_cb("tgw_retry"), token="fake"))
|
| 319 |
+
self.assertTrue(cap.called_any(), "callback tgw_retry")
|
| 320 |
+
|
| 321 |
+
def test_handle_callback_qp_bug(self):
|
| 322 |
+
"""quick-pick qp_bug lancia _cmd_do con goal preimpostato."""
|
| 323 |
+
with TGCapture(_mon, _ai, _cb) as cap:
|
| 324 |
+
run(_cb._handle_callback(self._make_cb("qp_bug"), token="fake"))
|
| 325 |
+
self.assertTrue(cap.called_any(), "callback qp_bug")
|
| 326 |
+
|
| 327 |
+
def test_handle_inline_empty(self):
|
| 328 |
+
"""Inline query vuota β risponde con lista comandi."""
|
| 329 |
+
iq = {"id": "iq_001", "from": {"id": CHAT_ID}, "query": ""}
|
| 330 |
+
with TGCapture(_cb) as cap:
|
| 331 |
+
run(_cb._handle_inline(iq, token="fake"))
|
| 332 |
+
# La risposta va a answerInlineQuery β httpx Γ¨ stubbato, non fallisce
|
| 333 |
+
|
| 334 |
+
def test_handle_inline_with_query(self):
|
| 335 |
+
iq = {"id": "iq_002", "from": {"id": CHAT_ID}, "query": "analizza bug login"}
|
| 336 |
+
with TGCapture(_cb):
|
| 337 |
+
run(_cb._handle_inline(iq, token="fake"))
|
| 338 |
+
|
| 339 |
+
# ββ Guardrail: nessun messaggio reale inviato ββββββββββββββββββββββββββ
|
| 340 |
+
def test_no_real_http_calls(self):
|
| 341 |
+
"""Verifica che httpx.AsyncClient.post non chiami api.telegram.org reale."""
|
| 342 |
+
real_calls: list[str] = []
|
| 343 |
+
original_post = _FakeAsyncClient.post
|
| 344 |
+
async def spy_post(self, url, **kw):
|
| 345 |
+
real_calls.append(url)
|
| 346 |
+
return _FakeResponse()
|
| 347 |
+
_FakeAsyncClient.post = spy_post
|
| 348 |
+
try:
|
| 349 |
+
with TGCapture(_mon):
|
| 350 |
+
run(_mon._cmd_help(CHAT_ID))
|
| 351 |
+
# Se arriva qui senza errori di rete β ok (le _tg_* erano moccate,
|
| 352 |
+
# quindi httpx.post non Γ¨ stato chiamato direttamente)
|
| 353 |
+
for url in real_calls:
|
| 354 |
+
self.assertNotIn("api.telegram.org", url,
|
| 355 |
+
f"Chiamata reale a api.telegram.org intercettata: {url}")
|
| 356 |
+
finally:
|
| 357 |
+
_FakeAsyncClient.post = original_post
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
if __name__ == "__main__":
|
| 361 |
+
import pathlib
|
| 362 |
+
backend = pathlib.Path(__file__).parent.parent
|
| 363 |
+
if str(backend) not in sys.path:
|
| 364 |
+
sys.path.insert(0, str(backend))
|
| 365 |
+
runner = unittest.TextTestRunner(verbosity=2)
|
| 366 |
+
result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestTelegramCommands))
|
| 367 |
+
sys.exit(0 if result.wasSuccessful() else 1)
|
tests/test_telegram_imports.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""tests/test_telegram_imports.py β Smoke test import chain moduli M2 split.
|
| 3 |
+
|
| 4 |
+
Verifica che tutti i 6 moduli Telegram si importino correttamente senza
|
| 5 |
+
avviare FastAPI nΓ© connettersi a Telegram. Eseguibile in CI senza env vars.
|
| 6 |
+
|
| 7 |
+
Uso:
|
| 8 |
+
cd backend && python -m pytest tests/test_telegram_imports.py -v
|
| 9 |
+
# oppure direttamente:
|
| 10 |
+
cd backend && python tests/test_telegram_imports.py
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
import importlib, sys, types, unittest
|
| 14 |
+
|
| 15 |
+
# ββ Stub dipendenze esterne non disponibili in CI βββββββββββββββββββββββββββββ
|
| 16 |
+
def _stub(name: str, **attrs) -> types.ModuleType:
|
| 17 |
+
m = types.ModuleType(name)
|
| 18 |
+
for k, v in attrs.items():
|
| 19 |
+
setattr(m, k, v)
|
| 20 |
+
sys.modules[name] = m
|
| 21 |
+
return m
|
| 22 |
+
|
| 23 |
+
# fastapi
|
| 24 |
+
_fa = _stub("fastapi")
|
| 25 |
+
_fa.APIRouter = type("APIRouter", (), {"__init__": lambda self, **kw: None,
|
| 26 |
+
"post": lambda self, *a, **kw: (lambda f: f),
|
| 27 |
+
"get": lambda self, *a, **kw: (lambda f: f)})
|
| 28 |
+
_fa.Request = type("Request", (), {})
|
| 29 |
+
_fa.HTTPException = type("HTTPException", (Exception,), {})
|
| 30 |
+
_fa.BackgroundTasks = type("BackgroundTasks", (), {})
|
| 31 |
+
_stub("fastapi.responses", JSONResponse=type("JSONResponse", (), {}))
|
| 32 |
+
_stub("fastapi.routing")
|
| 33 |
+
_stub("starlette.requests", Request=_fa.Request)
|
| 34 |
+
|
| 35 |
+
# pydantic
|
| 36 |
+
_stub("pydantic", BaseModel=type("BaseModel", (), {}))
|
| 37 |
+
|
| 38 |
+
# httpx
|
| 39 |
+
_httpx = _stub("httpx")
|
| 40 |
+
_httpx.AsyncClient = type("AsyncClient", (), {
|
| 41 |
+
"__aenter__": lambda self: self, "__aexit__": lambda *a: None,
|
| 42 |
+
"post": lambda self, *a, **kw: None,
|
| 43 |
+
})
|
| 44 |
+
_stub("httpx._models")
|
| 45 |
+
|
| 46 |
+
# httpcore, anyio (transitive deps di httpx)
|
| 47 |
+
_stub("httpcore")
|
| 48 |
+
_stub("anyio")
|
| 49 |
+
|
| 50 |
+
# Altre dipendenze del backend usate dalle funzioni TG
|
| 51 |
+
for _mod in [
|
| 52 |
+
"api.agent", "api.providers", "api.state", "api.unified_api",
|
| 53 |
+
"api.structured_log", "api.supabase_client", "api.exec",
|
| 54 |
+
"agents.unified_loop_fallback", "agents.goal_verifier",
|
| 55 |
+
"agents.memory_manager", "agents.audit_semantic_l2",
|
| 56 |
+
]:
|
| 57 |
+
_stub(_mod)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ββ Test ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
+
class TestTelegramImportChain(unittest.TestCase):
|
| 62 |
+
|
| 63 |
+
def _import(self, modname: str) -> types.ModuleType:
|
| 64 |
+
"""Importa (o re-importa) un modulo dal package api."""
|
| 65 |
+
full = f"api.{modname}"
|
| 66 |
+
if full in sys.modules:
|
| 67 |
+
return sys.modules[full]
|
| 68 |
+
return importlib.import_module(full)
|
| 69 |
+
|
| 70 |
+
# ββ Layer 0 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
def test_01_tg_client(self):
|
| 72 |
+
"""telegram_tg_client β nessuna dipendenza interna."""
|
| 73 |
+
m = self._import("telegram_tg_client")
|
| 74 |
+
for sym in ("_get_bot_token", "_tg_reply", "_tg_send", "_tg_edit",
|
| 75 |
+
"_tg_photo", "_tg_typing", "_tg_react",
|
| 76 |
+
"_tg_answer_callback", "_fmt_elapsed", "_log_tg_exc"):
|
| 77 |
+
self.assertTrue(hasattr(m, sym), f"Manca {sym} in tg_client")
|
| 78 |
+
|
| 79 |
+
# ββ Layer 1 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 80 |
+
def test_02_keyboards(self):
|
| 81 |
+
"""telegram_keyboards β solo costanti/dict."""
|
| 82 |
+
m = self._import("telegram_keyboards")
|
| 83 |
+
for sym in ("_MAIN_KB", "_QUICK_PICK_KB", "_after_task_kb",
|
| 84 |
+
"_LAST_GOAL", "_BENCH_CACHE"):
|
| 85 |
+
self.assertTrue(hasattr(m, sym), f"Manca {sym} in keyboards")
|
| 86 |
+
|
| 87 |
+
# ββ Layer 2 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 88 |
+
def test_03_cmd_monitoring(self):
|
| 89 |
+
"""telegram_cmd_monitoring β importa da tg_client + keyboards."""
|
| 90 |
+
m = self._import("telegram_cmd_monitoring")
|
| 91 |
+
for sym in ("_cmd_help", "_cmd_logs", "_cmd_status",
|
| 92 |
+
"_cmd_commit_summary", "_cmd_check", "_cmd_tasks"):
|
| 93 |
+
self.assertTrue(hasattr(m, sym), f"Manca {sym} in cmd_monitoring")
|
| 94 |
+
|
| 95 |
+
def test_04_cmd_ai(self):
|
| 96 |
+
"""telegram_cmd_ai β importa da tg_client + keyboards."""
|
| 97 |
+
m = self._import("telegram_cmd_ai")
|
| 98 |
+
for sym in ("_cmd_do", "_cmd_autofix", "_cmd_nota", "_cmd_cerca",
|
| 99 |
+
"_cmd_meteo", "_cmd_riepilogo", "_cmd_score", "_cmd_bench",
|
| 100 |
+
"_cmd_improve", "_cmd_git", "_cmd_coord",
|
| 101 |
+
"_cmd_scan_now", "_cmd_telemetry"):
|
| 102 |
+
self.assertTrue(hasattr(m, sym), f"Manca {sym} in cmd_ai")
|
| 103 |
+
|
| 104 |
+
# ββ Layer 3 ββββββββββββββββββββββοΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
def test_05_callbacks(self):
|
| 106 |
+
"""telegram_callbacks β importa da tg_client + keyboards + cmd_ai + cmd_monitoring."""
|
| 107 |
+
m = self._import("telegram_callbacks")
|
| 108 |
+
for sym in ("_handle_inline", "_handle_callback"):
|
| 109 |
+
self.assertTrue(hasattr(m, sym), f"Manca {sym} in callbacks")
|
| 110 |
+
|
| 111 |
+
# ββ Layer 4 (router) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
+
def test_06_webhook(self):
|
| 113 |
+
"""telegram_webhook β router puro, importa da tutti i moduli."""
|
| 114 |
+
m = self._import("telegram_webhook")
|
| 115 |
+
self.assertTrue(hasattr(m, "router"),
|
| 116 |
+
"Manca 'router' in telegram_webhook")
|
| 117 |
+
|
| 118 |
+
# ββ Import chain completo βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
+
def test_07_no_circular_imports(self):
|
| 120 |
+
"""Nessun import circolare: webhook importa callbacks che NON importa webhook."""
|
| 121 |
+
cb = sys.modules.get("api.telegram_callbacks")
|
| 122 |
+
wh = sys.modules.get("api.telegram_webhook")
|
| 123 |
+
if cb and wh:
|
| 124 |
+
# callbacks non deve avere 'telegram_webhook' come dipendenza diretta
|
| 125 |
+
cb_src = getattr(cb, "__file__", "")
|
| 126 |
+
import ast, pathlib
|
| 127 |
+
if cb_src and pathlib.Path(cb_src).exists():
|
| 128 |
+
tree = ast.parse(pathlib.Path(cb_src).read_text())
|
| 129 |
+
for node in ast.walk(tree):
|
| 130 |
+
if isinstance(node, ast.ImportFrom):
|
| 131 |
+
self.assertNotIn("telegram_webhook", (node.module or ""),
|
| 132 |
+
"Import circolare: callbacks β webhook")
|
| 133 |
+
|
| 134 |
+
def test_08_main_kb_in_keyboards_not_tg_client(self):
|
| 135 |
+
"""_MAIN_KB deve stare in keyboards, NON in tg_client."""
|
| 136 |
+
tc = self._import("telegram_tg_client")
|
| 137 |
+
kb = self._import("telegram_keyboards")
|
| 138 |
+
self.assertFalse(hasattr(tc, "_MAIN_KB"),
|
| 139 |
+
"_MAIN_KB non deve essere in tg_client")
|
| 140 |
+
self.assertTrue(hasattr(kb, "_MAIN_KB"),
|
| 141 |
+
"_MAIN_KB deve essere in keyboards")
|
| 142 |
+
|
| 143 |
+
def test_09_scan_now_in_cmd_ai(self):
|
| 144 |
+
"""_cmd_scan_now/_cmd_git/_cmd_coord/_cmd_telemetry devono stare in cmd_ai."""
|
| 145 |
+
ai = self._import("telegram_cmd_ai")
|
| 146 |
+
mon = self._import("telegram_cmd_monitoring")
|
| 147 |
+
for sym in ("_cmd_scan_now", "_cmd_git", "_cmd_coord", "_cmd_telemetry"):
|
| 148 |
+
self.assertTrue(hasattr(ai, sym),
|
| 149 |
+
f"{sym} deve essere in cmd_ai")
|
| 150 |
+
self.assertFalse(hasattr(mon, sym),
|
| 151 |
+
f"{sym} NON deve essere in cmd_monitoring")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
# Aggiungi backend/ al path se eseguito da root
|
| 156 |
+
import os, pathlib
|
| 157 |
+
backend = pathlib.Path(__file__).parent.parent
|
| 158 |
+
if str(backend) not in sys.path:
|
| 159 |
+
sys.path.insert(0, str(backend))
|
| 160 |
+
runner = unittest.TextTestRunner(verbosity=2)
|
| 161 |
+
result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestTelegramImportChain))
|
| 162 |
+
sys.exit(0 if result.wasSuccessful() else 1)
|
tools/registry.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
"""
|
| 2 |
registry.py β Tool Registry
|
| 3 |
Ogni tool ha: goal, required_inputs, risk_level, fallbacks, _fn.
|
|
@@ -63,6 +64,7 @@ from tools.registry_web import (
|
|
| 63 |
from tools.registry_fs import (
|
| 64 |
_read_file, _write_file, _ts_syntax_check, _apply_patch, _execute_shell,
|
| 65 |
_web_research, _send_email, _database_query, _call_api, _execute_sql, _create_pdf,
|
|
|
|
| 66 |
)
|
| 67 |
from tools.registry_dev import (
|
| 68 |
_directory_tree, _file_search, _git_status, _git_clone, _git_diff,
|
|
@@ -689,7 +691,17 @@ TOOL_REGISTRY: dict[str, dict] = {
|
|
| 689 |
"P30-B1: Analisi statica Python in-process β zero exec_engine, <5ms. "
|
| 690 |
"code: stringa Python (obbligatorio). filename: nome file per errori (default '<string>'). "
|
| 691 |
"Ritorna: syntax_ok (bool), errors[] (type/message/line/col), "
|
| 692 |
-
"complexity{total_lines/functions/classes/imports/max_nesting},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
"read_file_range": {
|
| 694 |
"name": "read_file_range",
|
| 695 |
"goal": "Legge un range specifico di righe da un file (ottimizzato per file grandi)",
|
|
@@ -731,15 +743,4 @@ TOOL_REGISTRY: dict[str, dict] = {
|
|
| 731 |
"fallbacks": [],
|
| 732 |
"_fn": _file_metadata,
|
| 733 |
},
|
| 734 |
-
, "
|
| 735 |
-
"suggestions[] (max 5 actionable), summary (stringa leggibile). "
|
| 736 |
-
"Usare per: verificare sintassi prima di exec, analizzare qualitΓ codice, "
|
| 737 |
-
"ottenere metriche strutturali, suggerire refactoring."
|
| 738 |
-
),
|
| 739 |
-
"required_inputs": ["code"],
|
| 740 |
-
"optional_inputs": {"filename": "<string>", "content": ""}, # GAP-1: alias per 'code' (frontend compat)
|
| 741 |
-
"risk_level": "low",
|
| 742 |
-
"fallbacks": [],
|
| 743 |
-
"_fn": _python_analyze,
|
| 744 |
-
},
|
| 745 |
}
|
|
|
|
| 1 |
+
# sync: 2026-07-02T21:03
|
| 2 |
"""
|
| 3 |
registry.py β Tool Registry
|
| 4 |
Ogni tool ha: goal, required_inputs, risk_level, fallbacks, _fn.
|
|
|
|
| 64 |
from tools.registry_fs import (
|
| 65 |
_read_file, _write_file, _ts_syntax_check, _apply_patch, _execute_shell,
|
| 66 |
_web_research, _send_email, _database_query, _call_api, _execute_sql, _create_pdf,
|
| 67 |
+
_read_file_range, _grep_file, _file_metadata,
|
| 68 |
)
|
| 69 |
from tools.registry_dev import (
|
| 70 |
_directory_tree, _file_search, _git_status, _git_clone, _git_diff,
|
|
|
|
| 691 |
"P30-B1: Analisi statica Python in-process β zero exec_engine, <5ms. "
|
| 692 |
"code: stringa Python (obbligatorio). filename: nome file per errori (default '<string>'). "
|
| 693 |
"Ritorna: syntax_ok (bool), errors[] (type/message/line/col), "
|
| 694 |
+
"complexity{total_lines/functions/classes/imports/max_nesting}, "
|
| 695 |
+
"suggestions[] (max 5 actionable), summary (stringa leggibile). "
|
| 696 |
+
"Usare per: verificare sintassi prima di exec, analizzare qualitΓ codice, "
|
| 697 |
+
"ottenere metriche strutturali, suggerire refactoring."
|
| 698 |
+
),
|
| 699 |
+
"required_inputs": ["code"],
|
| 700 |
+
"optional_inputs": {"filename": "<string>", "content": ""}, # GAP-1: alias per 'code' (frontend compat)
|
| 701 |
+
"risk_level": "low",
|
| 702 |
+
"fallbacks": [],
|
| 703 |
+
"_fn": _python_analyze,
|
| 704 |
+
},
|
| 705 |
"read_file_range": {
|
| 706 |
"name": "read_file_range",
|
| 707 |
"goal": "Legge un range specifico di righe da un file (ottimizzato per file grandi)",
|
|
|
|
| 743 |
"fallbacks": [],
|
| 744 |
"_fn": _file_metadata,
|
| 745 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
}
|
tools/registry_fs.py
CHANGED
|
@@ -30,7 +30,7 @@ import pathlib
|
|
| 30 |
import itertools
|
| 31 |
|
| 32 |
async def _read_file_range(path: str, start_line: int = 1, end_line: int = 100, encoding: str = "utf-8") -> dict:
|
| 33 |
-
|
| 34 |
try:
|
| 35 |
_p = pathlib.Path(path)
|
| 36 |
if not _p.exists():
|
|
@@ -53,7 +53,7 @@ async def _read_file_range(path: str, start_line: int = 1, end_line: int = 100,
|
|
| 53 |
return {"ok": False, "error": str(exc)}
|
| 54 |
|
| 55 |
async def _grep_file(path: str, pattern: str, max_results: int = 20, encoding: str = "utf-8") -> dict:
|
| 56 |
-
|
| 57 |
try:
|
| 58 |
_p = pathlib.Path(path)
|
| 59 |
if not _p.exists():
|
|
@@ -79,7 +79,7 @@ async def _grep_file(path: str, pattern: str, max_results: int = 20, encoding: s
|
|
| 79 |
return {"ok": False, "error": str(exc)}
|
| 80 |
|
| 81 |
async def _file_metadata(path: str) -> dict:
|
| 82 |
-
|
| 83 |
try:
|
| 84 |
_p = pathlib.Path(path)
|
| 85 |
if not _p.exists():
|
|
|
|
| 30 |
import itertools
|
| 31 |
|
| 32 |
async def _read_file_range(path: str, start_line: int = 1, end_line: int = 100, encoding: str = "utf-8") -> dict:
|
| 33 |
+
"""P42: Legge un range specifico di righe da un file (ottimizzato per file grandi)."""
|
| 34 |
try:
|
| 35 |
_p = pathlib.Path(path)
|
| 36 |
if not _p.exists():
|
|
|
|
| 53 |
return {"ok": False, "error": str(exc)}
|
| 54 |
|
| 55 |
async def _grep_file(path: str, pattern: str, max_results: int = 20, encoding: str = "utf-8") -> dict:
|
| 56 |
+
"""P42: Cerca un pattern in un file senza caricarlo interamente in RAM."""
|
| 57 |
try:
|
| 58 |
_p = pathlib.Path(path)
|
| 59 |
if not _p.exists():
|
|
|
|
| 79 |
return {"ok": False, "error": str(exc)}
|
| 80 |
|
| 81 |
async def _file_metadata(path: str) -> dict:
|
| 82 |
+
"""P42: Ottiene metadati del file (dimensione, numero righe) senza leggere il contenuto."""
|
| 83 |
try:
|
| 84 |
_p = pathlib.Path(path)
|
| 85 |
if not _p.exists():
|