Spaces:
Running
Running
sync: 171 file da Baida98/AI@9b697a97 (2026-07-05 14:21 UTC)
Browse files- .env.example +49 -210
- REBUILD_TRIGGER +2 -1
- api/_agent_helpers.py +2 -5
- api/ads_manager.py +50 -0
- api/agent_loop_routes.py +1 -0
- api/browser.py +10 -9
- api/database_router.py +17 -7
- api/email.py +19 -6
- api/exec.py +37 -17
- api/job_queue.py +17 -9
- api/research.py +10 -9
- api/scheduler.py +41 -25
- api/search.py +19 -10
- api/telegram_callbacks.py +33 -3
- api/telegram_cmd_ai.py +151 -1
- api/telegram_cmd_monitoring.py +215 -202
- api/telegram_keyboards.py +137 -83
- api/telegram_webhook.py +52 -27
- main.py +5 -4
- models/ai_client.py +5 -6
- models/role_router.py +201 -86
- requirements.txt +0 -1
.env.example
CHANGED
|
@@ -1,226 +1,65 @@
|
|
|
|
|
| 1 |
# ============================================================
|
| 2 |
-
# .env
|
| 3 |
-
#
|
| 4 |
-
# Per deploy su HF Spaces: aggiungere come Secrets/Variables nelle impostazioni.
|
| 5 |
# ============================================================
|
| 6 |
|
| 7 |
-
# ── Runtime ──────────────────────────────────────────
|
| 8 |
PORT=7860
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
VITE_ENABLE_BROWSER_SANDBOX=false
|
| 14 |
-
VITE_ENABLE_BROWSER_LLM=false
|
| 15 |
-
VITE_ENABLE_LOCAL_ONLY_MODE=false
|
| 16 |
-
|
| 17 |
-
# ── URLs (obbligatori) ────────────────────────────────────────
|
| 18 |
-
# URL pubblico del tuo HF Space
|
| 19 |
-
BACKEND_URL=https://arjanit98-terminal.hf.space # HF Space A (collab A) — usato come BACKEND_URL su Railway
|
| 20 |
-
FRONTEND_URL=https://agente-ai.pages.dev
|
| 21 |
-
HF_SPACE_URL=https://arjanit98-terminal.hf.space # HF Space A. Per collab B: https://baida00-ai-backend-collab.hf.space
|
| 22 |
-
HF_SPACE_ID=Arjanit98/Terminal # HF Space A (collab A). Per collab B: Baida00/ai-backend-collab
|
| 23 |
-
|
| 24 |
-
# ── Vault / Sicurezza (obbligatori) ──────────────────────────
|
| 25 |
-
# Genera con: python3 -c "import secrets; print(secrets.token_hex(32))"
|
| 26 |
-
VAULT_KEY=
|
| 27 |
-
VAULT_ADMIN_TOKEN=
|
| 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 |
-
# ──
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
HF_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct
|
| 69 |
-
|
| 70 |
-
# ── GitHub ────────────────────────────────────────────────────
|
| 71 |
-
# github.com → Settings → Developer settings → Personal access tokens
|
| 72 |
GITHUB_TOKEN=
|
| 73 |
-
|
| 74 |
-
GITHUB_REPOSITORY=Baida98/AI
|
| 75 |
-
GITHUB_REPO=Baida98/AI
|
| 76 |
-
GH_OWNER=Baida98
|
| 77 |
-
GH_REPO=AI
|
| 78 |
-
GITHUB_BRANCH=main
|
| 79 |
-
AGENT_KERNEL_REF=main
|
| 80 |
-
AGENT_KERNEL_MAX_TOKENS=3000
|
| 81 |
-
AGENT_KERNEL_TIMEOUT=90
|
| 82 |
-
AGENT_CONTEXT_FILES=120
|
| 83 |
-
|
| 84 |
-
# ── OpenAI ────────────────────────────────────────────────────
|
| 85 |
-
# platform.openai.com/api-keys
|
| 86 |
-
OPENAI_API_KEY=
|
| 87 |
-
OPENAI_API_BASE=https://api.openai.com/v1
|
| 88 |
-
OPENAI_MODEL=gpt-4o-mini
|
| 89 |
|
| 90 |
-
# ──
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
OPENROUTER_API_KEY=
|
| 93 |
-
OPENROUTER_MODEL=openai/gpt-oss-20b:free
|
| 94 |
-
|
| 95 |
-
# ── Gemini ────────────────────────────────────────────────────
|
| 96 |
-
# aistudio.google.com/app/apikey
|
| 97 |
GEMINI_API_KEY=
|
| 98 |
-
GEMINI_MODEL=gemini-2.5-flash-lite
|
| 99 |
-
|
| 100 |
-
# ── Groq ─────────────────────────────────────────────────────
|
| 101 |
-
# console.groq.com/keys
|
| 102 |
-
GROQ_API_KEY=
|
| 103 |
-
GROQ_API_KEY_B=
|
| 104 |
-
GROQ_MODEL=llama-3.3-70b-versatile
|
| 105 |
-
|
| 106 |
-
# ── Cerebras ──────────────────────────────────────────────────
|
| 107 |
-
# cloud.cerebras.ai
|
| 108 |
-
CEREBRAS_API_KEY=
|
| 109 |
-
CEREBRAS_MODEL=gpt-oss-120b
|
| 110 |
-
|
| 111 |
-
# ── SambaNova ─────────────────────────────────────────────────
|
| 112 |
-
# cloud.sambanova.ai
|
| 113 |
-
SAMBANOVA_API_KEY=
|
| 114 |
-
SAMBANOVA_MODEL=DeepSeek-V3.1
|
| 115 |
-
|
| 116 |
-
# ── NVIDIA NIM ─────────────────────────────────────────────────
|
| 117 |
-
# build.nvidia.com → Get API Key (gratuito, no carta di credito)
|
| 118 |
-
# Stessa chiave funziona su integrate.api.nvidia.com/v1 (OpenAI-compatible)
|
| 119 |
NVIDIA_API_KEY=
|
| 120 |
-
|
| 121 |
-
# Key B — secondo account NIM, raddoppia il rate-limit (30→60 RPM)
|
| 122 |
-
NVIDIA_API_KEY_B=
|
| 123 |
-
NVIDIA_B_MODEL=meta/llama-3.3-70b-instruct
|
| 124 |
-
# DISABLE_NVIDIA_B=1
|
| 125 |
-
|
| 126 |
-
# ── LLM Routing ───────────────────────────────────────────────
|
| 127 |
-
LLM_MODEL=deepseek/deepseek-r1:free
|
| 128 |
-
SMOLAGENTS_MODEL=deepseek/deepseek-r1:free
|
| 129 |
-
UNIFIED_LOOP_MAX_STEPS=8
|
| 130 |
-
|
| 131 |
-
# ── Telegram ─────────────────────────────────────────────────
|
| 132 |
-
# @BotFather su Telegram per i token bot
|
| 133 |
-
# @userinfobot per il tuo chat ID
|
| 134 |
-
TELEGRAM_BOT_TOKEN=
|
| 135 |
-
TELEGRAM_CHAT_ID=
|
| 136 |
-
|
| 137 |
-
# ── Cloudflare ────────────────────────────────────────────────
|
| 138 |
-
# dash.cloudflare.com → Profile → API Tokens
|
| 139 |
-
CF_API_TOKEN=
|
| 140 |
-
CLOUDFLARE_API_TOKEN=
|
| 141 |
-
CF_ACCOUNT_ID=
|
| 142 |
-
|
| 143 |
-
# ── Railway ───────────────────────────────────────────────────
|
| 144 |
-
# railway.app → Account Settings → Tokens
|
| 145 |
-
RAILWAY_TOKEN=
|
| 146 |
-
RAILWAY_URL=https://railway.app
|
| 147 |
|
| 148 |
-
# ──
|
| 149 |
-
# e2b.dev/dashboard
|
| 150 |
E2B_API_KEY=
|
| 151 |
-
|
| 152 |
-
# ── Notion ────────────────────────────────────────────────────
|
| 153 |
-
# notion.so/my-integrations
|
| 154 |
NOTION_TOKEN=
|
| 155 |
-
|
| 156 |
-
|
| 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=
|
| 176 |
-
QDRANT_API_KEY=
|
| 177 |
-
# Jina AI (web reader avanzato — jina.ai/api-key)
|
| 178 |
-
JINA_API_KEY=
|
| 179 |
-
# Tavily (web search — tavily.com)
|
| 180 |
-
TAVILY_API_KEY=
|
| 181 |
-
# Brave Search
|
| 182 |
-
BRAVE_SEARCH_API_KEY=
|
| 183 |
-
# Resend (email — resend.com)
|
| 184 |
-
RESEND_API_KEY=
|
| 185 |
-
RESEND_FROM_EMAIL=
|
| 186 |
-
# Upstash Redis
|
| 187 |
UPSTASH_REDIS_REST_URL=
|
| 188 |
UPSTASH_REDIS_REST_TOKEN=
|
| 189 |
-
# Pexels / Pixabay (immagini)
|
| 190 |
-
PEXELS_API_KEY=
|
| 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 |
-
#
|
| 201 |
-
|
| 202 |
-
|
| 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
|
|
|
|
| 1 |
+
# 🧠 Agente AI — Template .env Pulito (A-E)
|
| 2 |
# ============================================================
|
| 3 |
+
# Copiare in .env per uso locale. NON committare valori reali.
|
| 4 |
+
# Struttura ottimizzata per Quadranti A, B, C, D, E.
|
|
|
|
| 5 |
# ============================================================
|
| 6 |
|
| 7 |
+
# ── 1. Core Runtime ──────────────────────────────────────────
|
| 8 |
PORT=7860
|
| 9 |
+
APP_PROFILE=production_kernel
|
| 10 |
+
INTERNAL_TOKEN= # Bridge HF ↔ CF
|
| 11 |
+
VAULT_KEY= # AES-256 Hex
|
| 12 |
+
NOTIFY_TOKEN= # Notifiche Interne
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
# ── 2. Quadrante A (BRAIN - Primary) ─────────────────────────
|
| 15 |
+
BACKEND_URL=https://arjanit98-terminal.hf.space
|
| 16 |
+
RAILWAY_TOKEN=
|
| 17 |
+
RAILWAY_PROJECT_ID=a9ce05f8-aeca-46c1-837b-8c2ca7a11081
|
| 18 |
+
SUPABASE_URL=
|
| 19 |
+
SUPABASE_SERVICE_ROLE_KEY=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
GITHUB_TOKEN=
|
| 21 |
+
HF_TOKEN=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
# ── 3. Quadrante B (HANDS - Collab/Failover) ─────────────────
|
| 24 |
+
RAILWAY_TOKEN_B=
|
| 25 |
+
RAILWAY_PROJECT_ID_B=51c7f764-a8ca-4dff-b3cd-d91116e09d8a
|
| 26 |
+
SUPABASE_URL_B=
|
| 27 |
+
SUPABASE_SERVICE_ROLE_KEY_B=
|
| 28 |
+
GITHUB_TOKEN_B=
|
| 29 |
+
|
| 30 |
+
# ── 4. Quadrante C (DAEMON - Telegram) ───────────────────────
|
| 31 |
+
RAILWAY_TOKEN_C=
|
| 32 |
+
RAILWAY_PROJECT_ID_C=d8843346-7c6a-48f1-adb3-0fd4a650b3e5
|
| 33 |
+
SUPABASE_URL_C=
|
| 34 |
+
SUPABASE_SERVICE_ROLE_KEY_C=
|
| 35 |
+
|
| 36 |
+
# ── 5. Quadrante D (AUDIT - Compliance) ──────────────────────
|
| 37 |
+
RAILWAY_TOKEN_D=
|
| 38 |
+
RAILWAY_PROJECT_ID_D=898b1c3e-6e64-4c5a-9609-afdd0dce84f8
|
| 39 |
+
SUPABASE_URL_D=
|
| 40 |
+
SUPABASE_SERVICE_ROLE_KEY_D=
|
| 41 |
+
|
| 42 |
+
# ── 6. Quadrante E (BOT-TG - Dedicated) ──────────────────────
|
| 43 |
+
RAILWAY_TOKEN_E=
|
| 44 |
+
RAILWAY_PROJECT_ID_E=0834551e-51c8-4eff-aa4f-65c0b04ea933
|
| 45 |
+
|
| 46 |
+
# ── 7. LLM Unified Providers (A-E) ───────────────────────────
|
| 47 |
+
# Configurare nei Secrets del provider hosting (HF/Railway)
|
| 48 |
+
GROQ_API_KEY=
|
| 49 |
OPENROUTER_API_KEY=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
GEMINI_API_KEY=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
NVIDIA_API_KEY=
|
| 52 |
+
OPENAI_API_KEY=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
# ── 8. Sandboxes & Tools ─────────────────────────────────────
|
|
|
|
| 55 |
E2B_API_KEY=
|
|
|
|
|
|
|
|
|
|
| 56 |
NOTION_TOKEN=
|
| 57 |
+
TELEGRAM_BOT_TOKEN=
|
| 58 |
+
TELEGRAM_CHAT_ID=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
UPSTASH_REDIS_REST_URL=
|
| 60 |
UPSTASH_REDIS_REST_TOKEN=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
+
# ── 9. Feature Flags ─────────────────────────────────────────
|
| 63 |
+
VITE_ENABLE_BROWSER_SANDBOX=false
|
| 64 |
+
UNIFIED_LOOP_MAX_STEPS=8
|
| 65 |
+
LLM_MODEL=deepseek/deepseek-r1:free
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
REBUILD_TRIGGER
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
-
|
|
|
|
|
|
| 1 |
+
Rebuild trigger — 2026-07-03T13:39:25.801Z
|
| 2 |
+
Fix: rootDirectory corretto da /backend a backend (Railway backend service)
|
api/_agent_helpers.py
CHANGED
|
@@ -45,11 +45,8 @@ except Exception:
|
|
| 45 |
|
| 46 |
router = APIRouter()
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
"""Deprecated — use /agent/task instead."""
|
| 51 |
-
from fastapi.responses import JSONResponse
|
| 52 |
-
return JSONResponse(status_code=410, content={"detail": {"error": "Gone", "migration": "/api/agent/tasks"}})
|
| 53 |
|
| 54 |
|
| 55 |
# ─── P17-F5: Persona helpers ──────────────────────────────────────────────────
|
|
|
|
| 45 |
|
| 46 |
router = APIRouter()
|
| 47 |
|
| 48 |
+
# DEP-11: /run_loop route rimossa (era stub 410 che attraversava CORS+rate-limiter+auth inutilmente).
|
| 49 |
+
# Migrazione client → /api/agent/tasks ; CF Worker redirect se necessario.
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
# ─── P17-F5: Persona helpers ──────────────────────────────────────────────────
|
api/ads_manager.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sqlite3
|
| 3 |
+
import logging
|
| 4 |
+
from fastapi import APIRouter, HTTPException
|
| 5 |
+
from typing import List, Dict, Any
|
| 6 |
+
|
| 7 |
+
_logger = logging.getLogger("ads_manager")
|
| 8 |
+
router = APIRouter(prefix="/api/ads", tags=["ads"])
|
| 9 |
+
|
| 10 |
+
DB_PATH = os.getenv("ADS_DB_PATH", "/app/data/ads/ads-client.db")
|
| 11 |
+
|
| 12 |
+
def get_db_connection():
|
| 13 |
+
if not os.path.exists(DB_PATH):
|
| 14 |
+
_logger.warning(f"Database Ads non trovato in {DB_PATH}")
|
| 15 |
+
return None
|
| 16 |
+
try:
|
| 17 |
+
conn = sqlite3.connect(DB_PATH)
|
| 18 |
+
conn.row_factory = sqlite3.Row
|
| 19 |
+
return conn
|
| 20 |
+
except Exception as e:
|
| 21 |
+
_logger.error(f"Errore connessione DB Ads: {e}")
|
| 22 |
+
return None
|
| 23 |
+
|
| 24 |
+
@router.get("/health")
|
| 25 |
+
async def ads_health():
|
| 26 |
+
conn = get_db_connection()
|
| 27 |
+
if not conn:
|
| 28 |
+
return {"status": "error", "message": "Database non disponibile"}
|
| 29 |
+
try:
|
| 30 |
+
cursor = conn.cursor()
|
| 31 |
+
cursor.execute("SELECT count(*) FROM http_cache")
|
| 32 |
+
count = cursor.fetchone()[0]
|
| 33 |
+
conn.close()
|
| 34 |
+
return {"status": "ok", "record_count": count}
|
| 35 |
+
except Exception as e:
|
| 36 |
+
return {"status": "error", "message": str(e)}
|
| 37 |
+
|
| 38 |
+
@router.get("/cache")
|
| 39 |
+
async def get_ads_cache(limit: int = 10):
|
| 40 |
+
conn = get_db_connection()
|
| 41 |
+
if not conn:
|
| 42 |
+
raise HTTPException(status_code=503, detail="Database Ads non disponibile")
|
| 43 |
+
try:
|
| 44 |
+
cursor = conn.cursor()
|
| 45 |
+
cursor.execute("SELECT * FROM http_cache LIMIT ?", (limit,))
|
| 46 |
+
rows = [dict(row) for row in cursor.fetchall()]
|
| 47 |
+
conn.close()
|
| 48 |
+
return {"ok": True, "data": rows}
|
| 49 |
+
except Exception as e:
|
| 50 |
+
raise HTTPException(status_code=500, detail=str(e))
|
api/agent_loop_routes.py
CHANGED
|
@@ -43,6 +43,7 @@ from ._agent_helpers import (
|
|
| 43 |
_PERSONA_KEYWORD_MAP, _PERSONA_CLIENT_CACHE,
|
| 44 |
_build_persona_kw_map, _classify_persona_server, _get_persona_llm_client,
|
| 45 |
)
|
|
|
|
| 46 |
async def run_loop_removed():
|
| 47 |
"""S352: endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream."""
|
| 48 |
raise HTTPException(
|
|
|
|
| 43 |
_PERSONA_KEYWORD_MAP, _PERSONA_CLIENT_CACHE,
|
| 44 |
_build_persona_kw_map, _classify_persona_server, _get_persona_llm_client,
|
| 45 |
)
|
| 46 |
+
@router.post('/run_loop')
|
| 47 |
async def run_loop_removed():
|
| 48 |
"""S352: endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream."""
|
| 49 |
raise HTTPException(
|
api/browser.py
CHANGED
|
@@ -33,7 +33,8 @@ Problematiche W-NAV anticipate:
|
|
| 33 |
import os
|
| 34 |
import asyncio, base64, hashlib, os, time, uuid, logging
|
| 35 |
from typing import Optional, Any
|
| 36 |
-
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
| 37 |
from pydantic import BaseModel
|
| 38 |
|
| 39 |
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
|
@@ -792,14 +793,14 @@ async def browser_navigate(req: NavigateRequest):
|
|
| 792 |
# ─── /open ────────────────────────────────────────────────────────────────────
|
| 793 |
|
| 794 |
@router.post("/open", response_model=BrowserResult)
|
| 795 |
-
async def browser_open(
|
|
|
|
|
|
|
|
|
|
| 796 |
"""
|
| 797 |
Apre una sessione Playwright persistente, naviga all'URL, restituisce
|
| 798 |
session_id + screenshot + mappa DOM + text_content (trafilatura).
|
| 799 |
"""
|
| 800 |
-
_internal_token = os.getenv('INTERNAL_TOKEN', '')
|
| 801 |
-
if _internal_token and request.headers.get('X-Internal-Token') != _internal_token:
|
| 802 |
-
raise HTTPException(401, 'Unauthorized')
|
| 803 |
if not _safe_url(req.url):
|
| 804 |
raise HTTPException(400, "URL non consentita")
|
| 805 |
|
|
@@ -856,14 +857,14 @@ async def browser_open(req: BrowserOpenRequest, request: Request):
|
|
| 856 |
# ─── /act ─────────────────────────────────────────────────────────────────────
|
| 857 |
|
| 858 |
@router.post("/act", response_model=BrowserResult)
|
| 859 |
-
async def browser_act(
|
|
|
|
|
|
|
|
|
|
| 860 |
"""
|
| 861 |
Esegue azioni su una sessione aperta.
|
| 862 |
Restituisce screenshot + mappa DOM + warnings anti-loop.
|
| 863 |
"""
|
| 864 |
-
_internal_token = os.getenv('INTERNAL_TOKEN', '')
|
| 865 |
-
if _internal_token and request.headers.get('X-Internal-Token') != _internal_token:
|
| 866 |
-
raise HTTPException(401, 'Unauthorized')
|
| 867 |
sess = _sessions.get(req.session_id)
|
| 868 |
if not sess:
|
| 869 |
raise HTTPException(404, f"Sessione {req.session_id} non trovata o scaduta")
|
|
|
|
| 33 |
import os
|
| 34 |
import asyncio, base64, hashlib, os, time, uuid, logging
|
| 35 |
from typing import Optional, Any
|
| 36 |
+
from fastapi import APIRouter, HTTPException, Request, Depends
|
| 37 |
+
from .auth_guard import require_role, AuthRole
|
| 38 |
from pydantic import BaseModel
|
| 39 |
|
| 40 |
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
|
|
|
| 793 |
# ─── /open ────────────────────────────────────────────────────────────────────
|
| 794 |
|
| 795 |
@router.post("/open", response_model=BrowserResult)
|
| 796 |
+
async def browser_open(
|
| 797 |
+
req: BrowserOpenRequest,
|
| 798 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 799 |
+
):
|
| 800 |
"""
|
| 801 |
Apre una sessione Playwright persistente, naviga all'URL, restituisce
|
| 802 |
session_id + screenshot + mappa DOM + text_content (trafilatura).
|
| 803 |
"""
|
|
|
|
|
|
|
|
|
|
| 804 |
if not _safe_url(req.url):
|
| 805 |
raise HTTPException(400, "URL non consentita")
|
| 806 |
|
|
|
|
| 857 |
# ─── /act ─────────────────────────────────────────────────────────────────────
|
| 858 |
|
| 859 |
@router.post("/act", response_model=BrowserResult)
|
| 860 |
+
async def browser_act(
|
| 861 |
+
req: BrowserActRequest,
|
| 862 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 863 |
+
):
|
| 864 |
"""
|
| 865 |
Esegue azioni su una sessione aperta.
|
| 866 |
Restituisce screenshot + mappa DOM + warnings anti-loop.
|
| 867 |
"""
|
|
|
|
|
|
|
|
|
|
| 868 |
sess = _sessions.get(req.session_id)
|
| 869 |
if not sess:
|
| 870 |
raise HTTPException(404, f"Sessione {req.session_id} non trovata o scaduta")
|
api/database_router.py
CHANGED
|
@@ -21,7 +21,8 @@ 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"])
|
|
@@ -174,7 +175,10 @@ def _choose_node(
|
|
| 174 |
|
| 175 |
# ─── Endpoint Principale ──────────────────────────────────────────────────
|
| 176 |
@router.post("/query", response_model=QueryResponse)
|
| 177 |
-
async def database_query(
|
|
|
|
|
|
|
|
|
|
| 178 |
"""
|
| 179 |
Endpoint query con routing intelligente tra nodi Supabase A/B/C/D.
|
| 180 |
|
|
@@ -192,10 +196,6 @@ async def database_query(req: QueryRequest, request: Request):
|
|
| 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)
|
|
@@ -223,8 +223,18 @@ async def database_query(req: QueryRequest, request: Request):
|
|
| 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(
|
|
|
|
| 21 |
import re as _re
|
| 22 |
from typing import Optional, Literal
|
| 23 |
from enum import Enum
|
| 24 |
+
from fastapi import APIRouter, HTTPException, Request, Depends
|
| 25 |
+
from .auth_guard import require_role, AuthRole
|
| 26 |
from pydantic import BaseModel
|
| 27 |
|
| 28 |
router = APIRouter(prefix="/api/database", tags=["database"])
|
|
|
|
| 175 |
|
| 176 |
# ─── Endpoint Principale ──────────────────────────────────────────────────
|
| 177 |
@router.post("/query", response_model=QueryResponse)
|
| 178 |
+
async def database_query(
|
| 179 |
+
req: QueryRequest,
|
| 180 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 181 |
+
):
|
| 182 |
"""
|
| 183 |
Endpoint query con routing intelligente tra nodi Supabase A/B/C/D.
|
| 184 |
|
|
|
|
| 196 |
- truncated: se risultato è stato troncato
|
| 197 |
- node_used: nodo Supabase utilizzato
|
| 198 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
# Rileva tipo query e contesto
|
| 201 |
query_type = _detect_query_type(req.sql)
|
|
|
|
| 223 |
node_used=chosen_node.value,
|
| 224 |
)
|
| 225 |
|
| 226 |
+
# ── GAP-DB-QUERY-PUBLIC fix: forziamo read_only basandoci sul contenuto ──
|
| 227 |
+
# Non ci fidiamo di req.read_only dal client per la sicurezza.
|
| 228 |
+
_sql_upper = req.sql.upper()
|
| 229 |
+
_is_write = any(kw in _sql_upper for kw in ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE"])
|
| 230 |
+
|
| 231 |
+
# Se la query contiene keyword di scrittura, richiediamo esplicitamente permessi OPERATOR o superiore
|
| 232 |
+
# In questo endpoint MACHINE (default) permettiamo solo SELECT.
|
| 233 |
+
if _is_write and role < AuthRole.OPERATOR:
|
| 234 |
+
raise HTTPException(403, "Permessi insufficienti per query di scrittura (richiesto OPERATOR)")
|
| 235 |
+
|
| 236 |
# Verifica read-only
|
| 237 |
+
if req.read_only or not _is_write:
|
| 238 |
kw = _is_dangerous(req.sql)
|
| 239 |
if kw:
|
| 240 |
return QueryResponse(
|
api/email.py
CHANGED
|
@@ -22,7 +22,8 @@ Error handling:
|
|
| 22 |
from __future__ import annotations
|
| 23 |
|
| 24 |
import os, logging, httpx
|
| 25 |
-
from fastapi import APIRouter, Request
|
|
|
|
| 26 |
from pydantic import BaseModel, EmailStr, field_validator
|
| 27 |
from typing import Optional, List
|
| 28 |
|
|
@@ -60,14 +61,14 @@ class SendEmailResponse(BaseModel):
|
|
| 60 |
# ── Endpoint ─────────────────────────────────────────────────────────────────
|
| 61 |
|
| 62 |
@router.post("/send", response_model=SendEmailResponse)
|
| 63 |
-
async def send_email(
|
|
|
|
|
|
|
|
|
|
| 64 |
"""
|
| 65 |
Invia email via Resend API.
|
| 66 |
Richiede RESEND_API_KEY nell'ambiente del backend HF Space.
|
| 67 |
"""
|
| 68 |
-
_internal_token = os.getenv('INTERNAL_TOKEN', '')
|
| 69 |
-
if _internal_token and request.headers.get('X-Internal-Token') != _internal_token:
|
| 70 |
-
raise HTTPException(401, 'Unauthorized')
|
| 71 |
api_key = os.getenv("RESEND_API_KEY", "")
|
| 72 |
if not api_key:
|
| 73 |
_logger.warning("RESEND_API_KEY non configurata")
|
|
@@ -83,8 +84,20 @@ async def send_email(req: SendEmailRequest, request: Request) -> SendEmailRespon
|
|
| 83 |
),
|
| 84 |
)
|
| 85 |
|
| 86 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
from_email = req.from_email or os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
from_field = f"{req.from_name} <{from_email}>" if req.from_name else from_email
|
| 89 |
|
| 90 |
# Payload Resend
|
|
|
|
| 22 |
from __future__ import annotations
|
| 23 |
|
| 24 |
import os, logging, httpx
|
| 25 |
+
from fastapi import APIRouter, Request, Depends, HTTPException
|
| 26 |
+
from .auth_guard import require_role, AuthRole
|
| 27 |
from pydantic import BaseModel, EmailStr, field_validator
|
| 28 |
from typing import Optional, List
|
| 29 |
|
|
|
|
| 61 |
# ── Endpoint ─────────────────────────────────────────────────────────────────
|
| 62 |
|
| 63 |
@router.post("/send", response_model=SendEmailResponse)
|
| 64 |
+
async def send_email(
|
| 65 |
+
req: SendEmailRequest,
|
| 66 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 67 |
+
) -> SendEmailResponse:
|
| 68 |
"""
|
| 69 |
Invia email via Resend API.
|
| 70 |
Richiede RESEND_API_KEY nell'ambiente del backend HF Space.
|
| 71 |
"""
|
|
|
|
|
|
|
|
|
|
| 72 |
api_key = os.getenv("RESEND_API_KEY", "")
|
| 73 |
if not api_key:
|
| 74 |
_logger.warning("RESEND_API_KEY non configurata")
|
|
|
|
| 84 |
),
|
| 85 |
)
|
| 86 |
|
| 87 |
+
# ── GAP-EMAIL-OPENRELAY fix: allowlist domini mittente e validazione ──
|
| 88 |
+
import re as _re_email
|
| 89 |
+
_EMAIL_RE = _re_email.compile(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$')
|
| 90 |
+
if not _EMAIL_RE.match(req.to):
|
| 91 |
+
raise HTTPException(400, "Destinatario non valido")
|
| 92 |
+
|
| 93 |
+
_allowed_domains = {os.getenv("RESEND_DOMAIN", "resend.dev"), "agente-ai.pages.dev"}
|
| 94 |
from_email = req.from_email or os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev")
|
| 95 |
+
_from_domain = from_email.split("@")[-1] if "@" in from_email else ""
|
| 96 |
+
if _from_domain not in _allowed_domains and not from_email.endswith(".resend.dev"):
|
| 97 |
+
# Se il dominio non è in allowlist, forza il mittente di sistema
|
| 98 |
+
from_email = os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev")
|
| 99 |
+
|
| 100 |
+
# Costruisci mittente
|
| 101 |
from_field = f"{req.from_name} <{from_email}>" if req.from_name else from_email
|
| 102 |
|
| 103 |
# Payload Resend
|
api/exec.py
CHANGED
|
@@ -268,23 +268,30 @@ def _ast_sandbox_check(code: str) -> tuple[bool, str]:
|
|
| 268 |
# ── Routes ────────────────────────────────────────────────────────────────────
|
| 269 |
|
| 270 |
@router.post('/api/exec')
|
| 271 |
-
async def exec_code(
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
code = req.code.strip()
|
| 276 |
lang = req.lang.lower()
|
| 277 |
if not code:
|
| 278 |
return {'stdout': '', 'stderr': 'No code', 'exit_code': 1, 'durationMs': 0}
|
| 279 |
|
|
|
|
|
|
|
| 280 |
if lang == 'python':
|
| 281 |
-
_normalized = _re_exec.sub(r'\s+', ' ', code)
|
| 282 |
_blocked_match = _EXEC_BLOCKED_RE.search(_normalized)
|
| 283 |
if _blocked_match:
|
| 284 |
return {'stdout': '', 'stderr': f'Blocked: pattern "{_blocked_match.group()}"', 'exit_code': 1, 'durationMs': 0}
|
| 285 |
_ast_safe, _ast_reason = _ast_sandbox_check(code)
|
| 286 |
if not _ast_safe:
|
| 287 |
return {'stdout': '', 'stderr': f'Blocked (AST): {_ast_reason}', 'exit_code': 1, 'durationMs': 0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
t0 = int(time.time() * 1000)
|
| 290 |
try:
|
|
@@ -347,14 +354,23 @@ async def exec_code(req: ExecRequest, request: Request):
|
|
| 347 |
|
| 348 |
|
| 349 |
@router.post('/api/execute-shell')
|
| 350 |
-
async def execute_shell(
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
raw = cmd.command.strip()
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
timeout = min(max(cmd.timeout, 1), 60)
|
| 359 |
try:
|
| 360 |
async with _realtime_job(timeout_s=90.0):
|
|
@@ -366,7 +382,11 @@ async def execute_shell(cmd: ShellCmd, request: Request):
|
|
| 366 |
stderr=asyncio.subprocess.PIPE,
|
| 367 |
cwd=tmpdir,
|
| 368 |
preexec_fn=_child_resource_limits, # GAP-EXEC-FIX: RLIMIT_AS/CPU/NOFILE/NPROC
|
| 369 |
-
env={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
)
|
| 371 |
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
| 372 |
return {
|
|
@@ -559,11 +579,11 @@ class ToolDispatchRequest(BaseModel):
|
|
| 559 |
|
| 560 |
|
| 561 |
@router.post('/api/exec/tool')
|
| 562 |
-
async def exec_tool_dispatch(
|
|
|
|
|
|
|
|
|
|
| 563 |
"""GAP-2: dispatcher generico — risolve tool nel TOOL_REGISTRY e chiama _fn."""
|
| 564 |
-
_internal_token = os.getenv('INTERNAL_TOKEN', '')
|
| 565 |
-
if _internal_token and request.headers.get('X-Internal-Token') != _internal_token:
|
| 566 |
-
raise HTTPException(401, 'Unauthorized')
|
| 567 |
try:
|
| 568 |
from tools.registry import TOOL_REGISTRY # import locale — evita circular import
|
| 569 |
except ImportError as _ie:
|
|
|
|
| 268 |
# ── Routes ────────────────────────────────────────────────────────────────────
|
| 269 |
|
| 270 |
@router.post('/api/exec')
|
| 271 |
+
async def exec_code(
|
| 272 |
+
req: ExecRequest,
|
| 273 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 274 |
+
):
|
| 275 |
code = req.code.strip()
|
| 276 |
lang = req.lang.lower()
|
| 277 |
if not code:
|
| 278 |
return {'stdout': '', 'stderr': 'No code', 'exit_code': 1, 'durationMs': 0}
|
| 279 |
|
| 280 |
+
# ── GAP-NODE-NOSANDBOX fix: filtri per JS/TS ───────────────────────────
|
| 281 |
+
_normalized = _re_exec.sub(r'\s+', ' ', code)
|
| 282 |
if lang == 'python':
|
|
|
|
| 283 |
_blocked_match = _EXEC_BLOCKED_RE.search(_normalized)
|
| 284 |
if _blocked_match:
|
| 285 |
return {'stdout': '', 'stderr': f'Blocked: pattern "{_blocked_match.group()}"', 'exit_code': 1, 'durationMs': 0}
|
| 286 |
_ast_safe, _ast_reason = _ast_sandbox_check(code)
|
| 287 |
if not _ast_safe:
|
| 288 |
return {'stdout': '', 'stderr': f'Blocked (AST): {_ast_reason}', 'exit_code': 1, 'durationMs': 0}
|
| 289 |
+
elif lang in ('javascript', 'js', 'typescript', 'ts'):
|
| 290 |
+
# Blocca accesso a shell, fs, env e rete in Node.js
|
| 291 |
+
_JS_BLOCKED = _re_exec.compile(r'child_process|process\.env|require\s*\(|import\s+.*from|fs\.|net\.|http')
|
| 292 |
+
_js_match = _JS_BLOCKED.search(_normalized)
|
| 293 |
+
if _js_match:
|
| 294 |
+
return {'stdout': '', 'stderr': f'Blocked (JS/TS): pattern "{_js_match.group()}"', 'exit_code': 1, 'durationMs': 0}
|
| 295 |
|
| 296 |
t0 = int(time.time() * 1000)
|
| 297 |
try:
|
|
|
|
| 354 |
|
| 355 |
|
| 356 |
@router.post('/api/execute-shell')
|
| 357 |
+
async def execute_shell(
|
| 358 |
+
cmd: ShellCmd,
|
| 359 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 360 |
+
):
|
| 361 |
raw = cmd.command.strip()
|
| 362 |
+
# ── GAP-SHELL-NOFILTER fix: Allowlist invece di blocklist minima ─────────
|
| 363 |
+
_SAFE_SHELL = _re_exec.compile(r'^(ls|pwd|date|echo|cat|grep|find|du|df|uptime|ps|top|free|git status|git log|pnpm|npm|pip|python3|node|ts-node)( .*)?$')
|
| 364 |
+
if not _SAFE_SHELL.match(raw):
|
| 365 |
+
# Fallback blocklist per comandi composti o non in lista
|
| 366 |
+
for bad in BLOCKED_CMDS:
|
| 367 |
+
if bad in raw:
|
| 368 |
+
raise HTTPException(400, 'Command blocked for safety')
|
| 369 |
+
# Se non è in allowlist, limitiamo a sola lettura/info
|
| 370 |
+
_DANGEROUS = {'rm ', 'mv ', 'cp ', 'chmod ', 'chown ', 'wget ', 'curl ', '>', '>>', '|'}
|
| 371 |
+
for d in _DANGEROUS:
|
| 372 |
+
if d in raw:
|
| 373 |
+
raise HTTPException(400, f'Command "{d.strip()}" not allowed in this shell')
|
| 374 |
timeout = min(max(cmd.timeout, 1), 60)
|
| 375 |
try:
|
| 376 |
async with _realtime_job(timeout_s=90.0):
|
|
|
|
| 382 |
stderr=asyncio.subprocess.PIPE,
|
| 383 |
cwd=tmpdir,
|
| 384 |
preexec_fn=_child_resource_limits, # GAP-EXEC-FIX: RLIMIT_AS/CPU/NOFILE/NPROC
|
| 385 |
+
env={
|
| 386 |
+
'HOME': tmpdir, 'TMPDIR': tmpdir,
|
| 387 |
+
'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
| 388 |
+
'PYTHONPATH': os.environ.get('PYTHONPATH', ''),
|
| 389 |
+
},
|
| 390 |
)
|
| 391 |
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
| 392 |
return {
|
|
|
|
| 579 |
|
| 580 |
|
| 581 |
@router.post('/api/exec/tool')
|
| 582 |
+
async def exec_tool_dispatch(
|
| 583 |
+
req: ToolDispatchRequest,
|
| 584 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 585 |
+
):
|
| 586 |
"""GAP-2: dispatcher generico — risolve tool nel TOOL_REGISTRY e chiama _fn."""
|
|
|
|
|
|
|
|
|
|
| 587 |
try:
|
| 588 |
from tools.registry import TOOL_REGISTRY # import locale — evita circular import
|
| 589 |
except ImportError as _ie:
|
api/job_queue.py
CHANGED
|
@@ -10,7 +10,8 @@ try:
|
|
| 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
|
|
@@ -258,7 +259,9 @@ async def start_job_queue_consumer() -> None:
|
|
| 258 |
|
| 259 |
# ── FastAPI endpoints ──────────────────────────────────────────────────────────
|
| 260 |
@router.get("/status")
|
| 261 |
-
async def jq_status(
|
|
|
|
|
|
|
| 262 |
return {
|
| 263 |
"space_role": _SPACE_ROLE,
|
| 264 |
"jq_enabled": _JQ_ENABLED,
|
|
@@ -268,7 +271,10 @@ async def jq_status():
|
|
| 268 |
}
|
| 269 |
|
| 270 |
@router.get("/load/{role}")
|
| 271 |
-
async def jq_load(
|
|
|
|
|
|
|
|
|
|
| 272 |
if role not in ("brain", "hands", "memory", "audit"):
|
| 273 |
raise HTTPException(400, "role non valido")
|
| 274 |
res = await _rcmd(["GET", _K_LOAD(role)])
|
|
@@ -280,10 +286,11 @@ async def jq_load(role: str):
|
|
| 280 |
raise HTTPException(500, f"Metriche Redis corrotte per {role}: {_je}")
|
| 281 |
|
| 282 |
@router.post("/submit")
|
| 283 |
-
async def jq_submit(
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -317,10 +324,11 @@ async def jq_submit(job: JobPayload, request: Request):
|
|
| 317 |
}
|
| 318 |
|
| 319 |
@router.get("/result/{task_id}")
|
| 320 |
-
async def jq_result(
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
|
|
| 10 |
except ImportError:
|
| 11 |
_HAS_RESOURCE = False
|
| 12 |
from typing import Optional, List, Dict, Any
|
| 13 |
+
from fastapi import APIRouter, HTTPException, Request, Depends
|
| 14 |
+
from .auth_guard import require_role, AuthRole
|
| 15 |
from pydantic import BaseModel
|
| 16 |
from .load_balancer import balancer # S951
|
| 17 |
import httpx
|
|
|
|
| 259 |
|
| 260 |
# ── FastAPI endpoints ──────────────────────────────────────────────────────────
|
| 261 |
@router.get("/status")
|
| 262 |
+
async def jq_status(
|
| 263 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 264 |
+
):
|
| 265 |
return {
|
| 266 |
"space_role": _SPACE_ROLE,
|
| 267 |
"jq_enabled": _JQ_ENABLED,
|
|
|
|
| 271 |
}
|
| 272 |
|
| 273 |
@router.get("/load/{role}")
|
| 274 |
+
async def jq_load(
|
| 275 |
+
role: str,
|
| 276 |
+
auth_role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 277 |
+
):
|
| 278 |
if role not in ("brain", "hands", "memory", "audit"):
|
| 279 |
raise HTTPException(400, "role non valido")
|
| 280 |
res = await _rcmd(["GET", _K_LOAD(role)])
|
|
|
|
| 286 |
raise HTTPException(500, f"Metriche Redis corrotte per {role}: {_je}")
|
| 287 |
|
| 288 |
@router.post("/submit")
|
| 289 |
+
async def jq_submit(
|
| 290 |
+
job: JobPayload,
|
| 291 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 292 |
+
):
|
| 293 |
"""S42 — Grid Orchestrator: sottomissione job reale su Redis."""
|
|
|
|
|
|
|
| 294 |
if not _redis_ok():
|
| 295 |
raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
|
| 296 |
|
|
|
|
| 324 |
}
|
| 325 |
|
| 326 |
@router.get("/result/{task_id}")
|
| 327 |
+
async def jq_result(
|
| 328 |
+
task_id: str,
|
| 329 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 330 |
+
):
|
| 331 |
"""S429 — Tool Success Contract: recupero risultato job per taskId."""
|
|
|
|
|
|
|
| 332 |
if not _redis_ok():
|
| 333 |
raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
|
| 334 |
|
api/research.py
CHANGED
|
@@ -20,7 +20,8 @@ Budget (iPhone free tier):
|
|
| 20 |
"""
|
| 21 |
import asyncio, os, re, logging, time
|
| 22 |
import httpx
|
| 23 |
-
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
| 24 |
from pydantic import BaseModel
|
| 25 |
|
| 26 |
router = APIRouter(prefix="/api/web", tags=["web-research"])
|
|
@@ -277,11 +278,11 @@ async def _synthesize(topic: str, sources: list[dict]) -> str:
|
|
| 277 |
# ─── Main endpoint ────────────────────────────────────────────────────────────
|
| 278 |
|
| 279 |
@router.post("/research")
|
| 280 |
-
async def web_research(
|
|
|
|
|
|
|
|
|
|
| 281 |
# S-GAP23: X-Internal-Token guard — protegge consumi Groq API da abusi esterni.
|
| 282 |
-
_internal_token = os.getenv('INTERNAL_TOKEN', '')
|
| 283 |
-
if _internal_token and request.headers.get('X-Internal-Token') != _internal_token:
|
| 284 |
-
raise HTTPException(401, 'Unauthorized')
|
| 285 |
|
| 286 |
n = min(max(int(req.depth), 1), _MAX_URLS_PER_ROUND)
|
| 287 |
|
|
@@ -605,11 +606,11 @@ async def _synthesize(topic: str, sources: list[dict]) -> str:
|
|
| 605 |
# ─── Main endpoint ────────────────────────────────────────────────────────────
|
| 606 |
|
| 607 |
@router.post("/research")
|
| 608 |
-
async def web_research(
|
|
|
|
|
|
|
|
|
|
| 609 |
# S-GAP23: X-Internal-Token guard — protegge consumi Groq API da abusi esterni.
|
| 610 |
-
_internal_token = os.getenv('INTERNAL_TOKEN', '')
|
| 611 |
-
if _internal_token and request.headers.get('X-Internal-Token') != _internal_token:
|
| 612 |
-
raise HTTPException(401, 'Unauthorized')
|
| 613 |
|
| 614 |
n = min(max(int(req.depth), 1), _MAX_URLS_PER_ROUND)
|
| 615 |
|
|
|
|
| 20 |
"""
|
| 21 |
import asyncio, os, re, logging, time
|
| 22 |
import httpx
|
| 23 |
+
from fastapi import APIRouter, HTTPException, Request, Depends
|
| 24 |
+
from .auth_guard import require_role, AuthRole
|
| 25 |
from pydantic import BaseModel
|
| 26 |
|
| 27 |
router = APIRouter(prefix="/api/web", tags=["web-research"])
|
|
|
|
| 278 |
# ─── Main endpoint ────────────────────────────────────────────────────────────
|
| 279 |
|
| 280 |
@router.post("/research")
|
| 281 |
+
async def web_research(
|
| 282 |
+
req: ResearchRequest,
|
| 283 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 284 |
+
):
|
| 285 |
# S-GAP23: X-Internal-Token guard — protegge consumi Groq API da abusi esterni.
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
n = min(max(int(req.depth), 1), _MAX_URLS_PER_ROUND)
|
| 288 |
|
|
|
|
| 606 |
# ─── Main endpoint ────────────────────────────────────────────────────────────
|
| 607 |
|
| 608 |
@router.post("/research")
|
| 609 |
+
async def web_research(
|
| 610 |
+
req: ResearchRequest,
|
| 611 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 612 |
+
):
|
| 613 |
# S-GAP23: X-Internal-Token guard — protegge consumi Groq API da abusi esterni.
|
|
|
|
|
|
|
|
|
|
| 614 |
|
| 615 |
n = min(max(int(req.depth), 1), _MAX_URLS_PER_ROUND)
|
| 616 |
|
api/scheduler.py
CHANGED
|
@@ -34,9 +34,10 @@ import uuid
|
|
| 34 |
from pathlib import Path
|
| 35 |
from typing import Any, AsyncGenerator, Optional
|
| 36 |
|
| 37 |
-
from fastapi import APIRouter, HTTPException, Request
|
| 38 |
from fastapi.responses import StreamingResponse
|
| 39 |
from pydantic import BaseModel
|
|
|
|
| 40 |
import logging
|
| 41 |
_logger = logging.getLogger("agente_ai") # S-BUGFIX
|
| 42 |
|
|
@@ -281,13 +282,14 @@ def _is_due(task: dict, now_ms: int) -> bool:
|
|
| 281 |
if tt == "daily": return now_ms >= t.get("nextRun", 0)
|
| 282 |
if tt == "on_open": return True # boot-time task
|
| 283 |
if tt == "issue_poll": return now_ms >= t.get("nextRun", 0)
|
|
|
|
| 284 |
return False
|
| 285 |
|
| 286 |
|
| 287 |
def _advance_trigger(trigger: dict, now_ms: int) -> dict:
|
| 288 |
t = dict(trigger)
|
| 289 |
tt = t.get("type")
|
| 290 |
-
if tt in ("interval", "issue_poll"):
|
| 291 |
t["nextRun"] = now_ms + t.get("intervalMs", 3_600_000)
|
| 292 |
elif tt == "daily":
|
| 293 |
hour = t.get("hour", 9)
|
|
@@ -582,14 +584,19 @@ class TaskPatch(BaseModel):
|
|
| 582 |
# ─── REST Endpoints ───────────────────────────────────────────────────────────
|
| 583 |
|
| 584 |
@router.get("/tasks")
|
| 585 |
-
async def list_tasks(
|
|
|
|
|
|
|
| 586 |
"""Polling dal frontend (fallback se SSE non disponibile) — fonte di verità server-side."""
|
| 587 |
async with _lock:
|
| 588 |
return list(_tasks.values())
|
| 589 |
|
| 590 |
|
| 591 |
@router.post("/tasks", status_code=201)
|
| 592 |
-
async def create_task(
|
|
|
|
|
|
|
|
|
|
| 593 |
"""Crea task sul backend. Il frontend chiama questo DOPO il salvataggio Dexie."""
|
| 594 |
tid = body.id or f"sched_{int(time.time()*1000):x}_{uuid.uuid4().hex[:4]}"
|
| 595 |
task: dict[str, Any] = {
|
|
@@ -616,7 +623,11 @@ async def create_task(body: TaskCreate) -> dict:
|
|
| 616 |
|
| 617 |
|
| 618 |
@router.patch("/tasks/{task_id}")
|
| 619 |
-
async def patch_task(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
"""Pausa, riprendi, o aggiorna label/trigger di un task."""
|
| 621 |
async with _lock:
|
| 622 |
task = _tasks.get(task_id)
|
|
@@ -631,7 +642,10 @@ async def patch_task(task_id: str, body: TaskPatch) -> dict:
|
|
| 631 |
|
| 632 |
|
| 633 |
@router.delete("/tasks/{task_id}", status_code=204)
|
| 634 |
-
async def delete_task(
|
|
|
|
|
|
|
|
|
|
| 635 |
"""Cancella task dal backend + Supabase (MX11-SCHED)."""
|
| 636 |
async with _lock:
|
| 637 |
if task_id not in _tasks:
|
|
@@ -644,7 +658,10 @@ async def delete_task(task_id: str) -> None:
|
|
| 644 |
|
| 645 |
|
| 646 |
@router.post("/sync")
|
| 647 |
-
async def sync_tasks(
|
|
|
|
|
|
|
|
|
|
| 648 |
"""
|
| 649 |
Bulk upsert da Dexie → backend.
|
| 650 |
Idempotente: inserisce solo i task assenti. Non sovrascrive quelli esistenti.
|
|
@@ -675,7 +692,10 @@ async def sync_tasks(body: list[dict]) -> dict:
|
|
| 675 |
|
| 676 |
|
| 677 |
@router.post("/trigger/{task_id}")
|
| 678 |
-
async def trigger_task_now(
|
|
|
|
|
|
|
|
|
|
| 679 |
"""Esecuzione immediata ignorando il trigger temporale (debug / run manuale)."""
|
| 680 |
async with _lock:
|
| 681 |
task = _tasks.get(task_id)
|
|
@@ -686,7 +706,10 @@ async def trigger_task_now(task_id: str) -> dict:
|
|
| 686 |
|
| 687 |
|
| 688 |
@router.post("/tick")
|
| 689 |
-
async def external_tick(
|
|
|
|
|
|
|
|
|
|
| 690 |
"""
|
| 691 |
Pacemaker esterno — MX11-SCHED.
|
| 692 |
|
|
@@ -698,24 +721,12 @@ async def external_tick(request: Request) -> dict:
|
|
| 698 |
- Esegue _tick() immediatamente (no attesa del ciclo 60s)
|
| 699 |
- Auto-riavvia il loop asyncio se è morto (self-healing)
|
| 700 |
- Idempotente: sicuro da più sorgenti concorrenti
|
| 701 |
-
-
|
| 702 |
|
| 703 |
Response: { ok, source, loopRevived, loopRunning, tasks, pending, running, ts }
|
| 704 |
"""
|
| 705 |
global _loop_task
|
| 706 |
-
|
| 707 |
-
source = request.query_params.get("source", "external")
|
| 708 |
-
|
| 709 |
-
# Token check opzionale — solo se INTERNAL_TOKEN è configurato
|
| 710 |
-
_int_tok = os.getenv("INTERNAL_TOKEN", "")
|
| 711 |
-
# Fail-close: INTERNAL_TOKEN deve essere configurato e valido
|
| 712 |
-
if not _int_tok:
|
| 713 |
-
raise HTTPException(503, "Service unavailable — INTERNAL_TOKEN non configurato.")
|
| 714 |
-
|
| 715 |
-
if _int_tok:
|
| 716 |
-
req_tok = request.headers.get("X-Internal-Token", "")
|
| 717 |
-
if req_tok != _int_tok:
|
| 718 |
-
raise HTTPException(403, "Unauthorized — X-Internal-Token richiesto")
|
| 719 |
|
| 720 |
# Self-heal: riavvia il loop se morto
|
| 721 |
loop_was_dead = _loop_task is None or _loop_task.done()
|
|
@@ -756,7 +767,9 @@ async def external_tick(request: Request) -> dict:
|
|
| 756 |
|
| 757 |
|
| 758 |
@router.get("/status")
|
| 759 |
-
async def scheduler_status(
|
|
|
|
|
|
|
| 760 |
"""Stato del loop asyncio — usato dal frontend per il badge ☁️/📱."""
|
| 761 |
loop_ok = _loop_task is not None and not _loop_task.done()
|
| 762 |
async with _lock:
|
|
@@ -774,7 +787,10 @@ async def scheduler_status() -> dict:
|
|
| 774 |
|
| 775 |
|
| 776 |
@router.get("/delta")
|
| 777 |
-
async def scheduler_delta(
|
|
|
|
|
|
|
|
|
|
| 778 |
"""GAP-A7: Delta-only view — solo task aggiornati dopo since_ms (epoch ms).
|
| 779 |
|
| 780 |
Permette polling incrementale efficiente dal frontend:
|
|
|
|
| 34 |
from pathlib import Path
|
| 35 |
from typing import Any, AsyncGenerator, Optional
|
| 36 |
|
| 37 |
+
from fastapi import APIRouter, HTTPException, Request, Depends
|
| 38 |
from fastapi.responses import StreamingResponse
|
| 39 |
from pydantic import BaseModel
|
| 40 |
+
from .auth_guard import require_role, AuthRole
|
| 41 |
import logging
|
| 42 |
_logger = logging.getLogger("agente_ai") # S-BUGFIX
|
| 43 |
|
|
|
|
| 282 |
if tt == "daily": return now_ms >= t.get("nextRun", 0)
|
| 283 |
if tt == "on_open": return True # boot-time task
|
| 284 |
if tt == "issue_poll": return now_ms >= t.get("nextRun", 0)
|
| 285 |
+
if tt == "tiered_scan": return now_ms >= t.get("nextRun", 0) # MX-SCHED-TIERED
|
| 286 |
return False
|
| 287 |
|
| 288 |
|
| 289 |
def _advance_trigger(trigger: dict, now_ms: int) -> dict:
|
| 290 |
t = dict(trigger)
|
| 291 |
tt = t.get("type")
|
| 292 |
+
if tt in ("interval", "issue_poll", "tiered_scan"): # MX-SCHED-TIERED
|
| 293 |
t["nextRun"] = now_ms + t.get("intervalMs", 3_600_000)
|
| 294 |
elif tt == "daily":
|
| 295 |
hour = t.get("hour", 9)
|
|
|
|
| 584 |
# ─── REST Endpoints ───────────────────────────────────────────────────────────
|
| 585 |
|
| 586 |
@router.get("/tasks")
|
| 587 |
+
async def list_tasks(
|
| 588 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 589 |
+
) -> list[dict]:
|
| 590 |
"""Polling dal frontend (fallback se SSE non disponibile) — fonte di verità server-side."""
|
| 591 |
async with _lock:
|
| 592 |
return list(_tasks.values())
|
| 593 |
|
| 594 |
|
| 595 |
@router.post("/tasks", status_code=201)
|
| 596 |
+
async def create_task(
|
| 597 |
+
body: TaskCreate,
|
| 598 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 599 |
+
) -> dict:
|
| 600 |
"""Crea task sul backend. Il frontend chiama questo DOPO il salvataggio Dexie."""
|
| 601 |
tid = body.id or f"sched_{int(time.time()*1000):x}_{uuid.uuid4().hex[:4]}"
|
| 602 |
task: dict[str, Any] = {
|
|
|
|
| 623 |
|
| 624 |
|
| 625 |
@router.patch("/tasks/{task_id}")
|
| 626 |
+
async def patch_task(
|
| 627 |
+
task_id: str,
|
| 628 |
+
body: TaskPatch,
|
| 629 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 630 |
+
) -> dict:
|
| 631 |
"""Pausa, riprendi, o aggiorna label/trigger di un task."""
|
| 632 |
async with _lock:
|
| 633 |
task = _tasks.get(task_id)
|
|
|
|
| 642 |
|
| 643 |
|
| 644 |
@router.delete("/tasks/{task_id}", status_code=204)
|
| 645 |
+
async def delete_task(
|
| 646 |
+
task_id: str,
|
| 647 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 648 |
+
) -> None:
|
| 649 |
"""Cancella task dal backend + Supabase (MX11-SCHED)."""
|
| 650 |
async with _lock:
|
| 651 |
if task_id not in _tasks:
|
|
|
|
| 658 |
|
| 659 |
|
| 660 |
@router.post("/sync")
|
| 661 |
+
async def sync_tasks(
|
| 662 |
+
body: list[dict],
|
| 663 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 664 |
+
) -> dict:
|
| 665 |
"""
|
| 666 |
Bulk upsert da Dexie → backend.
|
| 667 |
Idempotente: inserisce solo i task assenti. Non sovrascrive quelli esistenti.
|
|
|
|
| 692 |
|
| 693 |
|
| 694 |
@router.post("/trigger/{task_id}")
|
| 695 |
+
async def trigger_task_now(
|
| 696 |
+
task_id: str,
|
| 697 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 698 |
+
) -> dict:
|
| 699 |
"""Esecuzione immediata ignorando il trigger temporale (debug / run manuale)."""
|
| 700 |
async with _lock:
|
| 701 |
task = _tasks.get(task_id)
|
|
|
|
| 706 |
|
| 707 |
|
| 708 |
@router.post("/tick")
|
| 709 |
+
async def external_tick(
|
| 710 |
+
request: Request,
|
| 711 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 712 |
+
) -> dict:
|
| 713 |
"""
|
| 714 |
Pacemaker esterno — MX11-SCHED.
|
| 715 |
|
|
|
|
| 721 |
- Esegue _tick() immediatamente (no attesa del ciclo 60s)
|
| 722 |
- Auto-riavvia il loop asyncio se è morto (self-healing)
|
| 723 |
- Idempotente: sicuro da più sorgenti concorrenti
|
| 724 |
+
- Richiede AuthRole.MACHINE (X-Internal-Token)
|
| 725 |
|
| 726 |
Response: { ok, source, loopRevived, loopRunning, tasks, pending, running, ts }
|
| 727 |
"""
|
| 728 |
global _loop_task
|
| 729 |
+
source = request.query_params.get("source", "external")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
|
| 731 |
# Self-heal: riavvia il loop se morto
|
| 732 |
loop_was_dead = _loop_task is None or _loop_task.done()
|
|
|
|
| 767 |
|
| 768 |
|
| 769 |
@router.get("/status")
|
| 770 |
+
async def scheduler_status(
|
| 771 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 772 |
+
) -> dict:
|
| 773 |
"""Stato del loop asyncio — usato dal frontend per il badge ☁️/📱."""
|
| 774 |
loop_ok = _loop_task is not None and not _loop_task.done()
|
| 775 |
async with _lock:
|
|
|
|
| 787 |
|
| 788 |
|
| 789 |
@router.get("/delta")
|
| 790 |
+
async def scheduler_delta(
|
| 791 |
+
since_ms: int = 0,
|
| 792 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 793 |
+
) -> dict:
|
| 794 |
"""GAP-A7: Delta-only view — solo task aggiornati dopo since_ms (epoch ms).
|
| 795 |
|
| 796 |
Permette polling incrementale efficiente dal frontend:
|
api/search.py
CHANGED
|
@@ -8,8 +8,9 @@ import os, asyncio, html, json
|
|
| 8 |
import re as _re
|
| 9 |
import urllib.request, urllib.parse
|
| 10 |
from typing import Optional
|
| 11 |
-
from fastapi import APIRouter, HTTPException, Request
|
| 12 |
from pydantic import BaseModel
|
|
|
|
| 13 |
|
| 14 |
import logging
|
| 15 |
_logger = logging.getLogger("api.search")
|
|
@@ -237,8 +238,12 @@ async def _ddg_instant_search(q: str, limit: int) -> list[dict]:
|
|
| 237 |
|
| 238 |
# ── Search ─────────────────────────────────────────────────────────────────────
|
| 239 |
|
| 240 |
-
@router.post('/
|
| 241 |
-
async def proxy_search(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
q = req.query.strip()[:200]
|
| 243 |
if not q:
|
| 244 |
return {'results': []}
|
|
@@ -337,8 +342,12 @@ async def proxy_search(req: SearchRequest):
|
|
| 337 |
|
| 338 |
# ── Fetch page ─────────────────────────────────────────────────────────────────
|
| 339 |
|
| 340 |
-
@router.post('/
|
| 341 |
-
async def proxy_fetch_page(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
url = req.url.strip()
|
| 343 |
if not url.startswith(('http://', 'https://')):
|
| 344 |
raise HTTPException(400, detail={'error': 'url_invalido'})
|
|
@@ -364,16 +373,16 @@ async def proxy_fetch_page(req: FetchPageRequest):
|
|
| 364 |
|
| 365 |
# ── Analyze image ──────────────────────────────────────────────────────────────
|
| 366 |
|
| 367 |
-
@router.post('/
|
| 368 |
-
async def analyze_image(
|
|
|
|
|
|
|
|
|
|
| 369 |
"""
|
| 370 |
Vision AI — OpenRouter free VL models → Gemini fallback.
|
| 371 |
[S191] rimossi modelli deprecated.
|
| 372 |
[S-GAP23] X-Internal-Token guard — protegge consumi LLM da abusi esterni.
|
| 373 |
"""
|
| 374 |
-
_internal_token = os.getenv('INTERNAL_TOKEN', '')
|
| 375 |
-
if _internal_token and request.headers.get('X-Internal-Token') != _internal_token:
|
| 376 |
-
raise HTTPException(401, 'Unauthorized')
|
| 377 |
import re as _re2, json as _json
|
| 378 |
from openai import OpenAI as _OAI
|
| 379 |
|
|
|
|
| 8 |
import re as _re
|
| 9 |
import urllib.request, urllib.parse
|
| 10 |
from typing import Optional
|
| 11 |
+
from fastapi import APIRouter, HTTPException, Request, Depends
|
| 12 |
from pydantic import BaseModel
|
| 13 |
+
from .auth_guard import require_role, AuthRole
|
| 14 |
|
| 15 |
import logging
|
| 16 |
_logger = logging.getLogger("api.search")
|
|
|
|
| 238 |
|
| 239 |
# ── Search ─────────────────────────────────────────────────────────────────────
|
| 240 |
|
| 241 |
+
@router.post('/search')
|
| 242 |
+
async def proxy_search(
|
| 243 |
+
req: SearchRequest,
|
| 244 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 245 |
+
):
|
| 246 |
+
# ── GAP-FAILOPEN fix: l'endpoint ora richiede AuthRole.MACHINE ───────────
|
| 247 |
q = req.query.strip()[:200]
|
| 248 |
if not q:
|
| 249 |
return {'results': []}
|
|
|
|
| 342 |
|
| 343 |
# ── Fetch page ─────────────────────────────────────────────────────────────────
|
| 344 |
|
| 345 |
+
@router.post('/fetch-page')
|
| 346 |
+
async def proxy_fetch_page(
|
| 347 |
+
req: FetchPageRequest,
|
| 348 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 349 |
+
):
|
| 350 |
+
# ── GAP-FAILOPEN fix: protezione endpoint fetch-page ─────────────────────
|
| 351 |
url = req.url.strip()
|
| 352 |
if not url.startswith(('http://', 'https://')):
|
| 353 |
raise HTTPException(400, detail={'error': 'url_invalido'})
|
|
|
|
| 373 |
|
| 374 |
# ── Analyze image ──────────────────────────────────────────────────────────────
|
| 375 |
|
| 376 |
+
@router.post('/analyze-image')
|
| 377 |
+
async def analyze_image(
|
| 378 |
+
body: AnalyzeImageRequest,
|
| 379 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 380 |
+
):
|
| 381 |
"""
|
| 382 |
Vision AI — OpenRouter free VL models → Gemini fallback.
|
| 383 |
[S191] rimossi modelli deprecated.
|
| 384 |
[S-GAP23] X-Internal-Token guard — protegge consumi LLM da abusi esterni.
|
| 385 |
"""
|
|
|
|
|
|
|
|
|
|
| 386 |
import re as _re2, json as _json
|
| 387 |
from openai import OpenAI as _OAI
|
| 388 |
|
api/telegram_callbacks.py
CHANGED
|
@@ -5,23 +5,25 @@ Handler:
|
|
| 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")
|
|
@@ -170,6 +172,34 @@ async def _handle_callback(callback_query: dict, token: str) -> None:
|
|
| 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":
|
|
|
|
| 5 |
_handle_callback — callback_data dei bottoni inline
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
+
import asyncio, html, httpx, 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, _WEBAPP_KB,
|
| 14 |
)
|
| 15 |
from .telegram_keyboards import (
|
| 16 |
_MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL,
|
| 17 |
+
_TASK_MENU_KB, _STATUS_MENU_KB, _PERF_MENU_KB, _DEV_MENU_KB, _HEALTH_MENU_KB,
|
| 18 |
)
|
| 19 |
from .telegram_cmd_ai import (
|
| 20 |
_cmd_do, _cmd_autofix, _cmd_score, _cmd_bench, _cmd_improve,
|
| 21 |
_cmd_scan_now, _cmd_telemetry, _cmd_coord, _cmd_git,
|
| 22 |
+
_cmd_deploy, _cmd_restart_node, _cmd_riepilogo,
|
| 23 |
)
|
| 24 |
from .telegram_cmd_monitoring import (
|
| 25 |
_cmd_help, _cmd_status, _cmd_tasks, _cmd_check,
|
| 26 |
+
_cmd_logs, _cmd_nodes, _cmd_commit_summary, _cmd_free_text,
|
| 27 |
)
|
| 28 |
|
| 29 |
_logger = logging.getLogger("api.telegram_webhook")
|
|
|
|
| 172 |
elif data == "tgw_bench_run":
|
| 173 |
# Rilancia bench
|
| 174 |
asyncio.create_task(_cmd_bench(chat_id)).add_done_callback(_log_tg_exc)
|
| 175 |
+
# ── Dispatch: infra + monitoraggio ──────────────────────────────────────
|
| 176 |
+
elif data == "tgw_nodes":
|
| 177 |
+
asyncio.create_task(_cmd_nodes(chat_id)).add_done_callback(_log_tg_exc)
|
| 178 |
+
elif data == "tgw_commits":
|
| 179 |
+
asyncio.create_task(_cmd_commit_summary(chat_id)).add_done_callback(_log_tg_exc)
|
| 180 |
+
elif data == "tgw_deploy":
|
| 181 |
+
asyncio.create_task(_cmd_deploy(chat_id)).add_done_callback(_log_tg_exc)
|
| 182 |
+
elif data in ("tgw_restart_A","tgw_restart_B","tgw_restart_C","tgw_restart_D"):
|
| 183 |
+
node = data.split("_")[-1] # "A","B","C","D"
|
| 184 |
+
asyncio.create_task(_cmd_restart_node(chat_id, node)).add_done_callback(_log_tg_exc)
|
| 185 |
+
elif data == "tgw_restart_all":
|
| 186 |
+
# "all" mostra conferma prima di agire (azione distruttiva)
|
| 187 |
+
asyncio.create_task(_cmd_restart_node(chat_id, "all")).add_done_callback(_log_tg_exc)
|
| 188 |
+
elif data == "tgw_confirm_restart_all":
|
| 189 |
+
# Conferma ottenuta → esegue davvero il restart di tutti i nodi
|
| 190 |
+
from .telegram_cmd_ai import _cmd_restart_node as _crn
|
| 191 |
+
asyncio.create_task(_crn(chat_id, "_CONFIRMED_ALL")).add_done_callback(_log_tg_exc)
|
| 192 |
+
# ── Confirm/cancel task da testo libero ───────────────────────────────────
|
| 193 |
+
elif data == "tgw_confirm_task":
|
| 194 |
+
from .telegram_keyboards import _LAST_GOAL
|
| 195 |
+
goal = _LAST_GOAL.get(chat_id, "")
|
| 196 |
+
if goal:
|
| 197 |
+
from .telegram_cmd_ai import _cmd_do
|
| 198 |
+
asyncio.create_task(_cmd_do(chat_id, goal)).add_done_callback(_log_tg_exc)
|
| 199 |
+
else:
|
| 200 |
+
asyncio.create_task(_cmd_help(chat_id)).add_done_callback(_log_tg_exc)
|
| 201 |
+
elif data == "tgw_cancel":
|
| 202 |
+
asyncio.create_task(_cmd_help(chat_id)).add_done_callback(_log_tg_exc)
|
| 203 |
elif data == "tgw_score":
|
| 204 |
asyncio.create_task(_cmd_score(chat_id)).add_done_callback(_log_tg_exc)
|
| 205 |
elif data == "tgw_telemetry":
|
api/telegram_cmd_ai.py
CHANGED
|
@@ -371,7 +371,7 @@ async def _cmd_autofix(chat_id: int, hint: str = "") -> None:
|
|
| 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 |
-
"
|
| 375 |
f"U0001f517 <a href=\"https://github.com/{gh_repo}/commit/{c_sha}\">Vedi commit</a>",
|
| 376 |
final=True,
|
| 377 |
)
|
|
@@ -1152,3 +1152,153 @@ async def _cmd_improve(chat_id: int) -> None:
|
|
| 1152 |
keyboard=_BACK_KB)
|
| 1153 |
|
| 1154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"🚀 <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 |
)
|
|
|
|
| 1152 |
keyboard=_BACK_KB)
|
| 1153 |
|
| 1154 |
|
| 1155 |
+
|
| 1156 |
+
|
| 1157 |
+
# ── Nuovi comandi infrastruttura (aggiunti da refactor bot) ───────────────────
|
| 1158 |
+
|
| 1159 |
+
async def _cmd_deploy(chat_id: int) -> None:
|
| 1160 |
+
"""☁️ /deploy — Trigger deploy Cloudflare Pages via hook o backend."""
|
| 1161 |
+
import httpx as _hx
|
| 1162 |
+
await _tg_typing(chat_id)
|
| 1163 |
+
|
| 1164 |
+
deploy_hook = os.getenv("CF_DEPLOY_HOOK", "").strip()
|
| 1165 |
+
railway_url = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/")
|
| 1166 |
+
|
| 1167 |
+
msg_id = await _tg_send(chat_id,
|
| 1168 |
+
"☁️ <b>Deploy Cloudflare Pages</b>\n⏳ <i>Triggering build…</i>")
|
| 1169 |
+
|
| 1170 |
+
async def _edit_final(text: str) -> None:
|
| 1171 |
+
if msg_id:
|
| 1172 |
+
await _tg_edit(chat_id, msg_id, text, keyboard=_MAIN_KB)
|
| 1173 |
+
|
| 1174 |
+
# Prova prima il CF deploy hook diretto
|
| 1175 |
+
if deploy_hook:
|
| 1176 |
+
try:
|
| 1177 |
+
async with _hx.AsyncClient(timeout=15.0) as c:
|
| 1178 |
+
r = await c.post(deploy_hook)
|
| 1179 |
+
if r.status_code in (200, 201):
|
| 1180 |
+
await _edit_final(
|
| 1181 |
+
"☁️ <b>Deploy avviato!</b>\n"
|
| 1182 |
+
"⏳ <i>Build Cloudflare in corso (2-3 min)…</i>\n"
|
| 1183 |
+
"🔗 <a href='https://agente-ai.pages.dev'>Verifica live</a>"
|
| 1184 |
+
)
|
| 1185 |
+
else:
|
| 1186 |
+
await _edit_final(f"⚠️ Hook risposta HTTP {r.status_code} — controlla CF dashboard")
|
| 1187 |
+
except Exception as exc:
|
| 1188 |
+
await _edit_final(f"❌ Deploy hook fallito: <code>{html.escape(str(exc)[:200])}</code>")
|
| 1189 |
+
return
|
| 1190 |
+
|
| 1191 |
+
# Fallback: chiedi al backend Railway di triggerare il deploy
|
| 1192 |
+
try:
|
| 1193 |
+
async with _hx.AsyncClient(timeout=20.0) as c:
|
| 1194 |
+
r = await c.post(f"{railway_url}/api/deploy",
|
| 1195 |
+
json={"target": "cloudflare", "source": "telegram"})
|
| 1196 |
+
if r.status_code == 200:
|
| 1197 |
+
data = r.json()
|
| 1198 |
+
await _edit_final(
|
| 1199 |
+
"☁️ <b>Deploy avviato via backend</b>\n"
|
| 1200 |
+
f"<code>{html.escape(str(data.get('message',''))[:200])}</code>\n"
|
| 1201 |
+
"🔗 <a href='https://agente-ai.pages.dev'>Verifica live</a>"
|
| 1202 |
+
)
|
| 1203 |
+
else:
|
| 1204 |
+
await _edit_final(
|
| 1205 |
+
f"⚠️ Backend risposta HTTP {r.status_code}\n"
|
| 1206 |
+
"<i>Configura CF_DEPLOY_HOOK nel .env per deploy diretto.</i>"
|
| 1207 |
+
)
|
| 1208 |
+
except Exception as exc:
|
| 1209 |
+
await _edit_final(
|
| 1210 |
+
f"❌ Deploy fallito: <code>{html.escape(str(exc)[:200])}</code>\n"
|
| 1211 |
+
"<i>Controlla CF_DEPLOY_HOOK nelle variabili Railway.</i>"
|
| 1212 |
+
)
|
| 1213 |
+
|
| 1214 |
+
|
| 1215 |
+
async def _cmd_restart_node(chat_id: int, node: str = "") -> None:
|
| 1216 |
+
"""🔄 /restart A|B|C|D|all — Riavvia un nodo HF Space via HF API."""
|
| 1217 |
+
import httpx as _hx
|
| 1218 |
+
|
| 1219 |
+
node = node.strip().upper()
|
| 1220 |
+
# "_CONFIRMED_ALL" arriva dal callback tgw_confirm_restart_all (bypass conferma)
|
| 1221 |
+
if node == "_CONFIRMED_ALL":
|
| 1222 |
+
node = "_ALL_CONFIRMED" # marcatore interno — salta la schermata di conferma
|
| 1223 |
+
_HF_SPACES = {
|
| 1224 |
+
"A": ("arjanit98", "Terminal"),
|
| 1225 |
+
"B": ("baida07", "ai-backend-collab"),
|
| 1226 |
+
"C": ("baida07", "ai-memory-backend"),
|
| 1227 |
+
"D": ("arypulka98","AUDIT"),
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
if node not in _HF_SPACES and node not in ("ALL", "_ALL_CONFIRMED"):
|
| 1231 |
+
from .telegram_keyboards import _CLUSTER_RESTART_KB
|
| 1232 |
+
await _tg_reply(chat_id,
|
| 1233 |
+
"⚠️ <b>Quale nodo vuoi riavviare?</b>\n\n"
|
| 1234 |
+
" A = BRAIN (Arjanit98/Terminal)\n"
|
| 1235 |
+
" B = HANDS (baida07/ai-backend-collab)\n"
|
| 1236 |
+
" C = MEMORY (baida07/ai-memory-backend)\n"
|
| 1237 |
+
" D = AUDIT (arypulka98/AUDIT)\n\n"
|
| 1238 |
+
"Usa: <code>/restart A</code> oppure <code>/restart all</code>\n"
|
| 1239 |
+
"<i>Ogni nodo impiega 1-2 min per ripartire dopo il restart.</i>",
|
| 1240 |
+
keyboard=_CLUSTER_RESTART_KB)
|
| 1241 |
+
return
|
| 1242 |
+
|
| 1243 |
+
# Conferma obbligatoria per restart di TUTTI i nodi (azione distruttiva)
|
| 1244 |
+
if node == "ALL":
|
| 1245 |
+
from .telegram_keyboards import _restart_confirm_kb
|
| 1246 |
+
await _tg_reply(chat_id,
|
| 1247 |
+
"⚠️ <b>Sei sicuro?</b>\n\n"
|
| 1248 |
+
"Stai per riavviare <b>tutti e 4 i nodi HF Space</b>:\n"
|
| 1249 |
+
" A BRAIN · B HANDS · C MEMORY · D AUDIT\n\n"
|
| 1250 |
+
"<i>L'infrastruttura sarà offline per 2-3 minuti durante il riavvio.</i>",
|
| 1251 |
+
keyboard=_restart_confirm_kb("all"))
|
| 1252 |
+
return
|
| 1253 |
+
|
| 1254 |
+
hf_token = os.getenv("HF_TOKEN", "").strip()
|
| 1255 |
+
if not hf_token:
|
| 1256 |
+
await _tg_reply(chat_id, "❌ HF_TOKEN non trovato nelle variabili Railway.", keyboard=_BACK_KB)
|
| 1257 |
+
return
|
| 1258 |
+
|
| 1259 |
+
is_all = node in ("ALL", "_ALL_CONFIRMED")
|
| 1260 |
+
nodes_to_restart = (
|
| 1261 |
+
list(_HF_SPACES.items()) if is_all
|
| 1262 |
+
else [(node, _HF_SPACES[node])]
|
| 1263 |
+
)
|
| 1264 |
+
|
| 1265 |
+
label = "tutti i nodi" if is_all else f"nodo {node}"
|
| 1266 |
+
await _tg_typing(chat_id)
|
| 1267 |
+
msg_id = await _tg_send(chat_id,
|
| 1268 |
+
f"🔄 <b>Restart {label}</b>\n⏳ <i>Chiamata HF API…</i>")
|
| 1269 |
+
|
| 1270 |
+
async def _restart_one(nid: str, owner: str, repo: str) -> tuple:
|
| 1271 |
+
try:
|
| 1272 |
+
async with _hx.AsyncClient(timeout=15.0) as c:
|
| 1273 |
+
r = await c.post(
|
| 1274 |
+
f"https://huggingface.co/api/spaces/{owner}/{repo}/restart",
|
| 1275 |
+
headers={"Authorization": f"Bearer {hf_token}"},
|
| 1276 |
+
)
|
| 1277 |
+
ok = r.status_code in (200, 201, 204)
|
| 1278 |
+
return nid, r.status_code, ok
|
| 1279 |
+
except Exception as exc:
|
| 1280 |
+
return nid, -1, False
|
| 1281 |
+
|
| 1282 |
+
results = await asyncio.gather(
|
| 1283 |
+
*[_restart_one(nid, owner, repo) for nid, (owner, repo) in nodes_to_restart]
|
| 1284 |
+
)
|
| 1285 |
+
|
| 1286 |
+
lines = []
|
| 1287 |
+
all_ok = True
|
| 1288 |
+
for nid, code, ok in results:
|
| 1289 |
+
icon = "✅" if ok else "❌"
|
| 1290 |
+
status = "riavviato" if ok else f"errore HTTP {code}"
|
| 1291 |
+
lines.append(f" {icon} Nodo <b>{nid}</b>: {status}")
|
| 1292 |
+
if not ok:
|
| 1293 |
+
all_ok = False
|
| 1294 |
+
|
| 1295 |
+
summary = "Restart completato" if all_ok else "Restart parziale — verifica i nodi in errore"
|
| 1296 |
+
final = (
|
| 1297 |
+
f"🔄 <b>{summary}</b>\n\n" +
|
| 1298 |
+
"\n".join(lines) +
|
| 1299 |
+
"\n\n<i>I nodi impiegano 1-2 min per ripartire.\nUsa /nodes per verificare lo stato.</i>"
|
| 1300 |
+
)
|
| 1301 |
+
if msg_id:
|
| 1302 |
+
await _tg_edit(chat_id, msg_id, final, keyboard=_MAIN_KB)
|
| 1303 |
+
else:
|
| 1304 |
+
await _tg_reply(chat_id, final, keyboard=_MAIN_KB)
|
api/telegram_cmd_monitoring.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
"""backend/api/telegram_cmd_monitoring.py — Comandi Telegram di monitoraggio e stato.
|
| 2 |
|
| 3 |
Comandi:
|
| 4 |
-
_cmd_help, _cmd_logs, _cmd_status,
|
| 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,
|
|
@@ -15,31 +17,33 @@ from .telegram_tg_client import (
|
|
| 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 |
-
"""
|
| 26 |
await _tg_typing(chat_id)
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 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:
|
|
@@ -77,198 +81,133 @@ async def _cmd_logs(chat_id: int, level: str = "WARNING") -> None:
|
|
| 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 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
ry_line = ""
|
| 126 |
try:
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
rj = rv.json()
|
| 132 |
-
ry_line = ("\n🚂 <b>Railway:</b> v" + rj.get("version","?")
|
| 133 |
-
+ " — " + rj.get("sprint",""))
|
| 134 |
except Exception:
|
| 135 |
-
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 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 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
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:
|
|
@@ -392,6 +331,80 @@ async def _cmd_check(chat_id: int) -> None:
|
|
| 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 = {
|
|
|
|
| 1 |
"""backend/api/telegram_cmd_monitoring.py — Comandi Telegram di monitoraggio e stato.
|
| 2 |
|
| 3 |
Comandi:
|
| 4 |
+
_cmd_help, _cmd_logs, _cmd_status, _cmd_nodes, _cmd_free_text,
|
| 5 |
+
_cmd_commit_summary, _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 |
+
import httpx # FIX: mancava a livello modulo — usato in _cmd_commit_summary e _cmd_check
|
| 11 |
+
# (bug silenzioso: NameError catturato da 'except Exception', mai visibile all'utente)
|
| 12 |
|
| 13 |
from .telegram_tg_client import (
|
| 14 |
_get_bot_token, _tg_reply, _tg_send, _tg_edit,
|
|
|
|
| 17 |
)
|
| 18 |
from .telegram_keyboards import (
|
| 19 |
_MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL,
|
| 20 |
+
_CLUSTER_RESTART_KB, _status_action_kb, _nodes_action_kb, _task_confirm_kb,
|
| 21 |
)
|
| 22 |
|
| 23 |
_logger = logging.getLogger("api.telegram_webhook")
|
| 24 |
|
| 25 |
+
# ── Token GitHub per commit summary e infra check ────────────────────────────
|
| 26 |
+
_gh_token: str = os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN", "")
|
| 27 |
+
|
| 28 |
# ── Command handlers ──────────────────────────────────────────────────────────
|
| 29 |
|
| 30 |
async def _cmd_help(chat_id: int) -> None:
|
| 31 |
+
"""Benvenuto / menu principale."""
|
| 32 |
await _tg_typing(chat_id)
|
| 33 |
+
msg = (
|
| 34 |
+
"👋 <b>Agente AI</b> — controllo infrastruttura dal telefono\n\n"
|
| 35 |
+
"💬 <b>Scrivi qualsiasi cosa</b> e la gestisco io\n"
|
| 36 |
+
" oppure usa i pulsanti qui sotto\n\n"
|
| 37 |
+
"⚡ <b>Comandi rapidi</b>\n"
|
| 38 |
+
" /status — cluster + deploy in un colpo\n"
|
| 39 |
+
" /nodes — ping live dei 4 nodi (ms)\n"
|
| 40 |
+
" /do <task> — lancia un task AI\n"
|
| 41 |
+
" /autofix — corregge errori automaticamente\n"
|
| 42 |
+
" /logs — ultimi warning/errori\n"
|
| 43 |
+
" /commits — commit GitHub recenti\n\n"
|
| 44 |
+
"💡 <i>Non serve conoscere i comandi — scrivi in italiano naturale</i>"
|
| 45 |
+
)
|
| 46 |
+
await _tg_reply(chat_id, msg, keyboard=_MAIN_KB)
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
async def _cmd_logs(chat_id: int, level: str = "WARNING") -> None:
|
|
|
|
| 81 |
await _tg_reply(chat_id, "\n".join(lines), keyboard=_BACK_KB)
|
| 82 |
|
| 83 |
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
|
| 88 |
async def _cmd_status(chat_id: int) -> None:
|
| 89 |
+
"""Dashboard unificata: cluster 4 nodi HF + Railway + CF Pages + task.
|
| 90 |
+
I pulsanti cambiano in base ai nodi down (UX contestuale).
|
| 91 |
+
"""
|
| 92 |
+
import httpx as _hx, datetime as _dt, time as _time
|
| 93 |
+
from .telegram_keyboards import _status_action_kb
|
| 94 |
await _tg_typing(chat_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
+
_RAILWAY = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/")
|
| 97 |
+
_HF_NODES = [
|
| 98 |
+
("A", "BRAIN", "https://arjanit98-terminal.hf.space"),
|
| 99 |
+
("B", "HANDS", "https://baida07-ai-backend-collab.hf.space"),
|
| 100 |
+
("C", "MEMORY", "https://baida07-ai-memory-backend.hf.space"),
|
| 101 |
+
("D", "AUDIT", "https://arypulka98-audit.hf.space"),
|
| 102 |
+
]
|
| 103 |
|
| 104 |
+
async def _ping(url: str, path: str = "/", timeout: float = 7.0) -> tuple:
|
| 105 |
+
t0 = _time.monotonic()
|
|
|
|
| 106 |
try:
|
| 107 |
+
async with _hx.AsyncClient(timeout=timeout, follow_redirects=True) as c:
|
| 108 |
+
r = await c.get(url + path)
|
| 109 |
+
ms = round((_time.monotonic() - t0) * 1000)
|
| 110 |
+
return r.status_code, ms
|
|
|
|
|
|
|
|
|
|
| 111 |
except Exception:
|
| 112 |
+
return -1, round((_time.monotonic() - t0) * 1000)
|
| 113 |
|
| 114 |
+
pings = await asyncio.gather(
|
| 115 |
+
*[_ping(url) for _, _, url in _HF_NODES],
|
| 116 |
+
_ping(_RAILWAY, "/health"),
|
| 117 |
+
_ping("https://agente-ai.pages.dev"),
|
| 118 |
+
return_exceptions=True,
|
| 119 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
+
try:
|
| 122 |
+
from api.state import _agent_tasks
|
| 123 |
+
running = sum(1 for t in _agent_tasks.values() if t.get("status") == "RUNNING")
|
| 124 |
+
done_tot = sum(1 for t in _agent_tasks.values() if t.get("status") == "SUCCESS")
|
| 125 |
+
except Exception:
|
| 126 |
+
running = done_tot = 0
|
| 127 |
+
|
| 128 |
+
now = _dt.datetime.now(_dt.timezone.utc).strftime("%d/%m %H:%M UTC")
|
| 129 |
+
|
| 130 |
+
def _icon_lat(r) -> tuple[str, str]:
|
| 131 |
+
if isinstance(r, Exception) or r[0] == -1:
|
| 132 |
+
return "🔴", "no risposta"
|
| 133 |
+
code, ms = r
|
| 134 |
+
if code in (200, 201, 204, 302):
|
| 135 |
+
return "✅", f"{ms}ms"
|
| 136 |
+
if code == 503:
|
| 137 |
+
return "🟠", "in avvio"
|
| 138 |
+
return "🟡", f"HTTP {code}"
|
| 139 |
+
|
| 140 |
+
down_nodes: list[str] = []
|
| 141 |
+
cluster_lines: list[str] = []
|
| 142 |
+
for i, (nid, role, _) in enumerate(_HF_NODES):
|
| 143 |
+
icon, lat = _icon_lat(pings[i])
|
| 144 |
+
if icon == "🔴":
|
| 145 |
+
down_nodes.append(nid)
|
| 146 |
+
cluster_lines.append(f" {icon} <b>{nid}</b> {role:<7} — {lat}")
|
| 147 |
+
|
| 148 |
+
rw_icon, rw_lat = _icon_lat(pings[4])
|
| 149 |
+
cf_icon, cf_lat = _icon_lat(pings[5])
|
| 150 |
+
|
| 151 |
+
if running:
|
| 152 |
+
task_line = f"⚙️ {running} in esecuzione"
|
| 153 |
+
elif done_tot:
|
| 154 |
+
task_line = f"✅ {done_tot} completati, nessuno in corso"
|
| 155 |
+
else:
|
| 156 |
+
task_line = "— nessun task recente"
|
| 157 |
+
|
| 158 |
+
issues = ""
|
| 159 |
+
if down_nodes:
|
| 160 |
+
issues = f"\n⚠️ <b>Nodi non raggiungibili:</b> {', '.join(down_nodes)} — usa i pulsanti per riavviare"
|
| 161 |
+
|
| 162 |
+
msg = (
|
| 163 |
+
f"📊 <b>Dashboard</b> — {now}\n"
|
| 164 |
+
f"\n🖥 <b>Cluster HF</b>\n" + "\n".join(cluster_lines) +
|
| 165 |
+
f"\n\n⚙️ <b>Backend Railway</b> — {rw_icon} {rw_lat}" +
|
| 166 |
+
f"\n☁️ <b>CF Pages</b> — {cf_icon} {cf_lat}" +
|
| 167 |
+
f"\n\n{task_line}" +
|
| 168 |
+
issues
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
await _tg_reply(chat_id, msg, keyboard=_status_action_kb(down_nodes))
|
| 172 |
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
async def _cmd_free_text(chat_id: int, text: str) -> None:
|
| 177 |
+
"""Gestisce testo libero senza /comando in modo intelligente.
|
| 178 |
+
|
| 179 |
+
Logica:
|
| 180 |
+
- Testo breve (≤ 70 chars) o domanda → esegue direttamente come query AI
|
| 181 |
+
- Testo lungo → mostra card di conferma prima di eseguire il task
|
| 182 |
+
Salva sempre il testo come _LAST_GOAL per il retry.
|
| 183 |
+
"""
|
| 184 |
+
from .telegram_keyboards import _task_confirm_kb
|
| 185 |
+
text = text.strip()
|
| 186 |
+
if not text:
|
| 187 |
+
await _tg_reply(chat_id, "Scrivi un obiettivo o usa /help per il menu.", keyboard=_MAIN_KB)
|
| 188 |
+
return
|
| 189 |
+
|
| 190 |
+
_LAST_GOAL[chat_id] = text # sempre, per retry
|
| 191 |
+
|
| 192 |
+
# Domanda breve o query semplice → esegui direttamente (nessuna frizione)
|
| 193 |
+
is_short = len(text) <= 70
|
| 194 |
+
is_question = text.endswith("?") or any(
|
| 195 |
+
text.lower().startswith(w) for w in ("cos'è", "cosa è", "come", "perché", "quando", "dove", "che", "chi", "qual")
|
| 196 |
+
)
|
| 197 |
+
if is_short or is_question:
|
| 198 |
+
from .telegram_cmd_ai import _cmd_do
|
| 199 |
+
await _cmd_do(chat_id, text)
|
| 200 |
+
return
|
| 201 |
+
|
| 202 |
+
# Goal lungo → chiedi conferma (evita task accidentali)
|
| 203 |
+
preview = html.escape(text[:150]) + ("…" if len(text) > 150 else "")
|
| 204 |
+
await _tg_reply(
|
| 205 |
+
chat_id,
|
| 206 |
+
f"🎯 <b>Eseguo questo task?</b>\n\n"
|
| 207 |
+
f"<blockquote>{preview}</blockquote>\n\n"
|
| 208 |
+
"<i>✅ Conferma per avviare, oppure ❌ per tornare al menu.</i>",
|
| 209 |
+
keyboard=_task_confirm_kb(),
|
| 210 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
|
| 213 |
async def _cmd_commit_summary(chat_id: int) -> None:
|
|
|
|
| 331 |
await _tg_reply(chat_id, "\n".join(lines_out), keyboard=_BACK_KB)
|
| 332 |
|
| 333 |
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
async def _cmd_nodes(chat_id: int) -> None:
|
| 337 |
+
"""Ping live dettagliato di tutti i nodi: HF A/B/C/D + Railway + CF Pages.
|
| 338 |
+
|
| 339 |
+
FIX: era duplicata (due `_cmd_nodes` nello stesso modulo — la seconda
|
| 340 |
+
sovrascriveva silenziosamente la prima). Unificata qui in un'unica
|
| 341 |
+
versione che mantiene il rilevamento dettagliato (503 sleeping, redirect)
|
| 342 |
+
e i pulsanti di restart CONTESTUALI (solo per i nodi HF realmente giù),
|
| 343 |
+
invece della tastiera statica precedente che offriva sempre tutti i restart.
|
| 344 |
+
"""
|
| 345 |
+
import httpx as _hx, datetime as _dt, time as _time
|
| 346 |
+
await _tg_typing(chat_id)
|
| 347 |
+
|
| 348 |
+
_RAILWAY = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/")
|
| 349 |
+
_HF_NODES = [
|
| 350 |
+
("A", "BRAIN", "https://arjanit98-terminal.hf.space", "arjanit98/Terminal"),
|
| 351 |
+
("B", "HANDS", "https://baida07-ai-backend-collab.hf.space", "baida07/ai-backend-collab"),
|
| 352 |
+
("C", "MEMORY", "https://baida07-ai-memory-backend.hf.space", "baida07/ai-memory-backend"),
|
| 353 |
+
("D", "AUDIT", "https://arypulka98-audit.hf.space", "arypulka98/AUDIT"),
|
| 354 |
+
]
|
| 355 |
+
|
| 356 |
+
async def _ping_full(label: str, url: str, path: str = "/", timeout: float = 8.0) -> tuple[str, bool]:
|
| 357 |
+
import time as _t
|
| 358 |
+
t0 = _t.monotonic()
|
| 359 |
+
try:
|
| 360 |
+
async with _hx.AsyncClient(timeout=timeout, follow_redirects=True) as c:
|
| 361 |
+
r = await c.get(url + path)
|
| 362 |
+
ms = round((_t.monotonic() - t0) * 1000)
|
| 363 |
+
if r.status_code in (200, 201, 204):
|
| 364 |
+
icon, status, ok = "✅", f"OK {ms}ms", True
|
| 365 |
+
elif r.status_code == 302:
|
| 366 |
+
icon, status, ok = "✅", f"redirect {ms}ms", True
|
| 367 |
+
elif r.status_code == 503:
|
| 368 |
+
icon, status, ok = "🔴", "503 sleeping", False
|
| 369 |
+
else:
|
| 370 |
+
icon, status, ok = "🟡", f"HTTP {r.status_code} {ms}ms", True
|
| 371 |
+
except _hx.TimeoutException:
|
| 372 |
+
ms = round((_t.monotonic() - t0) * 1000)
|
| 373 |
+
icon, status, ok = "🔴", f"timeout ({ms}ms)", False
|
| 374 |
+
except Exception as exc:
|
| 375 |
+
icon, status, ok = "🔴", str(exc)[:40], False
|
| 376 |
+
return f"{icon} <b>{label}</b> — {status}", ok
|
| 377 |
+
|
| 378 |
+
now = _dt.datetime.now(_dt.timezone.utc).strftime("%d/%m %H:%M UTC")
|
| 379 |
+
await _tg_reply(chat_id, f"🖥 <b>Ping cluster</b> — {now}\n⏳ <i>Pinging 6 endpoint…</i>")
|
| 380 |
+
|
| 381 |
+
results = await asyncio.gather(
|
| 382 |
+
*[_ping_full(f"HF {nid} {role}", url) for nid, role, url, _ in _HF_NODES],
|
| 383 |
+
_ping_full("Railway backend", _RAILWAY, "/health"),
|
| 384 |
+
_ping_full("CF Pages", "https://agente-ai.pages.dev"),
|
| 385 |
+
return_exceptions=True,
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
down_nodes: list[str] = []
|
| 389 |
+
lines = [f"🖥 <b>Cluster live</b> — {now}\n"]
|
| 390 |
+
for i, r in enumerate(results):
|
| 391 |
+
if isinstance(r, Exception):
|
| 392 |
+
lines.append(" ❌ Errore imprevisto")
|
| 393 |
+
continue
|
| 394 |
+
text, ok = r
|
| 395 |
+
lines.append(text)
|
| 396 |
+
if not ok and i < len(_HF_NODES):
|
| 397 |
+
down_nodes.append(_HF_NODES[i][0]) # "A", "B", "C", "D"
|
| 398 |
+
|
| 399 |
+
lines.append(
|
| 400 |
+
"\n<i>🔴 timeout/error | 🟡 risponde ma non 200 | ✅ ok</i>"
|
| 401 |
+
"\n<i>/restart A|B|C|D|all — riavvia nodo</i>"
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
await _tg_reply(chat_id, "\n".join(lines),
|
| 405 |
+
keyboard=_nodes_action_kb(down_nodes) if down_nodes else _CLUSTER_RESTART_KB)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
async def _cmd_tasks(chat_id: int) -> None:
|
| 409 |
await _tg_typing(chat_id)
|
| 410 |
STATUS_EMOJI = {
|
api/telegram_keyboards.py
CHANGED
|
@@ -1,122 +1,176 @@
|
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 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 |
-
# ──
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
|
|
|
| 31 |
def _after_task_kb(chat_id: int) -> dict:
|
| 32 |
-
"""Keyboard
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
return {
|
| 34 |
"inline_keyboard": [
|
| 35 |
-
[{"text": "
|
| 36 |
-
{"text": "
|
| 37 |
-
[{"text": "
|
| 38 |
-
{"text": "🏠 Menu", "callback_data": "tgw_help"}],
|
| 39 |
]
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
# ──
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
"inline_keyboard": [
|
| 47 |
-
[{"text": "
|
| 48 |
-
{"text": "
|
| 49 |
-
[{"text": "
|
| 50 |
-
{"text": "
|
| 51 |
-
[{"text": "
|
| 52 |
-
{"text": "
|
| 53 |
-
[{"text": "
|
|
|
|
| 54 |
]
|
| 55 |
}
|
| 56 |
|
| 57 |
-
# ──
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# ──
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
# ── Sub-menu inline keyboards ─────────────────────────────────────────────────
|
| 74 |
-
_TASK_MENU_KB = {
|
| 75 |
"inline_keyboard": [
|
| 76 |
-
[{"text": "
|
| 77 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
"inline_keyboard": [
|
| 88 |
-
[{"text": "📊
|
| 89 |
-
{"text": "
|
| 90 |
-
[{"text": "
|
| 91 |
-
{"text": "
|
| 92 |
-
[{"text": "
|
| 93 |
]
|
| 94 |
}
|
| 95 |
-
|
|
|
|
|
|
|
| 96 |
"inline_keyboard": [
|
| 97 |
-
[{"text": "
|
| 98 |
-
{"text": "
|
| 99 |
-
[{"text": "
|
| 100 |
-
{"text": "
|
| 101 |
-
[{"text": "
|
| 102 |
]
|
| 103 |
}
|
| 104 |
-
|
|
|
|
|
|
|
| 105 |
"inline_keyboard": [
|
| 106 |
-
[{"text": "
|
| 107 |
-
{"text": "
|
| 108 |
-
[{"text": "
|
| 109 |
-
{"text": "
|
|
|
|
| 110 |
]
|
| 111 |
}
|
| 112 |
-
|
|
|
|
|
|
|
| 113 |
"inline_keyboard": [
|
| 114 |
-
[{"text": "
|
| 115 |
-
{"text": "
|
| 116 |
-
[{"text": "
|
| 117 |
-
{"text": "
|
| 118 |
-
[{"text": "🔀 Git commits", "callback_data": "tgw_git"},
|
| 119 |
-
{"text": "🏓 Ping", "callback_data": "tgw_ping"}],
|
| 120 |
]
|
| 121 |
}
|
| 122 |
-
|
|
|
|
| 1 |
+
"""backend/api/telegram_keyboards.py — Inline keyboards, menu e costanti UI Telegram."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
+
import html as _html
|
| 4 |
+
|
| 5 |
+
# ── Stato condiviso tra moduli ────────────────────────────────────────────────
|
| 6 |
+
_LAST_GOAL: dict[int, str] = {} # ultimo goal per chat_id (retry)
|
| 7 |
+
_BENCH_CACHE: dict[int, dict] = {} # cache benchmark per chat_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
# ── Main keyboard ─────────────────────────────────────────────────────────────
|
| 10 |
+
_MAIN_KB = {
|
| 11 |
+
"inline_keyboard": [
|
| 12 |
+
[{"text": "💬 Chiedi all’AI", "callback_data": "agent"},
|
| 13 |
+
{"text": "⚡ Task rapidi", "callback_data": "quick_pick"}],
|
| 14 |
+
[{"text": "📊 Dashboard", "callback_data": "tgw_status"},
|
| 15 |
+
{"text": "🖥 Nodi", "callback_data": "tgw_nodes"}],
|
| 16 |
+
[{"text": "📋 Task attivi", "callback_data": "tgw_tasks"},
|
| 17 |
+
{"text": "🔧 AutoFix", "callback_data": "tgw_autofix"}],
|
| 18 |
+
[{"text": "📝 Commit", "callback_data": "tgw_commits"},
|
| 19 |
+
{"text": "📋 Log", "callback_data": "tgw_logs"}],
|
| 20 |
+
[{"text": "🌐 Apri Dashboard →", "url": "https://agente-ai.pages.dev"}],
|
| 21 |
+
]
|
| 22 |
+
}
|
| 23 |
|
| 24 |
+
# ── After-task keyboard ───────────────────────────────────────────────────────
|
| 25 |
def _after_task_kb(chat_id: int) -> dict:
|
| 26 |
+
"""Keyboard post-task con retry e navigazione."""
|
| 27 |
+
return {
|
| 28 |
+
"inline_keyboard": [
|
| 29 |
+
[{"text": "🔁 Rifai stesso task", "callback_data": "tgw_retry"},
|
| 30 |
+
{"text": "🚀 Nuovo task", "callback_data": "agent"}],
|
| 31 |
+
[{"text": "🔧 AutoFix risultato", "callback_data": "tgw_autofix"},
|
| 32 |
+
{"text": "📊 Dashboard", "callback_data": "tgw_status"}],
|
| 33 |
+
]
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# ── Conferma task da testo libero ─────────────────────────────────────────────
|
| 37 |
+
def _task_confirm_kb() -> dict:
|
| 38 |
+
"""Keyboard di conferma prima di eseguire un task da testo libero."""
|
| 39 |
return {
|
| 40 |
"inline_keyboard": [
|
| 41 |
+
[{"text": "✅ Sì, esegui", "callback_data": "tgw_confirm_task"},
|
| 42 |
+
{"text": "🔧 AutoFix invece", "callback_data": "tgw_autofix"}],
|
| 43 |
+
[{"text": "❌ Annulla", "callback_data": "tgw_help"}],
|
|
|
|
| 44 |
]
|
| 45 |
}
|
| 46 |
|
| 47 |
+
# ── Conferma restart nodo ─────────────────────────────────────────────────────
|
| 48 |
+
def _restart_confirm_kb(node: str) -> dict:
|
| 49 |
+
"""Chiede conferma prima di riavviare tutti i nodi."""
|
| 50 |
+
return {
|
| 51 |
+
"inline_keyboard": [
|
| 52 |
+
[{"text": f"✅ Sì, riavvia tutti e 4", "callback_data": "tgw_confirm_restart_all"},
|
| 53 |
+
{"text": "❌ Annulla", "callback_data": "tgw_help"}],
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
|
| 57 |
+
# ── Dashboard: pulsanti dinamici in base ai nodi down ───────────────────────
|
| 58 |
+
def _status_action_kb(down_nodes: list[str] | None = None) -> dict:
|
| 59 |
+
"""Keyboard post-dashboard: mostra restart solo per nodi effettivamente down."""
|
| 60 |
+
rows: list = []
|
| 61 |
+
if down_nodes:
|
| 62 |
+
btns = [{"text": f"🔄 Restart {n}", "callback_data": f"tgw_restart_{n}"}
|
| 63 |
+
for n in down_nodes[:4]]
|
| 64 |
+
rows += [btns[i:i+2] for i in range(0, len(btns), 2)]
|
| 65 |
+
rows.append([
|
| 66 |
+
{"text": "🔄 Aggiorna", "callback_data": "tgw_status"},
|
| 67 |
+
{"text": "📋 Task", "callback_data": "tgw_tasks"},
|
| 68 |
+
])
|
| 69 |
+
rows.append([
|
| 70 |
+
{"text": "☁️ Deploy", "callback_data": "tgw_deploy"},
|
| 71 |
+
{"text": "📋 Log", "callback_data": "tgw_logs"},
|
| 72 |
+
])
|
| 73 |
+
return {"inline_keyboard": rows}
|
| 74 |
|
| 75 |
+
# ── Nodes: pulsanti dinamici per restart nodi down ────────────────────────────
|
| 76 |
+
def _nodes_action_kb(down_nodes: list[str] | None = None) -> dict:
|
| 77 |
+
"""Keyboard post-/nodes: restart selettivo + navigazione."""
|
| 78 |
+
rows: list = []
|
| 79 |
+
if down_nodes:
|
| 80 |
+
btns = [{"text": f"🔄 Restart {n}", "callback_data": f"tgw_restart_{n}"}
|
| 81 |
+
for n in down_nodes[:4]]
|
| 82 |
+
rows += [btns[i:i+2] for i in range(0, len(btns), 2)]
|
| 83 |
+
rows.append([
|
| 84 |
+
{"text": "🔄 Riprova ping", "callback_data": "tgw_nodes"},
|
| 85 |
+
{"text": "📊 Dashboard", "callback_data": "tgw_status"},
|
| 86 |
+
])
|
| 87 |
+
return {"inline_keyboard": rows}
|
| 88 |
+
|
| 89 |
+
# ── Quick-pick task templates ─────────────────────────────────────────────────
|
| 90 |
+
_QUICK_PICK_KB = {
|
| 91 |
"inline_keyboard": [
|
| 92 |
+
[{"text": "🔍 Analizza bug", "callback_data": "qp_bug"},
|
| 93 |
+
{"text": "⚡ Ottimizza DB", "callback_data": "qp_db"}],
|
| 94 |
+
[{"text": "🔧 AutoFix log", "callback_data": "qp_autofix"},
|
| 95 |
+
{"text": "📝 Riassumi commit", "callback_data": "qp_commits"}],
|
| 96 |
+
[{"text": "📚 Genera docs", "callback_data": "qp_docs"},
|
| 97 |
+
{"text": "🧪 Genera test", "callback_data": "qp_tests"}],
|
| 98 |
+
[{"text": "✍️ Scrivi obiettivo…", "callback_data": "qp_custom"}],
|
| 99 |
+
[{"text": "🏠 Menu", "callback_data": "tgw_help"}],
|
| 100 |
]
|
| 101 |
}
|
| 102 |
|
| 103 |
+
# ── Cluster restart full (da usare quando nessun nodo è down) ────────────────
|
| 104 |
+
_CLUSTER_RESTART_KB = {
|
| 105 |
+
"inline_keyboard": [
|
| 106 |
+
[{"text": "🔄 Restart A (BRAIN)", "callback_data": "tgw_restart_A"},
|
| 107 |
+
{"text": "🔄 Restart B (HANDS)", "callback_data": "tgw_restart_B"}],
|
| 108 |
+
[{"text": "🔄 Restart C (MEMORY)", "callback_data": "tgw_restart_C"},
|
| 109 |
+
{"text": "🔄 Restart D (AUDIT)", "callback_data": "tgw_restart_D"}],
|
| 110 |
+
[{"text": "⚠️ Restart TUTTI", "callback_data": "tgw_restart_all"}],
|
| 111 |
+
[{"text": "🏠 Menu", "callback_data": "tgw_help"}],
|
| 112 |
+
]
|
| 113 |
+
}
|
| 114 |
|
| 115 |
+
# ── Bench action (retrocompatibilità) ─────────────────────────────────────────
|
| 116 |
_BENCH_ACTION_KB = {
|
| 117 |
"inline_keyboard": [
|
| 118 |
[{"text": "🔧 Applica Fix", "callback_data": "tgw_bench_fix"},
|
| 119 |
{"text": "🔄 Riesegui", "callback_data": "tgw_bench_run"}],
|
|
|
|
| 120 |
[{"text": "🏠 Menu", "callback_data": "tgw_help"}],
|
| 121 |
]
|
| 122 |
}
|
| 123 |
|
| 124 |
+
# ── Webapp / link keyboard ─────────────────────────────────────────────────────
|
| 125 |
+
_WEBAPP_KB = {
|
|
|
|
|
|
|
|
|
|
| 126 |
"inline_keyboard": [
|
| 127 |
+
[{"text": "🌐 Apri Dashboard", "url": "https://agente-ai.pages.dev"}],
|
| 128 |
+
[{"text": "🏠 Menu", "callback_data": "tgw_help"}],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
]
|
| 130 |
}
|
| 131 |
+
|
| 132 |
+
# ── Task menu (alias quick-pick) ───────────────────────────────────────────────
|
| 133 |
+
_TASK_MENU_KB = _QUICK_PICK_KB # template task rapidi
|
| 134 |
+
|
| 135 |
+
# ── Performance menu ──────────────────────────────────────────────────────────
|
| 136 |
+
_PERF_MENU_KB = {
|
| 137 |
"inline_keyboard": [
|
| 138 |
+
[{"text": "📊 Benchmark", "callback_data": "tgw_bench"},
|
| 139 |
+
{"text": "📈 Score", "callback_data": "tgw_score"}],
|
| 140 |
+
[{"text": "🖥 Nodi", "callback_data": "tgw_nodes"},
|
| 141 |
+
{"text": "📊 Dashboard", "callback_data": "tgw_status"}],
|
| 142 |
+
[{"text": "🏠 Menu", "callback_data": "tgw_help"}],
|
| 143 |
]
|
| 144 |
}
|
| 145 |
+
|
| 146 |
+
# ── Dev tools menu ────────────────────────────────────────────────────────────
|
| 147 |
+
_DEV_MENU_KB = {
|
| 148 |
"inline_keyboard": [
|
| 149 |
+
[{"text": "🔧 AutoFix", "callback_data": "tgw_autofix"},
|
| 150 |
+
{"text": "☁️ Deploy", "callback_data": "tgw_deploy"}],
|
| 151 |
+
[{"text": "📝 Commit", "callback_data": "tgw_commits"},
|
| 152 |
+
{"text": "📋 Log", "callback_data": "tgw_logs"}],
|
| 153 |
+
[{"text": "🏠 Menu", "callback_data": "tgw_help"}],
|
| 154 |
]
|
| 155 |
}
|
| 156 |
+
|
| 157 |
+
# ── Status/after-dashboard actions ────────────────────────────────────────────
|
| 158 |
+
_STATUS_MENU_KB = {
|
| 159 |
"inline_keyboard": [
|
| 160 |
+
[{"text": "🔄 Aggiorna", "callback_data": "tgw_status"},
|
| 161 |
+
{"text": "🖥 Nodi", "callback_data": "tgw_nodes"}],
|
| 162 |
+
[{"text": "☁️ Deploy", "callback_data": "tgw_deploy"},
|
| 163 |
+
{"text": "📋 Log", "callback_data": "tgw_logs"}],
|
| 164 |
+
[{"text": "🏠 Menu", "callback_data": "tgw_help"}],
|
| 165 |
]
|
| 166 |
}
|
| 167 |
+
|
| 168 |
+
# ── Health/provider actions ────────────────────────────────────────────────────
|
| 169 |
+
_HEALTH_MENU_KB = {
|
| 170 |
"inline_keyboard": [
|
| 171 |
+
[{"text": "🔄 Aggiorna", "callback_data": "tgw_health"},
|
| 172 |
+
{"text": "🔌 Provider", "callback_data": "tgw_providers"}],
|
| 173 |
+
[{"text": "📊 Dashboard", "callback_data": "tgw_status"},
|
| 174 |
+
{"text": "🏠 Menu", "callback_data": "tgw_help"}],
|
|
|
|
|
|
|
| 175 |
]
|
| 176 |
}
|
|
|
api/telegram_webhook.py
CHANGED
|
@@ -2,13 +2,16 @@
|
|
| 2 |
|
| 3 |
Riceve aggiornamenti dal bot Telegram via webhook e risponde a comandi:
|
| 4 |
/start /help — menu + lista comandi
|
| 5 |
-
/status
|
|
|
|
| 6 |
/tasks — ultimi task con elapsed time
|
| 7 |
-
/
|
| 8 |
-
/
|
| 9 |
-
/
|
| 10 |
-
/
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
|
| 13 |
ARCHITETTURA: il bot usa getUpdates polling via scripts/session-daemon.mjs.
|
| 14 |
Il webhook NON è registrato su Telegram — l'endpoint /webhook/setup è
|
|
@@ -26,15 +29,17 @@ from .telegram_tg_client import (
|
|
| 26 |
)
|
| 27 |
from .telegram_keyboards import (
|
| 28 |
_MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _LAST_GOAL,
|
|
|
|
| 29 |
)
|
| 30 |
from .telegram_cmd_monitoring import (
|
| 31 |
-
_cmd_help, _cmd_logs, _cmd_status, _cmd_commit_summary,
|
| 32 |
-
_cmd_check, _cmd_tasks,
|
| 33 |
)
|
| 34 |
from .telegram_cmd_ai import (
|
| 35 |
_cmd_do, _cmd_autofix, _cmd_nota, _cmd_cerca, _cmd_meteo,
|
| 36 |
_cmd_riepilogo, _cmd_score, _cmd_bench, _cmd_improve,
|
| 37 |
_cmd_git, _cmd_coord, _cmd_scan_now, _cmd_telemetry,
|
|
|
|
| 38 |
)
|
| 39 |
from .telegram_callbacks import _handle_inline, _handle_callback
|
| 40 |
|
|
@@ -100,11 +105,10 @@ async def telegram_webhook(request: Request) -> dict:
|
|
| 100 |
if not goal:
|
| 101 |
await _tg_reply(chat_id,
|
| 102 |
"🚀 <b>Avvia un Task AI</b>\n\n"
|
| 103 |
-
"Scrivi l\'obiettivo dopo il comando:\n
|
| 104 |
-
" <code>/
|
| 105 |
-
" <code>/
|
| 106 |
-
"
|
| 107 |
-
"💡 <i>Oppure scrivi direttamente l\'obiettivo senza nessun comando!</i>",
|
| 108 |
keyboard=_MAIN_KB)
|
| 109 |
else:
|
| 110 |
_t=asyncio.create_task(_cmd_do(chat_id, goal)); _t.add_done_callback(_log_tg_exc)
|
|
@@ -136,6 +140,29 @@ async def telegram_webhook(request: Request) -> dict:
|
|
| 136 |
elif cmd in ("/autofix", "/correzione", "/correggi", "/fix"):
|
| 137 |
fix_hint = text[len(cmd):].strip()
|
| 138 |
_t=asyncio.create_task(_cmd_autofix(chat_id, fix_hint)); _t.add_done_callback(_log_tg_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
elif cmd in ("/chart", "/burndown"):
|
| 140 |
await _tg_reply(chat_id,
|
| 141 |
"📈 <b>Grafici</b> — disponibili nella Dashboard\n"
|
|
@@ -196,9 +223,6 @@ async def telegram_webhook(request: Request) -> dict:
|
|
| 196 |
_t=asyncio.create_task(_cmd_riepilogo(chat_id)); _t.add_done_callback(_log_tg_exc)
|
| 197 |
elif cmd in ("/coord", "/sessioni", "/lock"):
|
| 198 |
_t=asyncio.create_task(_cmd_coord(chat_id)); _t.add_done_callback(_log_tg_exc)
|
| 199 |
-
elif cmd in ("/git", "/log", "/commits"):
|
| 200 |
-
_n = int(text.split()[1]) if len(text.split()) > 1 and text.split()[1].isdigit() else 5
|
| 201 |
-
_t=asyncio.create_task(_cmd_git(chat_id, _n)); _t.add_done_callback(_log_tg_exc)
|
| 202 |
# ── Reply Keyboard (testo pulsanti persistenti) ────────────────────────────
|
| 203 |
elif text == "🚀 Lancia Task":
|
| 204 |
await _tg_reply(chat_id, "🚀 <b>Task AI</b> — scegli un'azione:", keyboard=_TASK_MENU_KB)
|
|
@@ -218,8 +242,8 @@ async def telegram_webhook(request: Request) -> dict:
|
|
| 218 |
"<code>che differenza c'è tra asyncio.Task e asyncio.gather?</code>",
|
| 219 |
keyboard=_BACK_KB)
|
| 220 |
else:
|
| 221 |
-
# Testo libero →
|
| 222 |
-
_t=asyncio.create_task(
|
| 223 |
|
| 224 |
return {"ok": True}
|
| 225 |
|
|
@@ -233,15 +257,16 @@ async def _setup_bot_commands(token: str) -> dict:
|
|
| 233 |
results: dict = {}
|
| 234 |
base = f"https://api.telegram.org/bot{token}"
|
| 235 |
commands = [
|
| 236 |
-
{"command": "
|
| 237 |
-
{"command": "
|
| 238 |
-
{"command": "
|
| 239 |
-
{"command": "
|
| 240 |
-
{"command": "
|
| 241 |
-
{"command": "
|
| 242 |
-
{"command": "
|
| 243 |
-
{"command": "
|
| 244 |
-
{"command": "
|
|
|
|
| 245 |
]
|
| 246 |
async with httpx.AsyncClient(timeout=12.0) as c:
|
| 247 |
r = await c.post(f"{base}/setMyCommands", json={"commands": commands})
|
|
|
|
| 2 |
|
| 3 |
Riceve aggiornamenti dal bot Telegram via webhook e risponde a comandi:
|
| 4 |
/start /help — menu + lista comandi
|
| 5 |
+
/status /s — dashboard unificata: 4 nodi HF + Railway + CF Pages + task
|
| 6 |
+
/nodes — ping live cluster con latenza ms
|
| 7 |
/tasks — ultimi task con elapsed time
|
| 8 |
+
/task /do — lancia un nuovo task tramite loop
|
| 9 |
+
/logs — log ERROR/WARNING Railway
|
| 10 |
+
/fix — autofix automatico errori
|
| 11 |
+
/deploy — trigger deploy Cloudflare Pages
|
| 12 |
+
/restart A|B|C|D|all — riavvia nodo HF Space
|
| 13 |
+
/commits — ultimi commit GitHub
|
| 14 |
+
callback_query — gestisce inline buttons
|
| 15 |
|
| 16 |
ARCHITETTURA: il bot usa getUpdates polling via scripts/session-daemon.mjs.
|
| 17 |
Il webhook NON è registrato su Telegram — l'endpoint /webhook/setup è
|
|
|
|
| 29 |
)
|
| 30 |
from .telegram_keyboards import (
|
| 31 |
_MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _LAST_GOAL,
|
| 32 |
+
_WEBAPP_KB, _TASK_MENU_KB, _PERF_MENU_KB, _DEV_MENU_KB,
|
| 33 |
)
|
| 34 |
from .telegram_cmd_monitoring import (
|
| 35 |
+
_cmd_help, _cmd_logs, _cmd_status, _cmd_nodes, _cmd_commit_summary,
|
| 36 |
+
_cmd_check, _cmd_tasks, _cmd_free_text,
|
| 37 |
)
|
| 38 |
from .telegram_cmd_ai import (
|
| 39 |
_cmd_do, _cmd_autofix, _cmd_nota, _cmd_cerca, _cmd_meteo,
|
| 40 |
_cmd_riepilogo, _cmd_score, _cmd_bench, _cmd_improve,
|
| 41 |
_cmd_git, _cmd_coord, _cmd_scan_now, _cmd_telemetry,
|
| 42 |
+
_cmd_deploy, _cmd_restart_node,
|
| 43 |
)
|
| 44 |
from .telegram_callbacks import _handle_inline, _handle_callback
|
| 45 |
|
|
|
|
| 105 |
if not goal:
|
| 106 |
await _tg_reply(chat_id,
|
| 107 |
"🚀 <b>Avvia un Task AI</b>\n\n"
|
| 108 |
+
"Scrivi l\'obiettivo dopo il comando:\n"
|
| 109 |
+
" <code>/task analizza i bug in providers.py</code>\n"
|
| 110 |
+
" <code>/task ottimizza le query Supabase lente</code>\n\n"
|
| 111 |
+
"💡 <b>Tip:</b> <i>puoi anche scrivere direttamente senza /task!</i>",
|
|
|
|
| 112 |
keyboard=_MAIN_KB)
|
| 113 |
else:
|
| 114 |
_t=asyncio.create_task(_cmd_do(chat_id, goal)); _t.add_done_callback(_log_tg_exc)
|
|
|
|
| 140 |
elif cmd in ("/autofix", "/correzione", "/correggi", "/fix"):
|
| 141 |
fix_hint = text[len(cmd):].strip()
|
| 142 |
_t=asyncio.create_task(_cmd_autofix(chat_id, fix_hint)); _t.add_done_callback(_log_tg_exc)
|
| 143 |
+
# ── Nuovi comandi infrastruttura ───────────────────────────────────────────
|
| 144 |
+
elif cmd in ("/s",):
|
| 145 |
+
_t=asyncio.create_task(_cmd_status(chat_id)); _t.add_done_callback(_log_tg_exc)
|
| 146 |
+
elif cmd in ("/nodes", "/cluster", "/nodi"):
|
| 147 |
+
_t=asyncio.create_task(_cmd_nodes(chat_id)); _t.add_done_callback(_log_tg_exc)
|
| 148 |
+
elif cmd in ("/deploy", "/cf", "/build"):
|
| 149 |
+
_t=asyncio.create_task(_cmd_deploy(chat_id)); _t.add_done_callback(_log_tg_exc)
|
| 150 |
+
elif cmd in ("/restart", "/riavvia"):
|
| 151 |
+
node = text[len(cmd):].strip()
|
| 152 |
+
_t=asyncio.create_task(_cmd_restart_node(chat_id, node)); _t.add_done_callback(_log_tg_exc)
|
| 153 |
+
elif cmd in ("/commits", "/commit", "/git", "/log"):
|
| 154 |
+
_n = int(text.split()[1]) if len(text.split()) > 1 and text.split()[1].isdigit() else 5
|
| 155 |
+
_t=asyncio.create_task(_cmd_git(chat_id, _n)); _t.add_done_callback(_log_tg_exc)
|
| 156 |
+
elif cmd in ("/task",):
|
| 157 |
+
goal = text[len(cmd):].strip()
|
| 158 |
+
if not goal:
|
| 159 |
+
await _tg_reply(chat_id,
|
| 160 |
+
"🚀 <b>Lancia Task AI</b>\n\nUsa: <code>/task <obiettivo></code>\n\nEsempi:\n"
|
| 161 |
+
" <code>/task analizza i bug in providers.py</code>\n"
|
| 162 |
+
" <code>/task ottimizza le query Supabase più lente</code>",
|
| 163 |
+
keyboard=_MAIN_KB)
|
| 164 |
+
else:
|
| 165 |
+
_t=asyncio.create_task(_cmd_do(chat_id, goal)); _t.add_done_callback(_log_tg_exc)
|
| 166 |
elif cmd in ("/chart", "/burndown"):
|
| 167 |
await _tg_reply(chat_id,
|
| 168 |
"📈 <b>Grafici</b> — disponibili nella Dashboard\n"
|
|
|
|
| 223 |
_t=asyncio.create_task(_cmd_riepilogo(chat_id)); _t.add_done_callback(_log_tg_exc)
|
| 224 |
elif cmd in ("/coord", "/sessioni", "/lock"):
|
| 225 |
_t=asyncio.create_task(_cmd_coord(chat_id)); _t.add_done_callback(_log_tg_exc)
|
|
|
|
|
|
|
|
|
|
| 226 |
# ── Reply Keyboard (testo pulsanti persistenti) ────────────────────────────
|
| 227 |
elif text == "🚀 Lancia Task":
|
| 228 |
await _tg_reply(chat_id, "🚀 <b>Task AI</b> — scegli un'azione:", keyboard=_TASK_MENU_KB)
|
|
|
|
| 242 |
"<code>che differenza c'è tra asyncio.Task e asyncio.gather?</code>",
|
| 243 |
keyboard=_BACK_KB)
|
| 244 |
else:
|
| 245 |
+
# Testo libero → routing intelligente con conferma se goal lungo
|
| 246 |
+
_t=asyncio.create_task(_cmd_free_text(chat_id, text)); _t.add_done_callback(_log_tg_exc)
|
| 247 |
|
| 248 |
return {"ok": True}
|
| 249 |
|
|
|
|
| 257 |
results: dict = {}
|
| 258 |
base = f"https://api.telegram.org/bot{token}"
|
| 259 |
commands = [
|
| 260 |
+
{"command": "status", "description": "📊 Dashboard cluster + deploy + task"},
|
| 261 |
+
{"command": "nodes", "description": "🖥 Ping live tutti i nodi con latenza"},
|
| 262 |
+
{"command": "task", "description": "🚀 Lancia task AI — /task <obiettivo>"},
|
| 263 |
+
{"command": "tasks", "description": "📋 Ultimi task con stato"},
|
| 264 |
+
{"command": "logs", "description": "📋 Log errori recenti Railway"},
|
| 265 |
+
{"command": "fix", "description": "🔧 AutoFix automatico errori"},
|
| 266 |
+
{"command": "deploy", "description": "☁️ Trigger deploy Cloudflare Pages"},
|
| 267 |
+
{"command": "restart", "description": "🔄 Restart nodo HF — /restart A|B|C|D|all"},
|
| 268 |
+
{"command": "commits", "description": "📝 Ultimi commit GitHub"},
|
| 269 |
+
{"command": "help", "description": "❓ Menu principale"},
|
| 270 |
]
|
| 271 |
async with httpx.AsyncClient(timeout=12.0) as c:
|
| 272 |
r = await c.post(f"{base}/setMyCommands", json={"commands": commands})
|
main.py
CHANGED
|
@@ -29,7 +29,7 @@ from api.state import get_env_secret # P41
|
|
| 29 |
_setup_structured_log()
|
| 30 |
import logging as _boot_logger; _boot_logger.getLogger('agente_ai').info('BOOT: importing FastAPI...')
|
| 31 |
|
| 32 |
-
app = FastAPI(title='Agente AI', version='3.
|
| 33 |
_logger = logging.getLogger('agente_ai')
|
| 34 |
|
| 35 |
# S274-SEC3: INTERNAL_TOKEN — genera casuale al boot se non configurato.
|
|
@@ -214,6 +214,7 @@ from api.state_sync import router as _state_sync_router # S901: UltraVS
|
|
| 214 |
from api.job_queue import router as _jq_router # S-DUAL-2: /api/jq/** Redis coordination
|
| 215 |
from agents.skill_tracker import skill_router as _skill_tracker_router # P17-B2: POST /skill-record + DELETE /skill-stats
|
| 216 |
from api.mcp import router as _mcp_router # P19-B3: MCP JSON-RPC 2.0 server
|
|
|
|
| 217 |
try:
|
| 218 |
from service_handlers.semantic_memory_routes import router as _sem_mem_router # S766-MEM
|
| 219 |
_has_sem_mem = True
|
|
@@ -261,6 +262,7 @@ app.include_router(_integrity_router) # P41: /api/integrity/**
|
|
| 261 |
if _skill_tracker_router is not None:
|
| 262 |
app.include_router(_skill_tracker_router) # P17-B2: /api/agent/skill-record + /api/agent/skill-stats (DELETE)
|
| 263 |
app.include_router(_mcp_router) # P19-B3: /api/mcp — MCP JSON-RPC 2.0
|
|
|
|
| 264 |
if _has_sem_mem:
|
| 265 |
app.include_router(_sem_mem_router) # S766-MEM: /api/semantic-memory/**
|
| 266 |
# (memory/sync router montato in _on_startup)
|
|
@@ -408,7 +410,7 @@ async def _startup_warmup() -> None:
|
|
| 408 |
# P17-B4: pip pre-warm — importa i 20 moduli più usati dagli script sandbox
|
| 409 |
# così la prima exec utente non paga il costo di import (~30-200ms/modulo).
|
| 410 |
# Silenzioso: se non installato, skip.
|
| 411 |
-
import importlib
|
| 412 |
_PIP_PREWARM = [
|
| 413 |
"numpy", "pandas", "matplotlib", "requests", "httpx",
|
| 414 |
"json", "re", "os", "sys", "math",
|
|
@@ -417,7 +419,7 @@ async def _startup_warmup() -> None:
|
|
| 417 |
]
|
| 418 |
for _pkg in _PIP_PREWARM:
|
| 419 |
try:
|
| 420 |
-
|
| 421 |
except Exception:
|
| 422 |
pass
|
| 423 |
_logger.info("BOOT: pip pre-warm %d modules done", len(_PIP_PREWARM))
|
|
@@ -431,4 +433,3 @@ else:
|
|
| 431 |
_logger.warning('BOOT: no frontend at %s', _STATIC_DIR)
|
| 432 |
|
| 433 |
_logger.info('BOOT: main.py v%s ready — %s routes registered ✓', app.version, len(app.routes))
|
| 434 |
-
|
|
|
|
| 29 |
_setup_structured_log()
|
| 30 |
import logging as _boot_logger; _boot_logger.getLogger('agente_ai').info('BOOT: importing FastAPI...')
|
| 31 |
|
| 32 |
+
app = FastAPI(title='Agente AI', version='3.6.0')
|
| 33 |
_logger = logging.getLogger('agente_ai')
|
| 34 |
|
| 35 |
# S274-SEC3: INTERNAL_TOKEN — genera casuale al boot se non configurato.
|
|
|
|
| 214 |
from api.job_queue import router as _jq_router # S-DUAL-2: /api/jq/** Redis coordination
|
| 215 |
from agents.skill_tracker import skill_router as _skill_tracker_router # P17-B2: POST /skill-record + DELETE /skill-stats
|
| 216 |
from api.mcp import router as _mcp_router # P19-B3: MCP JSON-RPC 2.0 server
|
| 217 |
+
from api.ads_manager import router as _ads_router # ADS-MANAGER: /api/ads/**
|
| 218 |
try:
|
| 219 |
from service_handlers.semantic_memory_routes import router as _sem_mem_router # S766-MEM
|
| 220 |
_has_sem_mem = True
|
|
|
|
| 262 |
if _skill_tracker_router is not None:
|
| 263 |
app.include_router(_skill_tracker_router) # P17-B2: /api/agent/skill-record + /api/agent/skill-stats (DELETE)
|
| 264 |
app.include_router(_mcp_router) # P19-B3: /api/mcp — MCP JSON-RPC 2.0
|
| 265 |
+
app.include_router(_ads_router) # ADS-MANAGER: /api/ads/**
|
| 266 |
if _has_sem_mem:
|
| 267 |
app.include_router(_sem_mem_router) # S766-MEM: /api/semantic-memory/**
|
| 268 |
# (memory/sync router montato in _on_startup)
|
|
|
|
| 410 |
# P17-B4: pip pre-warm — importa i 20 moduli più usati dagli script sandbox
|
| 411 |
# così la prima exec utente non paga il costo di import (~30-200ms/modulo).
|
| 412 |
# Silenzioso: se non installato, skip.
|
| 413 |
+
import importlib
|
| 414 |
_PIP_PREWARM = [
|
| 415 |
"numpy", "pandas", "matplotlib", "requests", "httpx",
|
| 416 |
"json", "re", "os", "sys", "math",
|
|
|
|
| 419 |
]
|
| 420 |
for _pkg in _PIP_PREWARM:
|
| 421 |
try:
|
| 422 |
+
importlib.import_module(_pkg)
|
| 423 |
except Exception:
|
| 424 |
pass
|
| 425 |
_logger.info("BOOT: pip pre-warm %d modules done", len(_PIP_PREWARM))
|
|
|
|
| 433 |
_logger.warning('BOOT: no frontend at %s', _STATIC_DIR)
|
| 434 |
|
| 435 |
_logger.info('BOOT: main.py v%s ready — %s routes registered ✓', app.version, len(app.routes))
|
|
|
models/ai_client.py
CHANGED
|
@@ -93,11 +93,10 @@ _MODEL_OUTPUT_LIMITS: dict[str, int] = {
|
|
| 93 |
"DeepSeek-V3.2": 8192, # 32K ctx
|
| 94 |
"MiniMax-M2.7": 32768, # 196K ctx!
|
| 95 |
"gemma-4-31B-it": 16384, # 131K ctx
|
| 96 |
-
# Gemini
|
| 97 |
-
"gemini-
|
| 98 |
-
"gemini-
|
| 99 |
-
"gemini-
|
| 100 |
-
"gemini-3.1-flash-lite-preview": 16384,
|
| 101 |
# Groq 2026 — verificati live
|
| 102 |
"openai/gpt-oss-120b": 8192, # Groq GPT-OSS 120B
|
| 103 |
"openai/gpt-oss-20b": 4096, # Groq GPT-OSS 20B
|
|
@@ -455,7 +454,7 @@ class AIClient:
|
|
| 455 |
name=f"openrouter{suffix}",
|
| 456 |
api_key=key,
|
| 457 |
base_url="https://openrouter.ai/api/v1",
|
| 458 |
-
default_model=os.getenv("OPENROUTER_MODEL", "
|
| 459 |
))
|
| 460 |
|
| 461 |
# ── HUGGINGFACE: Qwen2.5-Coder-32B ────────────────────────────────────
|
|
|
|
| 93 |
"DeepSeek-V3.2": 8192, # 32K ctx
|
| 94 |
"MiniMax-M2.7": 32768, # 196K ctx!
|
| 95 |
"gemma-4-31B-it": 16384, # 131K ctx
|
| 96 |
+
# Gemini 2.x/2.5 — verificati luglio 2026 (GAP-DEP-GEMINI31LITE fix)
|
| 97 |
+
"gemini-2.5-flash": 65536,
|
| 98 |
+
"gemini-2.5-flash-lite": 32768,
|
| 99 |
+
"gemini-2.0-flash-lite": 32768,
|
|
|
|
| 100 |
# Groq 2026 — verificati live
|
| 101 |
"openai/gpt-oss-120b": 8192, # Groq GPT-OSS 120B
|
| 102 |
"openai/gpt-oss-20b": 4096, # Groq GPT-OSS 20B
|
|
|
|
| 454 |
name=f"openrouter{suffix}",
|
| 455 |
api_key=key,
|
| 456 |
base_url="https://openrouter.ai/api/v1",
|
| 457 |
+
default_model=os.getenv("OPENROUTER_MODEL", "nvidia/nemotron-3-nano-30b-a3b:free"),
|
| 458 |
))
|
| 459 |
|
| 460 |
# ── HUGGINGFACE: Qwen2.5-Coder-32B ────────────────────────────────────
|
models/role_router.py
CHANGED
|
@@ -1,33 +1,50 @@
|
|
| 1 |
"""
|
| 2 |
-
role_router.py — Multi-model role routing (
|
| 3 |
|
| 4 |
-
BENCHMARK RESULTS 2026-
|
| 5 |
-
100% qualità (ordinati per TTFT):
|
| 6 |
-
#1
|
| 7 |
-
#2
|
| 8 |
-
#3 Groq / llama-3.3-70b-versatile
|
| 9 |
-
#4
|
| 10 |
-
#5 Groq /
|
| 11 |
-
#6
|
| 12 |
-
#7
|
| 13 |
-
#8
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
RESEARCHER → Gemini 2.5-flash (599ms, 67% — math prompt-sensitive)
|
| 22 |
-
REASONER → Cerebras gpt-oss-120b (207ms, 100%, reasoning model → max_tokens≥500)
|
| 23 |
-
SAMBANOVA → SambaNova DeepSeek-V3.1 (482ms, 100%)
|
| 24 |
-
DEFAULT → AIClient() primary (llama-3.3-70b-versatile o primo disponibile)
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"""
|
| 28 |
from __future__ import annotations
|
| 29 |
|
| 30 |
import os
|
|
|
|
| 31 |
from enum import Enum
|
| 32 |
from typing import Any
|
| 33 |
|
|
@@ -35,17 +52,29 @@ import logging
|
|
| 35 |
_logger = logging.getLogger("models.role_router")
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
class Role(str, Enum):
|
| 39 |
-
FAST
|
| 40 |
-
ARCHITECT
|
| 41 |
-
CODER
|
| 42 |
-
TESTER
|
| 43 |
-
CONTEXT
|
| 44 |
-
DEFAULT
|
| 45 |
-
RESEARCHER
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
class RoleRouter:
|
|
@@ -69,15 +98,23 @@ class RoleRouter:
|
|
| 69 |
return RoleRouter._tester_client()
|
| 70 |
if role == Role.RESEARCHER:
|
| 71 |
return RoleRouter._researcher_client()
|
|
|
|
|
|
|
| 72 |
if role == Role.REASONER:
|
| 73 |
return RoleRouter._reasoner_client()
|
|
|
|
|
|
|
| 74 |
if role == Role.SAMBANOVA:
|
| 75 |
return RoleRouter._sambanova_client()
|
| 76 |
if role == Role.NVIDIA:
|
| 77 |
return RoleRouter._nvidia_client()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
except Exception as _exc:
|
| 79 |
_logger.warning("[role_router] GAP-ROUT: fallback to default AIClient — role=%s raised %s: %s",
|
| 80 |
-
role.value, type(_exc).__name__, _exc)
|
| 81 |
from models.ai_client import AIClient
|
| 82 |
return AIClient()
|
| 83 |
|
|
@@ -85,7 +122,7 @@ class RoleRouter:
|
|
| 85 |
|
| 86 |
@staticmethod
|
| 87 |
def _fast_client() -> Any:
|
| 88 |
-
"""Groq llama-3.1-8b-instant —
|
| 89 |
Usato per: greetings, calcoli semplici, identity, domande 1-liner."""
|
| 90 |
from models.ai_client import AIClient, ProviderConfig
|
| 91 |
groq_key = os.getenv("GROQ_API_KEY")
|
|
@@ -98,7 +135,7 @@ class RoleRouter:
|
|
| 98 |
base_url="https://api.groq.com/openai/v1",
|
| 99 |
default_model=os.getenv("GROQ_FAST_MODEL", "llama-3.1-8b-instant"),
|
| 100 |
)
|
| 101 |
-
rest = [p for p in client.providers if p.name not in ("groq", "groq-fast"
|
| 102 |
client.providers = [fast, *rest]
|
| 103 |
client.provider_name = fast.name
|
| 104 |
client.default_model = fast.default_model
|
|
@@ -107,101 +144,86 @@ class RoleRouter:
|
|
| 107 |
|
| 108 |
@staticmethod
|
| 109 |
def _architect_client() -> Any:
|
| 110 |
-
"""
|
| 111 |
-
Fallback 1:
|
|
|
|
| 112 |
from models.ai_client import AIClient, ProviderConfig
|
| 113 |
-
# NVIDIA NIM — nemotron-3-ultra-550b-a55b: 1M ctx, 550B params, free tier
|
| 114 |
-
nvidia_key = os.getenv("NVIDIA_API_KEY")
|
| 115 |
-
if nvidia_key:
|
| 116 |
-
client = AIClient()
|
| 117 |
-
nvidia = ProviderConfig(
|
| 118 |
-
name="nvidia-architect",
|
| 119 |
-
api_key=nvidia_key,
|
| 120 |
-
base_url="https://integrate.api.nvidia.com/v1",
|
| 121 |
-
default_model=os.getenv("NVIDIA_ARCHITECT_MODEL", "nvidia/nemotron-3-ultra-550b-a55b"),
|
| 122 |
-
)
|
| 123 |
-
rest = [p for p in client.providers if not p.name.startswith("nvidia")]
|
| 124 |
-
client.providers = [nvidia, *rest]
|
| 125 |
-
client.provider_name = nvidia.name
|
| 126 |
-
client.default_model = nvidia.default_model
|
| 127 |
-
client.client = client._client_for(nvidia)
|
| 128 |
-
return client
|
| 129 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 130 |
-
model = os.getenv("ARCHITECT_MODEL", "meta-llama/llama-4-scout-17b-16e-instruct")
|
| 131 |
if groq_key:
|
| 132 |
client = AIClient()
|
| 133 |
architect = ProviderConfig(
|
| 134 |
name="groq-architect",
|
| 135 |
api_key=groq_key,
|
| 136 |
base_url="https://api.groq.com/openai/v1",
|
| 137 |
-
default_model=
|
| 138 |
)
|
| 139 |
-
rest = [p for p in client.providers if p.name
|
| 140 |
client.providers = [architect, *rest]
|
| 141 |
client.provider_name = architect.name
|
| 142 |
client.default_model = architect.default_model
|
| 143 |
client.client = client._client_for(architect)
|
| 144 |
return client
|
| 145 |
-
# Fallback:
|
| 146 |
-
|
| 147 |
-
if
|
| 148 |
client = AIClient()
|
| 149 |
-
|
| 150 |
name="openrouter-architect",
|
| 151 |
-
api_key=
|
| 152 |
base_url="https://openrouter.ai/api/v1",
|
| 153 |
-
default_model="
|
| 154 |
)
|
| 155 |
rest = [p for p in client.providers if not p.name.startswith("openrouter")]
|
| 156 |
-
client.providers = [
|
| 157 |
-
client.provider_name =
|
| 158 |
-
client.default_model =
|
| 159 |
-
client.client = client._client_for(
|
| 160 |
return client
|
| 161 |
return AIClient()
|
| 162 |
|
| 163 |
@staticmethod
|
| 164 |
def _coder_client() -> Any:
|
| 165 |
-
"""Groq
|
| 166 |
-
|
| 167 |
-
Fallback:
|
| 168 |
from models.ai_client import AIClient, ProviderConfig
|
| 169 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 170 |
-
model = os.getenv("CODER_MODEL", "llama-3.3-70b-versatile")
|
| 171 |
if groq_key:
|
| 172 |
client = AIClient()
|
| 173 |
coder = ProviderConfig(
|
| 174 |
name="groq-coder",
|
| 175 |
api_key=groq_key,
|
| 176 |
base_url="https://api.groq.com/openai/v1",
|
| 177 |
-
default_model=
|
| 178 |
)
|
| 179 |
-
rest = [p for p in client.providers if p.name
|
| 180 |
client.providers = [coder, *rest]
|
| 181 |
client.provider_name = coder.name
|
| 182 |
client.default_model = coder.default_model
|
| 183 |
client.client = client._client_for(coder)
|
| 184 |
return client
|
|
|
|
| 185 |
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
| 186 |
if openrouter_key:
|
| 187 |
client = AIClient()
|
| 188 |
-
|
| 189 |
name="openrouter-coder",
|
| 190 |
api_key=openrouter_key,
|
| 191 |
base_url="https://openrouter.ai/api/v1",
|
| 192 |
-
default_model="
|
| 193 |
)
|
| 194 |
rest = [p for p in client.providers if not p.name.startswith("openrouter")]
|
| 195 |
-
client.providers = [
|
| 196 |
-
client.provider_name =
|
| 197 |
-
client.default_model =
|
| 198 |
-
client.client = client._client_for(
|
| 199 |
return client
|
| 200 |
return AIClient()
|
| 201 |
|
| 202 |
@staticmethod
|
| 203 |
def _researcher_client() -> Any:
|
| 204 |
-
"""Gemini 2.5-flash — TTFT
|
|
|
|
| 205 |
from models.ai_client import AIClient, ProviderConfig
|
| 206 |
gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
| 207 |
if not gemini_key:
|
|
@@ -220,22 +242,46 @@ class RoleRouter:
|
|
| 220 |
client.client = client._client_for(researcher)
|
| 221 |
return client
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
@staticmethod
|
| 224 |
def _reasoner_client() -> Any:
|
| 225 |
-
"""Cerebras
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
Fallback: _coder_client (Groq
|
| 229 |
from models.ai_client import AIClient, ProviderConfig
|
| 230 |
-
cerebras_key = os.getenv("CEREBRAS_API_KEY")
|
| 231 |
if not cerebras_key:
|
| 232 |
return RoleRouter._coder_client()
|
| 233 |
client = AIClient()
|
|
|
|
| 234 |
reasoner = ProviderConfig(
|
| 235 |
name="cerebras-reasoner",
|
| 236 |
api_key=cerebras_key,
|
| 237 |
base_url="https://api.cerebras.ai/v1",
|
| 238 |
-
default_model=os.getenv("CEREBRAS_MODEL", "
|
| 239 |
)
|
| 240 |
rest = [p for p in client.providers if not p.name.startswith("cerebras")]
|
| 241 |
client.providers = [reasoner, *rest]
|
|
@@ -244,10 +290,32 @@ class RoleRouter:
|
|
| 244 |
client.client = client._client_for(reasoner)
|
| 245 |
return client
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
@staticmethod
|
| 248 |
def _sambanova_client() -> Any:
|
| 249 |
"""SambaNova DeepSeek-V3.1 — 482ms TTFT, 100% qualità (bench 2026-06-14).
|
| 250 |
-
gemma-4-31B-it: 100% ma 2132ms. Meta-Llama: rate-limited. gpt-oss-120b: ERR.
|
| 251 |
Fallback: _architect_client (Groq) se SAMBANOVA_API_KEY mancante."""
|
| 252 |
from models.ai_client import AIClient, ProviderConfig
|
| 253 |
sn_key = os.getenv("SAMBANOVA_API_KEY")
|
|
@@ -289,6 +357,53 @@ class RoleRouter:
|
|
| 289 |
client.client = client._client_for(nvidia)
|
| 290 |
return client
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
@staticmethod
|
| 293 |
def _tester_client() -> Any:
|
| 294 |
"""Groq llama-3.1-8b-instant — fast, sufficiente per test gen e debug hints."""
|
|
|
|
| 1 |
"""
|
| 2 |
+
role_router.py — Multi-model role routing (aggiornato 2026-07-03 benchmark bot-token)
|
| 3 |
|
| 4 |
+
BENCHMARK RESULTS 2026-07-03 (bot-dedicated tokens, 32 modelli × test parallelo):
|
| 5 |
+
✅ 100% qualità (ordinati per TTFT):
|
| 6 |
+
#1 Cerebras / gemma-4-31b — 236ms 100% ← REASONER PRIMARY (nuovo)
|
| 7 |
+
#2 Groq / qwen3-32b — 325ms 100% ← CODER PRIMARY (strip <think>)
|
| 8 |
+
#3 Groq / llama-3.3-70b-versatile — 313ms 100%
|
| 9 |
+
#4 Groq / llama-3.1-8b-instant — 332ms 100%
|
| 10 |
+
#5 Groq / llama-4-scout-17b — 395ms 100%
|
| 11 |
+
#6 Gemini / 3.1-flash-lite — 742ms 100% ← GEMINI_FAST (nuovo)
|
| 12 |
+
#7 Gemini / 2.5-flash — 813ms 100%
|
| 13 |
+
#8 OR / nemotron-3-super-120b:free — 864ms 100% ← OR primary (era gpt-oss a 5920ms!)
|
| 14 |
+
#9 Groq / compound-mini — 891ms 100%
|
| 15 |
+
#10 OR / nemotron-3-ultra-550b:free — 922ms 100% ← 1M ctx, ARCHITECT fallback
|
| 16 |
+
#11 OR / gpt-oss-120b:free — 5920ms 100% (slow, ultimo fallback)
|
| 17 |
|
| 18 |
+
❌ Non disponibili con token bot attuali:
|
| 19 |
+
- GitHub Models (GH_TOKEN_BOT: bad credentials — mancano permessi GitHub Models)
|
| 20 |
+
- Cloudflare Workers AI (CF_API_TOKEN_BOT: auth error 10000)
|
| 21 |
+
- SambaNova (SAMBANOVA_API_KEY non bot-dedicata)
|
| 22 |
+
- Cerebras gpt-oss-120b (429 rate-limit durante test — gemma-4-31b ora primario)
|
| 23 |
+
- Cerebras zai-glm-4.7 (content vuoto: genera solo "reasoning" field, skip)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
Role assignments 2026-07-03:
|
| 26 |
+
FAST → Groq llama-3.1-8b-instant (332ms, 100%)
|
| 27 |
+
ARCHITECT → Groq llama-4-scout-17b 10M ctx (395ms, 100%) + OR nemotron-ultra 1M ctx fallback
|
| 28 |
+
CODER → Groq qwen3-32b (325ms, 100%, strip <think>)
|
| 29 |
+
TESTER → Groq llama-3.1-8b-instant
|
| 30 |
+
CONTEXT → Groq llama-3.1-8b-instant
|
| 31 |
+
RESEARCHER → Gemini 2.5-flash (813ms, 100%)
|
| 32 |
+
GEMINI_FAST → Gemini 3.1-flash-lite (742ms, 100%) ← nuovo
|
| 33 |
+
REASONER → Cerebras gemma-4-31b (236ms, 100%) ← aggiornato da gpt-oss-120b
|
| 34 |
+
OPENROUTER → OR nemotron-3-super-120b:free (864ms, 100%) ← aggiornato da gpt-oss-120b (5920ms)
|
| 35 |
+
SAMBANOVA → SambaNova DeepSeek-V3.1 (482ms, 100%) [chiave condivisa, non bot-dedicata]
|
| 36 |
+
NVIDIA → NVIDIA NIM nemotron-3-super-120b-a12b
|
| 37 |
+
GITHUB → GitHub Models gpt-4o-mini [fallback a CODER se bad credentials]
|
| 38 |
+
CLOUDFLARE → CF Workers AI llama-3.3-70b [fallback a FAST se auth error]
|
| 39 |
+
DEFAULT → AIClient() primary
|
| 40 |
+
|
| 41 |
+
Nota CODER qwen3: stripping automatico <think>...</think> via strip_thinking().
|
| 42 |
+
Nota CEREBRAS: legge CEREBRAS_API_KEY o CERBERUS_TOKEN (alias bot-dedicato su Railway).
|
| 43 |
"""
|
| 44 |
from __future__ import annotations
|
| 45 |
|
| 46 |
import os
|
| 47 |
+
import re
|
| 48 |
from enum import Enum
|
| 49 |
from typing import Any
|
| 50 |
|
|
|
|
| 52 |
_logger = logging.getLogger("models.role_router")
|
| 53 |
|
| 54 |
|
| 55 |
+
def strip_thinking(text: str) -> str:
|
| 56 |
+
"""Rimuove blocchi <think>...</think> dai modelli qwen3 e simili."""
|
| 57 |
+
if not text:
|
| 58 |
+
return text
|
| 59 |
+
cleaned = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
|
| 60 |
+
return cleaned.strip()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
class Role(str, Enum):
|
| 64 |
+
FAST = "fast" # greetings, math semplice, identity — llama-3.1-8b-instant
|
| 65 |
+
ARCHITECT = "architect" # planning, ragionamento complesso — llama-4-scout-17b (10M ctx)
|
| 66 |
+
CODER = "coder" # coding, debug — qwen3-32b (strip <think>)
|
| 67 |
+
TESTER = "tester" # test gen, debug hints — llama-3.1-8b-instant
|
| 68 |
+
CONTEXT = "context" # summarization, context compression — llama-3.1-8b-instant
|
| 69 |
+
DEFAULT = "default" # AIClient() primary
|
| 70 |
+
RESEARCHER = "researcher" # web research + doc synthesis — Gemini 2.5-flash
|
| 71 |
+
GEMINI_FAST = "gemini_fast" # light research, quick synthesis — Gemini 3.1-flash-lite
|
| 72 |
+
REASONER = "reasoner" # throughput massimo — Cerebras gemma-4-31b (236ms)
|
| 73 |
+
OPENROUTER = "openrouter" # OR nemotron-super-120b:free (864ms) — fallback universale
|
| 74 |
+
SAMBANOVA = "sambanova" # DeepSeek-V3.1 via SambaNova (482ms)
|
| 75 |
+
NVIDIA = "nvidia" # NVIDIA NIM — nemotron-3-super-120b-a12b (1M ctx)
|
| 76 |
+
GITHUB = "github" # GitHub Models gpt-4o-mini [fallback a CODER]
|
| 77 |
+
CLOUDFLARE = "cloudflare" # CF Workers AI llama-3.3-70b [fallback a FAST]
|
| 78 |
|
| 79 |
|
| 80 |
class RoleRouter:
|
|
|
|
| 98 |
return RoleRouter._tester_client()
|
| 99 |
if role == Role.RESEARCHER:
|
| 100 |
return RoleRouter._researcher_client()
|
| 101 |
+
if role == Role.GEMINI_FAST:
|
| 102 |
+
return RoleRouter._gemini_fast_client()
|
| 103 |
if role == Role.REASONER:
|
| 104 |
return RoleRouter._reasoner_client()
|
| 105 |
+
if role == Role.OPENROUTER:
|
| 106 |
+
return RoleRouter._openrouter_client()
|
| 107 |
if role == Role.SAMBANOVA:
|
| 108 |
return RoleRouter._sambanova_client()
|
| 109 |
if role == Role.NVIDIA:
|
| 110 |
return RoleRouter._nvidia_client()
|
| 111 |
+
if role == Role.GITHUB:
|
| 112 |
+
return RoleRouter._github_client()
|
| 113 |
+
if role == Role.CLOUDFLARE:
|
| 114 |
+
return RoleRouter._cloudflare_client()
|
| 115 |
except Exception as _exc:
|
| 116 |
_logger.warning("[role_router] GAP-ROUT: fallback to default AIClient — role=%s raised %s: %s",
|
| 117 |
+
role.value, type(_exc).__name__, _exc)
|
| 118 |
from models.ai_client import AIClient
|
| 119 |
return AIClient()
|
| 120 |
|
|
|
|
| 122 |
|
| 123 |
@staticmethod
|
| 124 |
def _fast_client() -> Any:
|
| 125 |
+
"""Groq llama-3.1-8b-instant — 332ms TTFT, 100% qualità.
|
| 126 |
Usato per: greetings, calcoli semplici, identity, domande 1-liner."""
|
| 127 |
from models.ai_client import AIClient, ProviderConfig
|
| 128 |
groq_key = os.getenv("GROQ_API_KEY")
|
|
|
|
| 135 |
base_url="https://api.groq.com/openai/v1",
|
| 136 |
default_model=os.getenv("GROQ_FAST_MODEL", "llama-3.1-8b-instant"),
|
| 137 |
)
|
| 138 |
+
rest = [p for p in client.providers if p.name not in ("groq", "groq-fast")]
|
| 139 |
client.providers = [fast, *rest]
|
| 140 |
client.provider_name = fast.name
|
| 141 |
client.default_model = fast.default_model
|
|
|
|
| 144 |
|
| 145 |
@staticmethod
|
| 146 |
def _architect_client() -> Any:
|
| 147 |
+
"""Groq llama-4-scout-17b (10M ctx) come primario — ottimo per planning e contesto lungo.
|
| 148 |
+
Fallback 1: OR nemotron-ultra-550b:free (922ms, 1M ctx) per max contesto.
|
| 149 |
+
Fallback 2: Groq llama-3.3-70b-versatile."""
|
| 150 |
from models.ai_client import AIClient, ProviderConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
groq_key = os.getenv("GROQ_API_KEY")
|
|
|
|
| 152 |
if groq_key:
|
| 153 |
client = AIClient()
|
| 154 |
architect = ProviderConfig(
|
| 155 |
name="groq-architect",
|
| 156 |
api_key=groq_key,
|
| 157 |
base_url="https://api.groq.com/openai/v1",
|
| 158 |
+
default_model=os.getenv("ARCHITECT_MODEL", "meta-llama/llama-4-scout-17b-16e-instruct"),
|
| 159 |
)
|
| 160 |
+
rest = [p for p in client.providers if not p.name.startswith("groq")]
|
| 161 |
client.providers = [architect, *rest]
|
| 162 |
client.provider_name = architect.name
|
| 163 |
client.default_model = architect.default_model
|
| 164 |
client.client = client._client_for(architect)
|
| 165 |
return client
|
| 166 |
+
# Fallback 1: OR nemotron-ultra (1M ctx) se GROQ non disponibile
|
| 167 |
+
or_key = os.getenv("OPENROUTER_API_KEY")
|
| 168 |
+
if or_key:
|
| 169 |
client = AIClient()
|
| 170 |
+
ultra = ProviderConfig(
|
| 171 |
name="openrouter-architect",
|
| 172 |
+
api_key=or_key,
|
| 173 |
base_url="https://openrouter.ai/api/v1",
|
| 174 |
+
default_model="nvidia/nemotron-3-ultra-550b-a55b:free",
|
| 175 |
)
|
| 176 |
rest = [p for p in client.providers if not p.name.startswith("openrouter")]
|
| 177 |
+
client.providers = [ultra, *rest]
|
| 178 |
+
client.provider_name = ultra.name
|
| 179 |
+
client.default_model = ultra.default_model
|
| 180 |
+
client.client = client._client_for(ultra)
|
| 181 |
return client
|
| 182 |
return AIClient()
|
| 183 |
|
| 184 |
@staticmethod
|
| 185 |
def _coder_client() -> Any:
|
| 186 |
+
"""Groq qwen3-32b — 325ms TTFT, 100% qualità, eccellente per coding.
|
| 187 |
+
NOTA: strip_thinking() rimuove <think>...</think> dall'output.
|
| 188 |
+
Fallback: Groq llama-3.3-70b-versatile → OpenRouter nemotron-super:free."""
|
| 189 |
from models.ai_client import AIClient, ProviderConfig
|
| 190 |
groq_key = os.getenv("GROQ_API_KEY")
|
|
|
|
| 191 |
if groq_key:
|
| 192 |
client = AIClient()
|
| 193 |
coder = ProviderConfig(
|
| 194 |
name="groq-coder",
|
| 195 |
api_key=groq_key,
|
| 196 |
base_url="https://api.groq.com/openai/v1",
|
| 197 |
+
default_model=os.getenv("CODER_MODEL", "qwen/qwen3-32b"),
|
| 198 |
)
|
| 199 |
+
rest = [p for p in client.providers if not p.name.startswith("groq")]
|
| 200 |
client.providers = [coder, *rest]
|
| 201 |
client.provider_name = coder.name
|
| 202 |
client.default_model = coder.default_model
|
| 203 |
client.client = client._client_for(coder)
|
| 204 |
return client
|
| 205 |
+
# Fallback: OpenRouter nemotron-super
|
| 206 |
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
| 207 |
if openrouter_key:
|
| 208 |
client = AIClient()
|
| 209 |
+
coder_or = ProviderConfig(
|
| 210 |
name="openrouter-coder",
|
| 211 |
api_key=openrouter_key,
|
| 212 |
base_url="https://openrouter.ai/api/v1",
|
| 213 |
+
default_model="nvidia/nemotron-3-super-120b-a12b:free",
|
| 214 |
)
|
| 215 |
rest = [p for p in client.providers if not p.name.startswith("openrouter")]
|
| 216 |
+
client.providers = [coder_or, *rest]
|
| 217 |
+
client.provider_name = coder_or.name
|
| 218 |
+
client.default_model = coder_or.default_model
|
| 219 |
+
client.client = client._client_for(coder_or)
|
| 220 |
return client
|
| 221 |
return AIClient()
|
| 222 |
|
| 223 |
@staticmethod
|
| 224 |
def _researcher_client() -> Any:
|
| 225 |
+
"""Gemini 2.5-flash — TTFT 813ms, ottima per research/synthesis/doc analysis.
|
| 226 |
+
Fallback: AIClient() primario se GEMINI_API_KEY mancante."""
|
| 227 |
from models.ai_client import AIClient, ProviderConfig
|
| 228 |
gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
| 229 |
if not gemini_key:
|
|
|
|
| 242 |
client.client = client._client_for(researcher)
|
| 243 |
return client
|
| 244 |
|
| 245 |
+
@staticmethod
|
| 246 |
+
def _gemini_fast_client() -> Any:
|
| 247 |
+
"""Gemini 3.1-flash-lite — 742ms TTFT, più veloce di 2.5-flash.
|
| 248 |
+
Usato per: research leggera, synthesis rapida, task non computazionalmente pesanti.
|
| 249 |
+
Fallback: gemini-2.5-flash → AIClient() primario."""
|
| 250 |
+
from models.ai_client import AIClient, ProviderConfig
|
| 251 |
+
gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
| 252 |
+
if not gemini_key:
|
| 253 |
+
return RoleRouter._researcher_client()
|
| 254 |
+
client = AIClient()
|
| 255 |
+
fast_gem = ProviderConfig(
|
| 256 |
+
name="gemini-fast",
|
| 257 |
+
api_key=gemini_key,
|
| 258 |
+
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
| 259 |
+
default_model="gemini-2.5-flash-lite",
|
| 260 |
+
)
|
| 261 |
+
rest = [p for p in client.providers if not p.name.startswith("gemini")]
|
| 262 |
+
client.providers = [fast_gem, *rest]
|
| 263 |
+
client.provider_name = fast_gem.name
|
| 264 |
+
client.default_model = fast_gem.default_model
|
| 265 |
+
client.client = client._client_for(fast_gem)
|
| 266 |
+
return client
|
| 267 |
+
|
| 268 |
@staticmethod
|
| 269 |
def _reasoner_client() -> Any:
|
| 270 |
+
"""Cerebras gemma-4-31b — 236ms TTFT, 100% qualità (bench 2026-07-03).
|
| 271 |
+
Aggiornato: era gpt-oss-120b (rate-limited). gemma-4-31b più veloce e stabile.
|
| 272 |
+
Legge CEREBRAS_API_KEY o CERBERUS_TOKEN (alias bot-dedicato Railway).
|
| 273 |
+
Fallback: Cerebras gpt-oss-120b → _coder_client (Groq)."""
|
| 274 |
from models.ai_client import AIClient, ProviderConfig
|
| 275 |
+
cerebras_key = os.getenv("CEREBRAS_API_KEY") or os.getenv("CERBERUS_TOKEN")
|
| 276 |
if not cerebras_key:
|
| 277 |
return RoleRouter._coder_client()
|
| 278 |
client = AIClient()
|
| 279 |
+
# Primary: gemma-4-31b (236ms, più stabile)
|
| 280 |
reasoner = ProviderConfig(
|
| 281 |
name="cerebras-reasoner",
|
| 282 |
api_key=cerebras_key,
|
| 283 |
base_url="https://api.cerebras.ai/v1",
|
| 284 |
+
default_model=os.getenv("CEREBRAS_MODEL", "gemma-4-31b"),
|
| 285 |
)
|
| 286 |
rest = [p for p in client.providers if not p.name.startswith("cerebras")]
|
| 287 |
client.providers = [reasoner, *rest]
|
|
|
|
| 290 |
client.client = client._client_for(reasoner)
|
| 291 |
return client
|
| 292 |
|
| 293 |
+
@staticmethod
|
| 294 |
+
def _openrouter_client() -> Any:
|
| 295 |
+
"""OR nemotron-3-super-120b-a12b:free — 864ms TTFT, 100% qualità.
|
| 296 |
+
AGGIORNATO 2026-07-03: era gpt-oss-120b:free (5920ms) → 7× più veloce.
|
| 297 |
+
Fallback: gpt-oss-120b:free (lento ma sempre disponibile)."""
|
| 298 |
+
from models.ai_client import AIClient, ProviderConfig
|
| 299 |
+
or_key = os.getenv("OPENROUTER_API_KEY")
|
| 300 |
+
if not or_key:
|
| 301 |
+
return RoleRouter._coder_client()
|
| 302 |
+
client = AIClient()
|
| 303 |
+
or_cfg = ProviderConfig(
|
| 304 |
+
name="openrouter-primary",
|
| 305 |
+
api_key=or_key,
|
| 306 |
+
base_url="https://openrouter.ai/api/v1",
|
| 307 |
+
default_model="nvidia/nemotron-3-super-120b-a12b:free",
|
| 308 |
+
)
|
| 309 |
+
rest = [p for p in client.providers if not p.name.startswith("openrouter")]
|
| 310 |
+
client.providers = [or_cfg, *rest]
|
| 311 |
+
client.provider_name = or_cfg.name
|
| 312 |
+
client.default_model = or_cfg.default_model
|
| 313 |
+
client.client = client._client_for(or_cfg)
|
| 314 |
+
return client
|
| 315 |
+
|
| 316 |
@staticmethod
|
| 317 |
def _sambanova_client() -> Any:
|
| 318 |
"""SambaNova DeepSeek-V3.1 — 482ms TTFT, 100% qualità (bench 2026-06-14).
|
|
|
|
| 319 |
Fallback: _architect_client (Groq) se SAMBANOVA_API_KEY mancante."""
|
| 320 |
from models.ai_client import AIClient, ProviderConfig
|
| 321 |
sn_key = os.getenv("SAMBANOVA_API_KEY")
|
|
|
|
| 357 |
client.client = client._client_for(nvidia)
|
| 358 |
return client
|
| 359 |
|
| 360 |
+
@staticmethod
|
| 361 |
+
def _github_client() -> Any:
|
| 362 |
+
"""GitHub Models gpt-4o-mini — via models.inference.ai.azure.com.
|
| 363 |
+
NOTA 2026-07-03: GH_TOKEN_BOT richiede permesso 'GitHub Models' abilitato.
|
| 364 |
+
Fallback automatico a _coder_client se credenziali non valide."""
|
| 365 |
+
from models.ai_client import AIClient, ProviderConfig
|
| 366 |
+
gh_key = os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN")
|
| 367 |
+
if not gh_key:
|
| 368 |
+
return RoleRouter._coder_client()
|
| 369 |
+
client = AIClient()
|
| 370 |
+
gh = ProviderConfig(
|
| 371 |
+
name="github-models",
|
| 372 |
+
api_key=gh_key,
|
| 373 |
+
base_url="https://models.inference.ai.azure.com",
|
| 374 |
+
default_model="gpt-4o-mini",
|
| 375 |
+
)
|
| 376 |
+
rest = [p for p in client.providers if not p.name.startswith("github")]
|
| 377 |
+
client.providers = [gh, *rest]
|
| 378 |
+
client.provider_name = gh.name
|
| 379 |
+
client.default_model = gh.default_model
|
| 380 |
+
client.client = client._client_for(gh)
|
| 381 |
+
return client
|
| 382 |
+
|
| 383 |
+
@staticmethod
|
| 384 |
+
def _cloudflare_client() -> Any:
|
| 385 |
+
"""Cloudflare Workers AI @cf/meta/llama-3.3-70b-instruct-fp8-fast.
|
| 386 |
+
NOTA 2026-07-03: CF_API_TOKEN_BOT auth error 10000 — mancano permessi Workers AI.
|
| 387 |
+
Fallback automatico a _fast_client."""
|
| 388 |
+
from models.ai_client import AIClient, ProviderConfig
|
| 389 |
+
cf_key = os.getenv("CF_API_TOKEN")
|
| 390 |
+
cf_account = os.getenv("CF_ACCOUNT_ID", "7ecd58d02d361d654de789dc5e976a6e")
|
| 391 |
+
if not cf_key:
|
| 392 |
+
return RoleRouter._fast_client()
|
| 393 |
+
client = AIClient()
|
| 394 |
+
cf = ProviderConfig(
|
| 395 |
+
name="cloudflare-ai",
|
| 396 |
+
api_key=cf_key,
|
| 397 |
+
base_url=f"https://api.cloudflare.com/client/v4/accounts/{cf_account}/ai/v1",
|
| 398 |
+
default_model="@cf/meta/llama-3.3-70b-instruct-fp8-fast",
|
| 399 |
+
)
|
| 400 |
+
rest = [p for p in client.providers if not p.name.startswith("cloudflare")]
|
| 401 |
+
client.providers = [cf, *rest]
|
| 402 |
+
client.provider_name = cf.name
|
| 403 |
+
client.default_model = cf.default_model
|
| 404 |
+
client.client = client._client_for(cf)
|
| 405 |
+
return client
|
| 406 |
+
|
| 407 |
@staticmethod
|
| 408 |
def _tester_client() -> Any:
|
| 409 |
"""Groq llama-3.1-8b-instant — fast, sufficiente per test gen e debug hints."""
|
requirements.txt
CHANGED
|
@@ -16,5 +16,4 @@ cryptography>=42.0.0
|
|
| 16 |
# Provider SDKs — richiesti da T15 benchmark + client diretti (confermati su HF Space 2026-06-14)
|
| 17 |
aiohttp>=3.9.0
|
| 18 |
groq>=0.11.0
|
| 19 |
-
google-generativeai>=0.8.0
|
| 20 |
# SambaNova (OpenAI-compat, usa openai client) — nessun SDK separato necessario
|
|
|
|
| 16 |
# Provider SDKs — richiesti da T15 benchmark + client diretti (confermati su HF Space 2026-06-14)
|
| 17 |
aiohttp>=3.9.0
|
| 18 |
groq>=0.11.0
|
|
|
|
| 19 |
# SambaNova (OpenAI-compat, usa openai client) — nessun SDK separato necessario
|