Baida07 commited on
Commit
c66a1ba
·
verified ·
1 Parent(s): 9e0066c

sync: 171 file da Baida98/AI@9b697a97 (2026-07-05 14:21 UTC)

Browse files
.env.example CHANGED
@@ -1,226 +1,65 @@
 
1
  # ============================================================
2
- # .env.example Template variabili d'ambiente Agente AI
3
- # Copiare in .env per uso locale. NON committare .env con valori reali.
4
- # Per deploy su HF Spaces: aggiungere come Secrets/Variables nelle impostazioni.
5
  # ============================================================
6
 
7
- # ── Runtime ──────────────────────────────────────────────────
8
  PORT=7860
9
- FRONTEND_DIST=/app/backend/static
10
- APP_PROFILE=hf_spaces_free_remote_kernel
11
- VITE_BACKEND_URL=
12
- VITE_API_BASE_URL=
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
- # ── HuggingFace ───────────────────────────────────────────────
63
- # huggingface.co → Settings → Access Tokens
64
- HF_TOKEN=
65
- HUGGINGFACE_API_KEY=
66
- HUGGINGFACE_TOKEN=
67
- HF_OPENAI_BASE_URL=https://router.huggingface.co/v1
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
- GH_TOKEN=
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
- # ── OpenRouter ────────────────────────────────────────────────
91
- # openrouter.ai/keys
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- NVIDIA_MODEL=nvidia/nemotron-3-super-120b-a12b
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
- # ── E2B (Code Execution Sandbox) ──────���──────────────────────
149
- # e2b.dev/dashboard
150
  E2B_API_KEY=
151
-
152
- # ── Notion ────────────────────────────────────────────────────
153
- # notion.so/my-integrations
154
  NOTION_TOKEN=
155
-
156
- # ── Storage locale ────────────────────────────────────────────
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
- # Groq account B (14.400 req/giorno) load balancing con A
201
- GROQ_API_KEY_3=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
202
- VITE_GROQ_API_KEY_3=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
203
-
204
- # ═══════════════════════════════════════════════════════════════════════════════════════
205
- # ── Nodo C: Memory/RAG/Embeddings — Backend Memoria e Vector Search ──────────────────────
206
- # ═══════════════════════════════════════════════════════════════════════════════════════
207
- # Workload: embeddings, vector search, RAG index, skill memory, conversazioni
208
- SUPABASE_URL_C=https://rsiphwzlokhsxnkcvzos.supabase.co
209
- SUPABASE_ANON_KEY_C=sb_secret_11jeLuJnZBA1IKCAm1ZjiA_QxIyU5u6
210
- SUPABASE_SERVICE_ROLE_KEY_C=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJzaXBod3psb2toc3hua2N2em9zIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjI1OTEyOSwiZXhwIjoyMDk3ODM1MTI5fQ.LxO8ltt-t8SpHf-ZIi1ogcEtcQcPvW1Vyu2W14i1zzM
211
- E2B_API_KEY_C=2f548a79-9210-4037-87fa-4490a6a6b8fe
212
- # RAG/Vector DB — Qdrant per embeddings (opzionale, altrimenti usa Supabase C)
213
- QDRANT_URL_C=
214
- QDRANT_API_KEY_C=
215
-
216
- # ═══════════════════════════════════════════════════════════════════════════════════════
217
- # ── Nodo D: Audit/Logging/Compliance — Event Log e Compliance Records ─────────��────────
218
- # ═══════════════════════════════════════════════════════════════════════════════════════
219
- # Workload: audit trail, event logging, compliance records, immutable logs
220
- SUPABASE_URL_D=https://cyotadpvgaxfdwaovstv.supabase.co
221
- SUPABASE_ANON_KEY_D=sb_secret_aZGjt7d8KWsvIm5-JP4qrg_XrNPBAZE
222
- SUPABASE_SERVICE_ROLE_KEY_D=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImN5b3RhZHB2Z2F4ZmR3YW92c3R2Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjY1NjQ1OSwiZXhwIjoyMDk4MjMyNDU5fQ.FuLweX2H6RKJmC7PbXdctmgzVIcqspq9FSxwFzFwB9o
223
- E2B_API_KEY_D=e2b_9d242366288d5e9e0bb267f31cccc5d49298348c
224
- # Audit log — immutable append-only (opzionale)
225
- AUDIT_LOG_ENABLED=true
226
- AUDIT_LOG_RETENTION_DAYS=90
 
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
- rebuild
 
 
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
- @router.post('/run_loop', deprecated=True)
49
- async def run_loop():
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(req: BrowserOpenRequest, request: Request):
 
 
 
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(req: BrowserActRequest, request: Request):
 
 
 
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(req: QueryRequest, request: Request):
 
 
 
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(req: SendEmailRequest, request: Request) -> SendEmailResponse:
 
 
 
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
- # Costruisci mittente
 
 
 
 
 
 
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(req: ExecRequest, request: Request):
272
- _internal_token = os.getenv('INTERNAL_TOKEN', '')
273
- if _internal_token and request.headers.get('X-Internal-Token') != _internal_token:
274
- raise HTTPException(401, 'Unauthorized')
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(cmd: ShellCmd, request: Request):
351
- _internal_token = os.getenv('INTERNAL_TOKEN', '')
352
- if _internal_token and request.headers.get('X-Internal-Token') != _internal_token:
353
- raise HTTPException(401, 'Unauthorized')
354
  raw = cmd.command.strip()
355
- for bad in BLOCKED_CMDS:
356
- if bad in raw:
357
- raise HTTPException(400, 'Command blocked for safety')
 
 
 
 
 
 
 
 
 
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={**os.environ, 'HOME': tmpdir, 'TMPDIR': tmpdir},
 
 
 
 
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(req: ToolDispatchRequest, request: Request):
 
 
 
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(role: str):
 
 
 
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(job: JobPayload, request: Request):
 
 
 
284
  """S42 — Grid Orchestrator: sottomissione job reale su Redis."""
285
- if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
286
- raise HTTPException(401, "Unauthorized")
287
  if not _redis_ok():
288
  raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
289
 
@@ -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(task_id: str, request: Request):
 
 
 
321
  """S429 — Tool Success Contract: recupero risultato job per taskId."""
322
- if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
323
- raise HTTPException(401, "Unauthorized")
324
  if not _redis_ok():
325
  raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
326
 
 
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(req: ResearchRequest, request: Request):
 
 
 
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(req: ResearchRequest, request: Request):
 
 
 
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() -> list[dict]:
 
 
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(body: TaskCreate) -> dict:
 
 
 
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(task_id: str, body: TaskPatch) -> dict:
 
 
 
 
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(task_id: str) -> None:
 
 
 
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(body: list[dict]) -> dict:
 
 
 
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(task_id: str) -> dict:
 
 
 
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(request: Request) -> dict:
 
 
 
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
- - Non richiede auth se INTERNAL_TOKEN non configurato
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() -> dict:
 
 
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(since_ms: int = 0) -> dict:
 
 
 
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('/api/search')
241
- async def proxy_search(req: SearchRequest):
 
 
 
 
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('/api/fetch-page')
341
- async def proxy_fetch_page(req: FetchPageRequest):
 
 
 
 
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('/api/analyze-image')
368
- async def analyze_image(body: AnalyzeImageRequest, request: Request):
 
 
 
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
- "U0001f680 <b>Railway deploy:</b> avviato automaticamente\n"
375
  f"U0001f517 <a href=\"https://github.com/{gh_repo}/commit/{c_sha}\">Vedi commit</a>",
376
  final=True,
377
  )
@@ -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, _cmd_commit_summary,
5
- _cmd_check, _cmd_tasks, _cmd_git, _cmd_coord,
6
  _cmd_scan_now, _cmd_telemetry
7
  """
8
  from __future__ import annotations
9
  import asyncio, html, logging, os, re, time
 
 
10
 
11
  from .telegram_tg_client import (
12
  _get_bot_token, _tg_reply, _tg_send, _tg_edit,
@@ -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
- """Menu principale: un messaggio pulito + inline keyboard essenziale."""
26
  await _tg_typing(chat_id)
27
- welcome = (
28
- "🤖 <b>Agente AI</b>\n"
29
- "<i>Assistente autonomo per lo sviluppo software</i>\n\n"
30
- "📝 <b>Come usarmi:</b>\n"
31
- "Scrivi qualsiasi obiettivo — lo eseguo autonomamente:\n"
32
- " <code>analizza i bug in providers.py</code>\n"
33
- " <code>ottimizza le query Supabase più lente</code>\n"
34
- " <code>fai autofix degli errori nel log</code>\n\n"
35
- "📌 <b>Comandi rapidi:</b>\n"
36
- " /avvia lancia un task AI\n"
37
- " /statovedi cosa sta facendo\n"
38
- " /salutecontrollo sistema\n"
39
- " /chiedi — domanda veloce all\'AI\n\n"
40
- "⬇️ <b>O scegli dal menu:</b>"
41
- )
42
- await _tg_reply(chat_id, welcome, keyboard=_MAIN_KB)
43
 
44
 
45
  async def _cmd_logs(chat_id: int, level: str = "WARNING") -> None:
@@ -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
- from api.scheduler import _tasks as sched_tasks, _loop_task
119
- sched_ok = _loop_task is not None and not _loop_task.done()
120
- sched_pending = sum(1 for t in sched_tasks.values() if t.get("status") == "pending")
121
- sched_label = "✅ attivo" if sched_ok else "❌ fermo"
 
 
 
122
 
123
- ts_now = time.strftime("%Y-%m-%d %H:%M:%S")
124
- railway_url = os.getenv("RAILWAY_URL","https://ai-production-4c06.up.railway.app")
125
- ry_line = ""
126
  try:
127
- import httpx as _hx
128
- async with _hx.AsyncClient(timeout=4.0) as c:
129
- rv = await c.get(f"{railway_url}/api/info")
130
- if rv.status_code == 200:
131
- rj = rv.json()
132
- ry_line = ("\n🚂 <b>Railway:</b> v" + rj.get("version","?")
133
- + " — " + rj.get("sprint",""))
134
  except Exception:
135
- pass
136
 
137
- # ── NEW-1: HEAD git + ultimo commit ──────────────────────────────────
138
- # Chiama GitHub API con GITHUB_TOKEN (Railway env) timeout 4s, silent fail.
139
- # Mostra: sha corto + prima riga commit message + età ("3h fa").
140
- git_line = ""
141
- try:
142
- import httpx as _hx_g, datetime as _dt
143
- _gh_token = os.getenv("GITHUB_TOKEN", "").strip()
144
- _gh_repo = os.getenv("GITHUB_REPO", "Baida98/AI").strip()
145
- if _gh_token and _gh_repo:
146
- async with _hx_g.AsyncClient(timeout=4.0) as _gc:
147
- _gr = await _gc.get(
148
- f"https://api.github.com/repos/{_gh_repo}/commits/main",
149
- headers={"Authorization": f"Bearer {_gh_token}", "User-Agent": "agente-ai"},
150
- params={"per_page": 1},
151
- )
152
- if _gr.status_code == 200:
153
- _cj = _gr.json()
154
- _sha = (_cj.get("sha") or "")[:7]
155
- _cmsg = ((_cj.get("commit") or {}).get("message") or "").split("\n")[0][:45]
156
- _date = ((_cj.get("commit") or {}).get("committer") or {}).get("date", "")
157
- _age = ""
158
- if _date:
159
- _ts = _dt.datetime.fromisoformat(_date.replace("Z", "+00:00"))
160
- _secs = int((_dt.datetime.now(_dt.timezone.utc) - _ts).total_seconds())
161
- if _secs < 3600: _age = f"{_secs // 60}m fa"
162
- elif _secs < 86400: _age = f"{_secs // 3600}h fa"
163
- else: _age = f"{_secs // 86400}g fa"
164
- git_line = (
165
- f"\n🔀 <b>HEAD:</b> <code>{html.escape(_sha)}</code>"
166
- f" {html.escape(_cmsg)} <i>({_age})</i>"
167
- )
168
- except Exception:
169
- pass
170
 
171
- # ── NEW-2: task live — goal + step corrente dal loop_registry ────────
172
- # Per il primo task RUNNING: mostra goal + azione corrente (dal buffer SSE)
173
- # + tempo trascorso. Zero overhead se non c'è task in corso.
174
- live_line = ""
175
- try:
176
- _running_list = [t for t in _agent_tasks.values() if t.get("status") == "RUNNING"]
177
- if _running_list:
178
- import json as _lj
179
- _rt = _running_list[0]
180
- _tid = _rt.get("id") or _rt.get("task_id") or ""
181
- _goal_s = html.escape((_rt.get("goal") or "")[:38])
182
- _elapsed = ""
183
- _ca = _rt.get("created_at", 0)
184
- if isinstance(_ca, int) and _ca > 0:
185
- _es = int(time.time() * 1000 - _ca) // 1000
186
- _elapsed = f" · {_es // 60}m{_es % 60:02d}s" if _es >= 60 else f" · {_es}s"
187
- # Legge ultimo evento SSE dal buffer (action/type corrente)
188
- _act = ""
189
- _ebuf = (_loop_registry.get(_tid) or {}).get("event_buffer", [])
190
- for _ev in reversed(_ebuf[-30:]):
191
- try:
192
- _raw = _ev[6:] if _ev.startswith("data: ") else _ev
193
- _ed = _lj.loads(_raw)
194
- _a = _ed.get("action") or _ed.get("type") or ""
195
- if _a and _a not in ("ping", "connected", "keepalive"):
196
- _act = f" <code>{html.escape(str(_a)[:20])}</code>"
197
- break
198
- except Exception:
199
- pass
200
- live_line = f"\n⚙️ <b>Live:</b> {_goal_s}{_act}{_elapsed}"
201
- except Exception:
202
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
 
204
- # ── NEW-3: coord mini — sessioni agent-coord attive ──────────────────
205
- # 1 riga: chi sta lavorando, su quale sprint, su quali file.
206
- # Timeout aggressivo 3s — /status deve essere veloce.
207
- coord_line = ""
208
- try:
209
- import httpx as _hx_c2, json as _jc2, time as _tc2
210
- _SB_URL2 = os.getenv("SUPABASE_URL", "").rstrip("/")
211
- _SB_KEY2 = os.getenv("SUPABASE_KEY", "")
212
- if _SB_KEY2:
213
- async with _hx_c2.AsyncClient(timeout=3.0) as _cc:
214
- _cr = await _cc.get(
215
- f"{_SB_URL2}/rest/v1/agent_tasks",
216
- params={"goal": "ilike.*__session__*", "select": "context"},
217
- headers={"apikey": _SB_KEY2, "Authorization": f"Bearer {_SB_KEY2}"},
218
- )
219
- if _cr.is_success:
220
- _now_ms = int(_tc2.time() * 1000)
221
- _active = []
222
- for _row in (_cr.json() or []):
223
- try:
224
- _ctx = _jc2.loads(_row.get("context") or "{}")
225
- if _now_ms - int(_ctx.get("lastHeartbeat", 0)) < 300_000:
226
- _active.append(_ctx)
227
- except Exception:
228
- pass
229
- if _active:
230
- _s = _active[0]
231
- _files = [f.split("/")[-1] for f in _s.get("claimedFiles", [])]
232
- _fstr = ", ".join(_files[:3]) or "—"
233
- _extra = f" (+{len(_active)-1})" if len(_active) > 1 else ""
234
- coord_line = (
235
- f"\n🔗 <b>Coord:</b> {html.escape(_s.get('sessionName','?'))}"
236
- f" [{html.escape(_s.get('sprint','—'))}]"
237
- f" · <code>{html.escape(_fstr)}</code>{_extra}"
238
- )
239
- else:
240
- coord_line = "\n🔗 <b>Coord:</b> <i>nessuna sessione attiva</i>"
241
- except Exception:
242
- pass
243
-
244
- # ── Icona salute sistema ──────────────────────────────────────────────
245
- if running > 0:
246
- _sys_icon, _sys_label = "⚙️", f"{running} task in esecuzione"
247
- elif error > 0 and success == 0 and total > 0:
248
- _sys_icon, _sys_label = "🔴", "ultimi task terminati con errore"
249
- elif total == 0:
250
- _sys_icon, _sys_label = "💤", "nessun task recente"
251
- else:
252
- _sys_icon, _sys_label = "✅", "tutto operativo"
253
-
254
- parts = [f"📊 <b>Sistema</b> {_sys_icon} — <i>{_sys_label}</i>", ""]
255
- if running or queued:
256
- parts.append(f"⚙️ <b>In esecuzione:</b> {running} · <b>In coda:</b> {queued}")
257
- if success or error or total:
258
- parts.append(f"✅ Completati: {success} · ❌ Errori: {error} · Totale: {total}")
259
- for _extra_line in [sb_line, ry_line, git_line, live_line, coord_line]:
260
- if _extra_line:
261
- parts.append(_extra_line)
262
- parts += [
263
- "",
264
- f"🗓 <b>Scheduler:</b> {sched_label}" + (f" · {sched_pending} in coda" if sched_pending else ""),
265
- "",
266
- f"<i>🕐 {ts_now} UTC</i>",
267
- ]
268
- await _tg_reply(chat_id, "\n".join(p for p in parts if p is not None),
269
- keyboard=_MAIN_KB)
270
- except Exception as exc:
271
- await _tg_reply(chat_id, "⚠️ Errore lettura stato: " + html.escape(str(exc)[:200]))
272
 
273
 
274
  async def _cmd_commit_summary(chat_id: int) -> None:
@@ -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 &lt;task&gt; lancia un task AI\n"
41
+ " /autofix corregge errori automaticamente\n"
42
+ " /logs ultimi warning/errori\n"
43
+ " /commitscommit 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
- # ── Quick-pick task templates (MX-QUICKPICK) ──────────────────────────────────
14
- _QUICK_PICK_KB = {
15
- "inline_keyboard": [
16
- [{"text": "🔍 Analizza bug", "callback_data": "qp_bug"},
17
- {"text": "⚡ Ottimizza DB", "callback_data": "qp_db"}],
18
- [{"text": "🔧 AutoFix log", "callback_data": "qp_autofix"},
19
- {"text": "🔀 Riassumi commit", "callback_data": "qp_commits"}],
20
- [{"text": "📝 Genera docs", "callback_data": "qp_docs"},
21
- {"text": "🧪 Genera test", "callback_data": "qp_tests"}],
22
- [{"text": "✍️ Scrivi obiettivo...", "callback_data": "qp_custom"}],
23
- [{"text": "🏠 Menu", "callback_data": "tgw_help"}],
24
- ]
25
- }
26
 
27
- # ── After-task keyboard (retry + navigazione) ─────────────────────────────────
28
- # Ultimo goal per chat_id — usato da 🔁 Rifai
29
- _LAST_GOAL: dict[int, str] = {}
 
 
 
 
 
 
 
 
 
 
 
30
 
 
31
  def _after_task_kb(chat_id: int) -> dict:
32
- """Keyboard mostrata dopo ogni task completato."""
 
 
 
 
 
 
 
 
 
 
 
 
33
  return {
34
  "inline_keyboard": [
35
- [{"text": "🔁 Rifai", "callback_data": "tgw_retry"},
36
- {"text": "📋 Attività", "callback_data": "tgw_tasks"}],
37
- [{"text": "🚀 Nuovo Task", "callback_data": "agent"},
38
- {"text": "🏠 Menu", "callback_data": "tgw_help"}],
39
  ]
40
  }
41
 
 
 
 
 
 
 
 
 
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
- # ── Main reply keyboard ─────────────────────────────────────────────────────
45
- _MAIN_KB = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  "inline_keyboard": [
47
- [{"text": "🚀 Nuovo Task", "callback_data": "agent"},
48
- {"text": "📋 Attività", "callback_data": "tgw_tasks"}],
49
- [{"text": "🩺 Salute", "callback_data": "tgw_health"},
50
- {"text": "🔧 AutoFix", "callback_data": "tgw_autofix"}],
51
- [{"text": "💬 Chiedi all'AI","callback_data": "tgw_ask"},
52
- {"text": "📈 Stato", "callback_data": "tgw_status"}],
53
- [{"text": "🌐 Dashboard ", "url": "https://agente-ai.pages.dev"}],
 
54
  ]
55
  }
56
 
57
- # ── Bench cache + keyboard (GAP-TGB) ─────────────────────────────────────────
58
- # Salva l'ultimo run bench per chat_id → usato dai callback tgw_bench_fix/run
59
- _BENCH_CACHE: dict[int, dict] = {}
 
 
 
 
 
 
 
 
60
 
 
61
  _BENCH_ACTION_KB = {
62
  "inline_keyboard": [
63
  [{"text": "🔧 Applica Fix", "callback_data": "tgw_bench_fix"},
64
  {"text": "🔄 Riesegui", "callback_data": "tgw_bench_run"}],
65
- [{"text": "⚙️ Migliora", "callback_data": "tgw_improve"}],
66
  [{"text": "🏠 Menu", "callback_data": "tgw_help"}],
67
  ]
68
  }
69
 
70
- # ── ReplyKeyboardRemove rimuove tastiera persistente da versioni precedenti ──
71
- _REPLY_KB_REMOVE = {"remove_keyboard": True}
72
-
73
- # ── Sub-menu inline keyboards ─────────────────────────────────────────────────
74
- _TASK_MENU_KB = {
75
  "inline_keyboard": [
76
- [{"text": "🤖 Nuovo Task", "callback_data": "tgw_do"},
77
- {"text": "🔧 AutoFix", "callback_data": "tgw_autofix"}],
78
- [{"text": "⚙️ Migliora AI", "callback_data": "tgw_improve"},
79
- {"text": "📋 Task recenti", "callback_data": "tgw_tasks"}],
80
- [{"text": "🧠 Briefing", "callback_data": "tgw_briefing"},
81
- {"text": "📝 Salva Nota", "callback_data": "tgw_nota"}],
82
- [{"text": "🔍 Cerca web", "callback_data": "tgw_cerca"},
83
- {"text": "🌤 Meteo", "callback_data": "tgw_meteo"}],
84
  ]
85
  }
86
- _STATUS_MENU_KB = {
 
 
 
 
 
87
  "inline_keyboard": [
88
- [{"text": "📊 Daemon+Task", "callback_data": "tgw_status"},
89
- {"text": "🔌 Provider AI", "callback_data": "tgw_providers"}],
90
- [{"text": "🔗 Coord sessioni", "callback_data": "tgw_coord"},
91
- {"text": "🔀 Git log", "callback_data": "tgw_git"}],
92
- [{"text": "🌐 Dashboard", "url": "https://agente-ai.pages.dev"}],
93
  ]
94
  }
95
- _PERF_MENU_KB = {
 
 
96
  "inline_keyboard": [
97
- [{"text": "📊 Benchmark", "callback_data": "tgw_bench"},
98
- {"text": "🏆 Score", "callback_data": "tgw_score"}],
99
- [{"text": "📡 Telemetria", "callback_data": "tgw_telemetry"},
100
- {"text": "⚙️ Migliora", "callback_data": "tgw_improve"}],
101
- [{"text": "🔧 Fix gap bench", "callback_data": "tgw_bench_fix"}],
102
  ]
103
  }
104
- _HEALTH_MENU_KB = {
 
 
105
  "inline_keyboard": [
106
- [{"text": "🔍 Scan completo", "callback_data": "tgw_health"},
107
- {"text": "📝 Log errori", "callback_data": "tgw_logs"}],
108
- [{"text": "📊 Status", "callback_data": "tgw_status"},
109
- {"text": "🔌 Provider AI", "callback_data": "tgw_providers"}],
 
110
  ]
111
  }
112
- _DEV_MENU_KB = {
 
 
113
  "inline_keyboard": [
114
- [{"text": "📸 Snapshot", "callback_data": "tgw_snap"},
115
- {"text": " Verify", "callback_data": "tgw_verify"}],
116
- [{"text": "💊 Heal", "callback_data": "tgw_heal"},
117
- {"text": "📝 Log", "callback_data": "tgw_logs"}],
118
- [{"text": "🔀 Git commits", "callback_data": "tgw_git"},
119
- {"text": "🏓 Ping", "callback_data": "tgw_ping"}],
120
  ]
121
  }
122
-
 
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 stato backend + task attivi (in-memory + Supabase fallback)
 
6
  /tasks — ultimi task con elapsed time
7
- /do <goal> — lancia un nuovo task tramite loop
8
- /scan_now health-check completo (AI + Supabase + Telegram)
9
- /health alias /scan_now
10
- /score score immediato dall'ultimo benchmark (no run)
11
- callback_querygestisce inline buttons notify_task_done
 
 
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\n"
104
- " <code>/avvia analizza i bug in providers.py</code>\n"
105
- " <code>/avvia ottimizza le query Supabase</code>\n"
106
- " <code>/avvia fai autofix degli errori nel log</code>\n\n"
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 → avvia task come /do
222
- _t=asyncio.create_task(_cmd_do(chat_id, text)); _t.add_done_callback(_log_tg_exc)
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": "avvia", "description": "🚀 Avvia un task AI /avvia <obiettivo>"},
237
- {"command": "stato", "description": "📊 Stato backend e task in corso"},
238
- {"command": "salute", "description": "🩺 Controllo salute del sistema"},
239
- {"command": "benchmark", "description": "📊 Esegui benchmark e analisi gap"},
240
- {"command": "punteggio", "description": "🏆 Punteggio attuale senza nuovo run"},
241
- {"command": "correzione", "description": "🔧 Correzione automatica errori AI"},
242
- {"command": "attivita", "description": "📋 Ultimi task con tempi di esecuzione"},
243
- {"command": "chiedi", "description": "🧠 Domanda rapida — /chiedi <testo>"},
244
- {"command": "aiuto", "description": " Menu principale e guida"},
 
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 &lt;obiettivo&gt;</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.4.2')
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 as _imp
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
- _imp.import_module(_pkg)
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 3.x — disponibili live 2026-06-14 (flash = free)
97
- "gemini-3-flash-preview": 32768,
98
- "gemini-3.5-flash": 32768,
99
- "gemini-3.1-flash-lite": 16384,
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", "openai/gpt-oss-20b:free"),
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.5verificati 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 (S362, aggiornato 2026-06-14 benchmark)
3
 
4
- BENCHMARK RESULTS 2026-06-14 FINALE (14 modelli × 3 test, max_tokens corretti):
5
- 100% qualità (ordinati per TTFT):
6
- #1 Groq / llama-3.1-8b-instant 170ms 100% ← FASTEST
7
- #2 Cerebras / gpt-oss-120b 207ms 100% ← REASONING (max_tokens≥500)
8
- #3 Groq / llama-3.3-70b-versatile 235ms 100%
9
- #4 Cerebras / zai-glm-4.7 254ms 100%
10
- #5 Groq / compound-mini 341ms 100%
11
- #6 SambaNova / DeepSeek-V3.1 482ms 100%
12
- #7 SambaNova / gemma-4-31B 2132ms 100%
13
- #8 OpenRouter / gpt-oss-120b:free 2160ms 100%
 
 
 
14
 
15
- Role assignments 2026-06-14 FINALE:
16
- FAST → Groq llama-3.1-8b-instant (170ms, 100%) #1 assoluto
17
- ARCHITECT → Groq llama-4-scout-17b 10M ctx (244ms, 67% — best per contesto lungo)
18
- CODER → Groq llama-3.3-70b-versatile (235ms, 100%) ← #3 qualità
19
- TESTER → Groq llama-3.1-8b-instant
20
- CONTEXT → Groq llama-3.1-8b-instant
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
- OpenRouter tenuto come fallback secondario (gpt-oss-120b:free = 1645ms ma 100% qualità).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = "fast" # greetings, math semplice, identity — llama-3.1-8b-instant
40
- ARCHITECT = "architect" # planning, ragionamento complesso — llama-4-scout-17b (10M ctx)
41
- CODER = "coder" # coding, debug — llama-3.3-70b-versatile
42
- TESTER = "tester" # test gen, debug hints — llama-3.1-8b-instant
43
- CONTEXT = "context" # summarization, context compression — llama-3.1-8b-instant
44
- DEFAULT = "default" # AIClient() primary
45
- RESEARCHER = "researcher" # web research + document synthesis — Gemini 2.5-flash
46
- REASONER = "reasoner" # throughput massimoCerebras gpt-oss-120b (2000+ tok/s)
47
- SAMBANOVA = "sambanova"
48
- NVIDIA = "nvidia" # NVIDIA NIM — nemotron-3-ultra-550b (1M ctx) # DeepSeek-V3.1 via SambaNova (404ms, 100% qualità benchmark)
 
 
 
 
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) # GAP-ROUT-FIX: debug→warning
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 — 344ms TTFT, 100% benchmark qualità.
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", "groq-tester")]
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
- """NVIDIA Nemotron-3-Ultra-550B (1M ctx) come primario — massima potenza per architettura.
111
- Fallback 1: Groq llama-4-scout-17b (10M ctx, 480ms). Fallback 2: OpenRouter gpt-oss-120b:free."""
 
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=model,
138
  )
139
- rest = [p for p in client.providers if p.name not in ("groq", "groq-architect")]
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: OpenRouter gpt-oss-120b:free (1645ms ma 100% qualità)
146
- openrouter_key = os.getenv("OPENROUTER_API_KEY")
147
- if openrouter_key:
148
  client = AIClient()
149
- fallback = ProviderConfig(
150
  name="openrouter-architect",
151
- api_key=openrouter_key,
152
  base_url="https://openrouter.ai/api/v1",
153
- default_model="openai/gpt-oss-120b:free",
154
  )
155
  rest = [p for p in client.providers if not p.name.startswith("openrouter")]
156
- client.providers = [fallback, *rest]
157
- client.provider_name = fallback.name
158
- client.default_model = fallback.default_model
159
- client.client = client._client_for(fallback)
160
  return client
161
  return AIClient()
162
 
163
  @staticmethod
164
  def _coder_client() -> Any:
165
- """Groq llama-3.3-70b-versatile358ms TTFT, 100% benchmark qualità.
166
- AGGIORNATO 2026-06-14: era OpenRouter qwen3-coder:free → 429 rate-limited daily.
167
- Fallback: OpenRouter gpt-oss-120b:free se GROQ_API_KEY mancante."""
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=model,
178
  )
179
- rest = [p for p in client.providers if p.name not in ("groq", "groq-coder")]
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
- fallback = ProviderConfig(
189
  name="openrouter-coder",
190
  api_key=openrouter_key,
191
  base_url="https://openrouter.ai/api/v1",
192
- default_model="openai/gpt-oss-120b:free",
193
  )
194
  rest = [p for p in client.providers if not p.name.startswith("openrouter")]
195
- client.providers = [fallback, *rest]
196
- client.provider_name = fallback.name
197
- client.default_model = fallback.default_model
198
- client.client = client._client_for(fallback)
199
  return client
200
  return AIClient()
201
 
202
  @staticmethod
203
  def _researcher_client() -> Any:
204
- """Gemini 2.5-flash — TTFT 910ms, ottima per research/synthesis/doc analysis."""
 
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 gpt-oss-120b207ms TTFT, 100% qualità (bench 2026-06-14).
226
- REASONING MODEL: genera "reasoning" field prima del "content".
227
- Richiede max_tokens≥500 per output non-vuoto su task non-triviali.
228
- Fallback: _coder_client (Groq 70B) se CEREBRAS_API_KEY mancante."""
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", "gpt-oss-120b"),
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-32b325ms 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-31b236ms 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