Baida07 commited on
Commit
815b2e9
Β·
verified Β·
1 Parent(s): 12e9142

fix: sync registry+fallback fixes da A (registry.py line692, LLM_TIMEOUT, _ANALYTICAL_VERBS_RE)

Browse files
.env.example CHANGED
@@ -28,13 +28,36 @@ VAULT_ADMIN_TOKEN=
28
  INTERNAL_TOKEN=
29
  NOTIFY_TOKEN=
30
 
31
- # ── Supabase (obbligatorio) ───────────────────────────────────
32
- # supabase.com β†’ Settings β†’ API
33
- SUPABASE_URL=https://xxxx.supabase.co
34
- SUPABASE_KEY=
35
- SUPABASE_SERVICE_ROLE_KEY=
36
- SUPABASE_ANON_KEY=
37
- DATABASE_URL=postgresql://postgres:[password]@db.[ref].supabase.co:5432/postgres
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  # ── HuggingFace ───────────────────────────────────────────────
40
  # huggingface.co β†’ Settings β†’ Access Tokens
@@ -134,6 +157,19 @@ NOTION_TOKEN=
134
  CHROMA_DB_DIR=/app/backend/.data/chroma
135
  SQLITE_DB_PATH=/app/backend/.data/agent.sqlite
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  # ── Opzionali ─────────────────────────────────────────────────
138
  # Qdrant (vector DB cloud)
139
  QDRANT_URL=
@@ -155,13 +191,36 @@ PEXELS_API_KEY=
155
  PIXABAY_API_KEY=
156
 
157
  # ═══════════════════════════════════════════════════
158
- # ── Collaboratore Account B (dual-infra) ───────────
159
  # ═══════════════════════════════════════════════════
160
  VITE_BACKEND_URL_2=https://baida00-ai-backend-collab.hf.space # HF Space B β€” backend collab B (chat/AI)
161
  VITE_EXEC_BACKEND_URL_2= # Railway B URL (exec/PTY) β€” formato: https://xxx.up.railway.app β€” DA CONFIGURARE
162
- E2B_API_KEY_2= # e2b.dev β€” account B (100h/mese)
163
- SUPABASE_URL_2= # Supabase B URL
164
- SUPABASE_KEY_2= # Supabase B anon key
165
- SUPABASE_SERVICE_ROLE_KEY_2= # Supabase B service role (opzionale)
166
- GROQ_API_KEY_3= # Groq account B (14.400 req/giorno)
167
- VITE_GROQ_API_KEY_3= # stessa chiave β€” frontend
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  INTERNAL_TOKEN=
29
  NOTIFY_TOKEN=
30
 
31
+ # ═══════════════════════════════════════════════════════════════════════════════════════
32
+ # ── CLUSTER SUPABASE β€” Separazione Workload Ottimale ──────────────────────────────────
33
+ # ═══════════════════════════════════════════════════════════════════════════════════════
34
+ # Architettura:
35
+ # A: Analytics/Cache/Read-Heavy (reporting, dashboard, cache distribuito)
36
+ # B: Sync/State/Transazioni (stato globale, sincronizzazione cluster β€” PRIMARY)
37
+ # C: Memory/RAG/Embeddings (backend memoria, vector search, skill index)
38
+ # D: Audit/Logging/Compliance (event log, audit trail, compliance records)
39
+ #
40
+ # SUPABASE_URL (primario) β†’ punta a B (Sync/State)
41
+ # SUPABASE_KEY (primario) β†’ punta a B
42
+ # ═══════════════════════════════════════════════════════════════════════════════════════
43
+
44
+ # ── Nodo B: PRIMARY β€” Stato Globale e Sincronizzazione ──────────────────────────────────
45
+ # Workload: transazioni critiche, stato cluster, sincronizzazione, write-heavy
46
+ SUPABASE_URL=https://sluvxtpbtxevrcooaqou.supabase.co
47
+ SUPABASE_KEY=sb_secret_OHtVs6Vw4b5UTbm_QUnh4A_zqx9dadY
48
+ SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNsdXZ4dHBidHhldnJjb29hcW91Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjA3Mjk5MiwiZXhwIjoyMDk3NjQ4OTkyfQ.WS4cpsvtO-FGkXukCqpFF6qXfQb38VUs5rtiLwa0khk
49
+ SUPABASE_ANON_KEY=sb_secret_OHtVs6Vw4b5UTbm_QUnh4A_zqx9dadY
50
+ DATABASE_URL=postgresql://postgres:[password]@db.sluvxtpbtxevrcooaqou.supabase.co:5432/postgres
51
+
52
+ # ── Nodo A: Analytics/Cache β€” Read Replica e Cache Distribuito ──────────────────────────
53
+ # Workload: reporting, dashboard, cache, read-heavy queries (non-critical)
54
+ # Nota: Configurare con URL reale se disponibile; altrimenti fallback a B
55
+ SUPABASE_URL_A=https://xxxx.supabase.co
56
+ SUPABASE_KEY_A=
57
+ SUPABASE_SERVICE_ROLE_KEY_A=
58
+ # Enable cache layer su A (riduce carico su B)
59
+ SUPABASE_CACHE_ENABLED=true
60
+ SUPABASE_CACHE_TTL=3600
61
 
62
  # ── HuggingFace ───────────────────────────────────────────────
63
  # huggingface.co β†’ Settings β†’ Access Tokens
 
157
  CHROMA_DB_DIR=/app/backend/.data/chroma
158
  SQLITE_DB_PATH=/app/backend/.data/agent.sqlite
159
 
160
+ # ═══════════════════════════════════════════════════════════════════════════════════════
161
+ # ── Nodo E: Oracle Cloud (OCI) β€” Compute/AI Workload ──────────────────────────────────
162
+ # ═══════════════════════════════════════════════════════════════════════════════════════
163
+ # Workload: Carichi di calcolo intensivi, esecuzione script Python complessi, task di background a lunga durata, hosting modelli AI locali.
164
+ ORACLE_NODE_E_ENABLED=true
165
+ ORACLE_NODE_E_IP=80.225.89.217
166
+ ORACLE_NODE_E_USER=opc
167
+ ORACLE_NODE_E_SSH_KEY_PATH=/app/secrets/oracle_private_key.pem # Path alla chiave privata SSH (gestita come segreto)
168
+ ORACLE_NODE_E_ROLE="Compute/AI Workload"
169
+ ORACLE_NODE_E_REGION="Italy Northwest (Milan)"
170
+ ORACLE_NODE_E_OCPUS=4
171
+ ORACLE_NODE_E_RAM_GB=24
172
+
173
  # ── Opzionali ─────────────────────────────────────────────────
174
  # Qdrant (vector DB cloud)
175
  QDRANT_URL=
 
191
  PIXABAY_API_KEY=
192
 
193
  # ═══════════════════════════════════════════════════
194
+ # ── HF Spaces e Railway β€” Configurazione Infra ─────
195
  # ═══════════════════════════════════════════════════
196
  VITE_BACKEND_URL_2=https://baida00-ai-backend-collab.hf.space # HF Space B β€” backend collab B (chat/AI)
197
  VITE_EXEC_BACKEND_URL_2= # Railway B URL (exec/PTY) β€” formato: https://xxx.up.railway.app β€” DA CONFIGURARE
198
+ E2B_API_KEY_2=2f548a79-9210-4037-87fa-4490a6a6b8fe # e2b.dev β€” account B (100h/mese)
199
+
200
+ # Groq account B (14.400 req/giorno) β€” load balancing con A
201
+ GROQ_API_KEY_3=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
202
+ VITE_GROQ_API_KEY_3=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
203
+
204
+ # ═══════════════════════════════════════════════════════════════════════════════════════
205
+ # ── Nodo C: Memory/RAG/Embeddings β€” Backend Memoria e Vector Search ──────────────────────
206
+ # ═══════════════════════════════════════════════════════════════════════════════════════
207
+ # Workload: embeddings, vector search, RAG index, skill memory, conversazioni
208
+ SUPABASE_URL_C=https://rsiphwzlokhsxnkcvzos.supabase.co
209
+ SUPABASE_ANON_KEY_C=sb_secret_11jeLuJnZBA1IKCAm1ZjiA_QxIyU5u6
210
+ SUPABASE_SERVICE_ROLE_KEY_C=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJzaXBod3psb2toc3hua2N2em9zIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjI1OTEyOSwiZXhwIjoyMDk3ODM1MTI5fQ.LxO8ltt-t8SpHf-ZIi1ogcEtcQcPvW1Vyu2W14i1zzM
211
+ E2B_API_KEY_C=2f548a79-9210-4037-87fa-4490a6a6b8fe
212
+ # RAG/Vector DB β€” Qdrant per embeddings (opzionale, altrimenti usa Supabase C)
213
+ QDRANT_URL_C=
214
+ QDRANT_API_KEY_C=
215
+
216
+ # ═══════════════════════════════════════════════════════════════════════════════════════
217
+ # ── Nodo D: Audit/Logging/Compliance β€” Event Log e Compliance Records ──────────────────
218
+ # ═══════════════════════════════════════════════════════════════════════════════════════
219
+ # Workload: audit trail, event logging, compliance records, immutable logs
220
+ SUPABASE_URL_D=https://cyotadpvgaxfdwaovstv.supabase.co
221
+ SUPABASE_ANON_KEY_D=sb_secret_aZGjt7d8KWsvIm5-JP4qrg_XrNPBAZE
222
+ SUPABASE_SERVICE_ROLE_KEY_D=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImN5b3RhZHB2Z2F4ZmR3YW92c3R2Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjY1NjQ1OSwiZXhwIjoyMDk4MjMyNDU5fQ.FuLweX2H6RKJmC7PbXdctmgzVIcqspq9FSxwFzFwB9o
223
+ E2B_API_KEY_D=e2b_9d242366288d5e9e0bb267f31cccc5d49298348c
224
+ # Audit log β€” immutable append-only (opzionale)
225
+ AUDIT_LOG_ENABLED=true
226
+ AUDIT_LOG_RETENTION_DAYS=90
agents/audit_semantic_l2.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ audit_semantic_l2.py β€” S303: Audit Semantico L2 (Critico Senior) su Nodo D.
3
+
4
+ L1 (goal_verifier.py) valida se la risposta *aderisce* al goal.
5
+ L2 (questo file) verifica la *coerenza logica interna* dell'output:
6
+ - Nessuna contraddizione auto-referenziale
7
+ - Claim verificabili non inventati (anti-hallucination guard)
8
+ - Completezza rispetto ai sotto-obiettivi esplicitati nel goal
9
+ - Stato outcome: PASS / FAIL / UNKNOWN β€” mai forzare PASS
10
+
11
+ Integrazione: chiamato DOPO GoalVerifier L1 in unified_loop_fallback.py.
12
+ Se L1 = FAIL β†’ L2 non viene invocato (risparmio token).
13
+ Se L1 = PASS o UNKNOWN β†’ L2 aggiunge una seconda garanzia semantica.
14
+
15
+ Output: AuditL2Result (dataclass) con status, issues[], confidence, repair_hint.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ import logging
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from enum import Enum
25
+ from typing import Any, Optional
26
+
27
+ _logger = logging.getLogger("agents.audit_l2")
28
+
29
+
30
+ class AuditStatus(str, Enum):
31
+ PASS = "PASS"
32
+ FAIL = "FAIL"
33
+ UNKNOWN = "UNKNOWN"
34
+
35
+
36
+ @dataclass
37
+ class AuditL2Result:
38
+ status: AuditStatus
39
+ confidence: float = 0.0 # 0.0 – 1.0
40
+ issues: list[str] = field(default_factory=list)
41
+ repair_hint: str = ""
42
+ engine: str = "heuristic" # "heuristic" | "llm"
43
+
44
+
45
+ # ── Pattern anti-hallucination ────────────────────────────────────────────────
46
+ # Claim di azioni che l'agente NON puΓ² eseguire da solo senza tool confirmation.
47
+ # Copre IT / EN / ES / FR β€” le 4 lingue attive nel cluster.
48
+ _HALLUCINATION_PATTERNS: list[tuple[re.Pattern, str]] = [
49
+ # Deploy / publish
50
+ (re.compile(
51
+ r"\b(ho deployato|ho pubblicato|ho pushato|ho committato|ho inviato|"
52
+ r"ho caricato|ho aggiornato il server|ho rilasciato|"
53
+ r"i deployed|i pushed|i committed|i published|i sent|i uploaded|i released|"
54
+ r"he desplegado|he publicado|he enviado|he subido|he lanzado|"
55
+ r"j'ai dΓ©ployΓ©|j'ai publiΓ©|j'ai envoyΓ©|j'ai poussΓ©|j'ai mis en ligne)\b",
56
+ re.I),
57
+ "claim di deploy/push/send non verificato da tool"),
58
+
59
+ # Stato esterno live
60
+ (re.compile(
61
+ r"\b(il sito Γ¨ live|the site is live|ora funziona|it now works|"
62
+ r"Γ¨ online|is online|Γ¨ andato live|went live|"
63
+ r"the app is running|l'app Γ¨ in esecuzione|"
64
+ r"el sitio estΓ‘ en vivo|el sistema funciona ahora|"
65
+ r"le site est en ligne|l'application fonctionne maintenant)\b",
66
+ re.I),
67
+ "claim di stato esterno non verificabile"),
68
+
69
+ # Assunzioni sull'utente
70
+ (re.compile(
71
+ r"\b(l'utente ha|the user has|hai giΓ |you already|"
72
+ r"your database (is|has)|il tuo database (Γ¨|ha)|"
73
+ r"el usuario ya|vous avez dΓ©jΓ )\b",
74
+ re.I),
75
+ "assunzione su stato dell'utente non verificabile"),
76
+
77
+ # Test / CI passati senza prova
78
+ (re.compile(
79
+ r"\b(tutti i test passano|all tests pass|i test sono verdi|tests are green|"
80
+ r"la CI Γ¨ verde|CI is green|build successful|build riuscita|"
81
+ r"todos los tests pasan|tous les tests passent)\b",
82
+ re.I),
83
+ "claim di test/CI passati senza esecuzione verificata"),
84
+ ]
85
+
86
+ # ── Pattern contraddizione interna ────────────────────────────────────────────
87
+ _CONTRADICTION_PAIRS: list[tuple[str, str]] = [
88
+ ("errore", "nessun errore"),
89
+ ("error", "no error"),
90
+ ("fallito", "completato con successo"),
91
+ ("failed", "completed successfully"),
92
+ ("non trovato", "trovato correttamente"),
93
+ ("not found", "found correctly"),
94
+ ("timeout", "risposta ricevuta"),
95
+ ("timeout", "response received"),
96
+ ("impossibile", "funziona"),
97
+ ("impossible", "works"),
98
+ ("non funziona", "funziona correttamente"),
99
+ ("doesn't work", "works correctly"),
100
+ ("eccezione", "nessuna eccezione"),
101
+ ("exception", "no exception"),
102
+ ("crash", "stabile"),
103
+ ("crash", "stable"),
104
+ ]
105
+
106
+
107
+ def _check_hallucinations(text: str) -> list[str]:
108
+ issues = []
109
+ for pattern, label in _HALLUCINATION_PATTERNS:
110
+ if pattern.search(text):
111
+ issues.append(f"Possibile hallucination: {label}")
112
+ return issues
113
+
114
+
115
+ def _check_contradictions(text: str) -> list[str]:
116
+ issues = []
117
+ text_lower = text.lower()
118
+ for a, b in _CONTRADICTION_PAIRS:
119
+ if a in text_lower and b in text_lower:
120
+ issues.append(f"Contraddizione interna: '{a}' e '{b}' co-presenti")
121
+ return issues
122
+
123
+
124
+ def _check_completeness(goal: str, answer: str) -> list[str]:
125
+ """
126
+ Controlla che i sotto-obiettivi espliciti del goal (identificati da liste numerate
127
+ o bullet points) siano almeno menzionati nella risposta.
128
+ """
129
+ issues = []
130
+ sub_goals = re.findall(
131
+ r"(?:^|\n)\s*(?:\d+\.|[-*β€’])\s+(.+?)(?:\n|$)", goal
132
+ )
133
+ if not sub_goals:
134
+ return []
135
+ answer_lower = answer.lower()
136
+ missing = []
137
+ for sg in sub_goals[:8]: # max 8 sotto-obiettivi
138
+ words = [w for w in sg.lower().split() if len(w) > 4][:4]
139
+ if words and sum(1 for w in words if w in answer_lower) < max(1, len(words) // 2):
140
+ missing.append(sg.strip()[:60])
141
+ if missing:
142
+ issues.append(f"Sotto-obiettivi non indirizzati: {missing[:3]}")
143
+ return issues
144
+
145
+
146
+ def _heuristic_audit(goal: str, answer: str) -> AuditL2Result:
147
+ """Audit euristico: pattern matching su testo, senza LLM."""
148
+ issues: list[str] = []
149
+ issues.extend(_check_hallucinations(answer))
150
+ issues.extend(_check_contradictions(answer))
151
+ issues.extend(_check_completeness(goal, answer))
152
+
153
+ if not issues:
154
+ return AuditL2Result(
155
+ status=AuditStatus.PASS,
156
+ confidence=0.75,
157
+ engine="heuristic",
158
+ )
159
+ # Gravi (hallucination o contraddizione) β†’ FAIL; solo completeness β†’ UNKNOWN
160
+ has_severe = any(
161
+ "hallucination" in i or "Contraddizione" in i or "contradiction" in i.lower()
162
+ for i in issues
163
+ )
164
+ return AuditL2Result(
165
+ status=AuditStatus.FAIL if has_severe else AuditStatus.UNKNOWN,
166
+ confidence=0.82 if has_severe else 0.55,
167
+ issues=issues,
168
+ repair_hint="Rivedere e rimuovere claim non verificati o contraddizioni.",
169
+ engine="heuristic",
170
+ )
171
+
172
+
173
+ _AUDIT_SYSTEM = (
174
+ "Sei un Critico Senior che verifica la coerenza logica delle risposte di un agente AI. "
175
+ "Rispondi SOLO con JSON valido, senza markdown. Formato:\n"
176
+ '{"status":"PASS"|"FAIL"|"UNKNOWN","confidence":0.0-1.0,'
177
+ '"issues":["..."],"repair_hint":"..."}\n\n'
178
+ "Regole: FAIL solo per problemi gravi (hallucination, contraddizioni). "
179
+ "UNKNOWN per incertezze moderate. PASS se la risposta Γ¨ coerente. "
180
+ "Mai forzare PASS se ci sono dubbi fondati."
181
+ )
182
+
183
+
184
+ def _build_audit_prompt(goal: str, answer: str) -> str:
185
+ # Tronca intelligentemente: preserva inizio e fine dell'answer
186
+ max_ans = 1400
187
+ if len(answer) > max_ans:
188
+ half = max_ans // 2
189
+ answer_trunc = answer[:half] + "\n[...]\n" + answer[-half:]
190
+ else:
191
+ answer_trunc = answer
192
+ return (
193
+ f"GOAL ORIGINALE:\n{goal[:500]}\n\n"
194
+ f"RISPOSTA AGENTE:\n{answer_trunc}\n\n"
195
+ "VERIFICA (rispondi solo con JSON):\n"
196
+ "1. Ci sono claim di azioni esterne non verificabili (deploy/push/send/test-pass senza tool proof)?\n"
197
+ "2. Ci sono contraddizioni interne (es. 'errore' e 'completato con successo' co-presenti)?\n"
198
+ "3. La risposta indirizza almeno i sotto-obiettivi espliciti del goal?\n"
199
+ )
200
+
201
+
202
+ class SemanticAuditorL2:
203
+ """
204
+ S303 β€” Audit Semantico L2.
205
+ Istanziato come singleton.
206
+ Usato in unified_loop_fallback.py dopo GoalVerifier L1 (solo se L1 β‰  FAIL).
207
+ """
208
+
209
+ def __init__(self, ai_client: Any = None, timeout_s: float = 12.0):
210
+ self.ai_client = ai_client
211
+ self.timeout_s = timeout_s
212
+
213
+ async def audit(self, goal: str, answer: str) -> AuditL2Result:
214
+ """
215
+ Punto di ingresso principale.
216
+ 1. Prova audit LLM se ai_client disponibile.
217
+ 2. Fallback a audit euristico in caso di errore o timeout.
218
+ """
219
+ if not goal or not answer:
220
+ return AuditL2Result(status=AuditStatus.UNKNOWN, confidence=0.0,
221
+ issues=["goal o answer vuoti"])
222
+
223
+ # Euristico sempre eseguito β€” base line gratuita
224
+ heuristic_result = _heuristic_audit(goal, answer)
225
+
226
+ # Se euristico ha giΓ  trovato problemi gravi, non invocare LLM per efficienza
227
+ if heuristic_result.status == AuditStatus.FAIL and len(heuristic_result.issues) >= 2:
228
+ _logger.debug("[AuditL2] heuristic FAIL con %d issues β€” skip LLM", len(heuristic_result.issues))
229
+ return heuristic_result
230
+
231
+ if self.ai_client is not None:
232
+ try:
233
+ result = await asyncio.wait_for(
234
+ self._llm_audit(goal, answer),
235
+ timeout=self.timeout_s
236
+ )
237
+ if result:
238
+ # Merge: se LLM dice PASS ma euristico ha trovato issue β†’ UNKNOWN
239
+ if result.status == AuditStatus.PASS and heuristic_result.issues:
240
+ result.status = AuditStatus.UNKNOWN
241
+ result.issues = heuristic_result.issues
242
+ result.confidence = min(result.confidence, 0.65)
243
+ return result
244
+ except asyncio.TimeoutError:
245
+ _logger.warning("[AuditL2] timeout LLM (%.1fs) β€” fallback euristico", self.timeout_s)
246
+ except Exception as e:
247
+ _logger.warning("[AuditL2] errore LLM (%s) β€” fallback euristico", type(e).__name__)
248
+
249
+ return heuristic_result
250
+
251
+ async def _llm_audit(self, goal: str, answer: str) -> Optional[AuditL2Result]:
252
+ """Chiamata LLM reale per l'audit semantico."""
253
+ prompt = _build_audit_prompt(goal, answer)
254
+ # Preferisce modello veloce/economico (8B) β€” audit non richiede ragionamento profondo
255
+ _model = getattr(self.ai_client, "_audit_model", None) or "llama-3.1-8b-instant"
256
+ response = await self.ai_client.chat.completions.create(
257
+ model=_model,
258
+ messages=[
259
+ {"role": "system", "content": _AUDIT_SYSTEM},
260
+ {"role": "user", "content": prompt},
261
+ ],
262
+ max_tokens=256,
263
+ temperature=0.0, # deterministico
264
+ )
265
+ raw = response.choices[0].message.content or ""
266
+ match = re.search(r"\{[\s\S]*?\}", raw)
267
+ if not match:
268
+ _logger.warning("[AuditL2] risposta LLM non contiene JSON: %.80s", raw)
269
+ return None
270
+ try:
271
+ parsed = json.loads(match.group(0))
272
+ except json.JSONDecodeError as _je:
273
+ _logger.warning("[AuditL2] JSON decode error: %s", _je)
274
+ return None
275
+ status_raw = parsed.get("status", "UNKNOWN").upper()
276
+ try:
277
+ status = AuditStatus(status_raw)
278
+ except ValueError:
279
+ status = AuditStatus.UNKNOWN
280
+ return AuditL2Result(
281
+ status=status,
282
+ confidence=float(parsed.get("confidence", 0.70)),
283
+ issues=parsed.get("issues", []),
284
+ repair_hint=parsed.get("repair_hint", ""),
285
+ engine="llm",
286
+ )
287
+
288
+
289
+ # ── Singleton ─────────────────────────────────────────────────────────────────
290
+ _auditor: Optional[SemanticAuditorL2] = None
291
+
292
+ def get_auditor(ai_client: Any = None, timeout_s: float = 12.0) -> SemanticAuditorL2:
293
+ """
294
+ Ritorna o crea il singleton SemanticAuditorL2.
295
+ Se chiamato con ai_client=<client> e il singleton esiste giΓ  senza client,
296
+ aggiorna il client sul singleton esistente (upgrade lazy).
297
+ """
298
+ global _auditor
299
+ if _auditor is None:
300
+ _auditor = SemanticAuditorL2(ai_client=ai_client, timeout_s=timeout_s)
301
+ elif ai_client is not None and _auditor.ai_client is None:
302
+ _auditor.ai_client = ai_client # upgrade: inserisce client dopo init
303
+ return _auditor
agents/unified_loop_fallback.py CHANGED
@@ -33,10 +33,22 @@ from agents.unified_loop_types import (
33
  UnifiedLoopState,
34
  _maybe_await,
35
  _LANG_INSTRUCTIONS,
 
 
 
 
 
 
 
 
36
  )
37
 
38
  _logger = logging.getLogger("agente_ai")
39
 
 
 
 
 
40
 
41
  class FallbackMixin:
42
  async def _run_fallback(self, state: UnifiedLoopState,
@@ -2425,6 +2437,75 @@ class FallbackMixin:
2425
  except Exception as _gv_exc:
2426
  _logger.warning("S624 GoalVerifier failed (silent): %s", _gv_exc) # S624
2427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2428
  # Sprint 3b ITEM 8: Browser Goal Verification Ҁ” Playwright headless su app live
2429
  # Attivato solo se l'answer contiene un URL di deploy (pages.dev / vercel.app / ecc.)
2430
  # e il RequirementEngine ha trovato requisiti (già estratti sopra in _gv2_reqs).
 
33
  UnifiedLoopState,
34
  _maybe_await,
35
  _LANG_INSTRUCTIONS,
36
+ _ANALYTICAL_VERBS_RE,
37
+ _TASK_VERBS_RE,
38
+ _is_goal_ambiguous,
39
+ _is_borderline_ambiguous,
40
+ _BORDERLINE_FIX_RE,
41
+ _BORDERLINE_HELP_RE,
42
+ _BORDERLINE_MAKE_RE,
43
+ _detect_user_lang,
44
  )
45
 
46
  _logger = logging.getLogger("agente_ai")
47
 
48
+ # QF-2: costanti timeout β€” replicate da unified_loop.py (evita circular import)
49
+ LLM_TIMEOUT: float = float(os.getenv('LLM_CALL_TIMEOUT', '60'))
50
+ TOOL_TIMEOUT: float = float(os.getenv('TOOL_CALL_TIMEOUT', '25'))
51
+
52
 
53
  class FallbackMixin:
54
  async def _run_fallback(self, state: UnifiedLoopState,
 
2437
  except Exception as _gv_exc:
2438
  _logger.warning("S624 GoalVerifier failed (silent): %s", _gv_exc) # S624
2439
 
2440
+
2441
+ # S303: Audit Semantico L2 β€” verifica coerenza logica interna dell'output.
2442
+ # Eseguito DOPO GoalVerifier L1, solo se L1 β‰  FAIL (risparmio token).
2443
+ # Rileva: hallucination claims (deploy/push/send non verificati),
2444
+ # contraddizioni interne (errore + completato), sotto-obiettivi mancanti.
2445
+ # Silent failure totale β€” non blocca mai la risposta al client.
2446
+ _l2_should_run = (
2447
+ answer
2448
+ and not answer.startswith('[LLM')
2449
+ and len(answer.split()) > 15
2450
+ )
2451
+ try:
2452
+ _l1_was_fail = (
2453
+ getattr(_gvr, 'verification_status', None) is not None # type: ignore[name-defined]
2454
+ and str(getattr(_gvr, 'verification_status', '')).endswith('FAIL')
2455
+ )
2456
+ except NameError:
2457
+ _l1_was_fail = False # _gvr non definito β€” L1 non era attivo (goal non-code)
2458
+ if _l2_should_run and not _l1_was_fail:
2459
+ try:
2460
+ from agents.audit_semantic_l2 import get_auditor as _get_auditor_l2
2461
+ _auditor_l2 = _get_auditor_l2(
2462
+ ai_client=self._get_verifier_llm(), # cross-model (P25-B4)
2463
+ timeout_s=12.0,
2464
+ )
2465
+ _l2_result = await asyncio.wait_for(
2466
+ _auditor_l2.audit(state.goal, answer),
2467
+ timeout=13.0,
2468
+ )
2469
+ # Telemetria
2470
+ try:
2471
+ from api.state import increment_stat as _inc_l2
2472
+ _inc_l2(f"audit_l2_{_l2_result.status.value.lower()}")
2473
+ except Exception as _exc:
2474
+ _logger.debug("[S303] telemetry silenced: %s", type(_exc).__name__)
2475
+ _logger.info(
2476
+ "[S303] AuditL2 %s (conf=%.2f engine=%s) issues=%d",
2477
+ _l2_result.status.value,
2478
+ _l2_result.confidence,
2479
+ _l2_result.engine,
2480
+ len(_l2_result.issues),
2481
+ )
2482
+ if _l2_result.status.value == "FAIL" and _l2_result.issues:
2483
+ # Notifica UI β€” step visibile nel pannello avanzamento
2484
+ if on_step:
2485
+ await _maybe_await(on_step({
2486
+ "action": "audit_l2",
2487
+ "status": "warning",
2488
+ "visibility": "progress",
2489
+ "title": "⚠️ Verifica coerenza risposta",
2490
+ "explanation": _l2_result.issues[0][:120],
2491
+ }))
2492
+ # Appende nota discreta β€” non modifica il codice, solo avvisa
2493
+ if answer:
2494
+ _l2_note_parts = ["\n\n> ⚠️ **Nota di coerenza**:"]
2495
+ for _iss in _l2_result.issues[:2]:
2496
+ _l2_note_parts.append(f" {_iss}")
2497
+ if _l2_result.repair_hint:
2498
+ _l2_note_parts.append(f" \n> πŸ’‘ {_l2_result.repair_hint}")
2499
+ answer += "".join(_l2_note_parts)
2500
+ except asyncio.TimeoutError:
2501
+ try:
2502
+ from api.state import increment_stat as _inc_l2t
2503
+ _inc_l2t("audit_l2_timeout")
2504
+ except Exception as _exc:
2505
+ _logger.debug("[S303] timeout counter silenced: %s", type(_exc).__name__)
2506
+ except Exception as _l2_exc:
2507
+ _logger.debug("[S303] AuditL2 silenced: %s", type(_l2_exc).__name__) # S624
2508
+
2509
  # Sprint 3b ITEM 8: Browser Goal Verification Ҁ” Playwright headless su app live
2510
  # Attivato solo se l'answer contiene un URL di deploy (pages.dev / vercel.app / ecc.)
2511
  # e il RequirementEngine ha trovato requisiti (già estratti sopra in _gv2_reqs).
api/TELEGRAM_MODULES.md ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/api β€” Moduli Telegram (M2 Split)
2
+
3
+ > **Refactor M2 β€” 2 Luglio 2026**
4
+ > Il monolite `telegram_webhook.py` (2844 righe, 5 hotfix consecutivi) Γ¨ stato
5
+ > spezzato in 6 moduli a responsabilitΓ  singola. Hotfix futuri toccano **un solo file**.
6
+
7
+ ---
8
+
9
+ ## Grafo delle dipendenze
10
+
11
+ ```
12
+ telegram_tg_client.py (stdlib + httpx only)
13
+ β”‚
14
+ β”œβ”€β”€β–Ί telegram_keyboards.py (solo costanti/dict β€” zero import interni)
15
+ β”‚ β”‚
16
+ β”‚ β”œβ”€β”€β–Ί telegram_cmd_monitoring.py
17
+ β”‚ β”‚ └──► (esposto via telegram_webhook.py)
18
+ β”‚ β”‚
19
+ β”‚ β”œβ”€β”€β–Ί telegram_cmd_ai.py
20
+ β”‚ β”‚ └──► (esposto via telegram_webhook.py)
21
+ β”‚ β”‚
22
+ β”‚ └──► telegram_callbacks.py
23
+ β”‚ β”œβ”€β”€ importa _cmd_do, _cmd_autofix ... da telegram_cmd_ai
24
+ β”‚ └── importa _cmd_help, _cmd_status ... da telegram_cmd_monitoring
25
+ β”‚
26
+ └──► telegram_webhook.py (router FastAPI puro β€” importa da tutti)
27
+ └──► montato in backend/main.py come _tg_webhook_router
28
+ ```
29
+
30
+ ---
31
+
32
+ ## Moduli
33
+
34
+ ### `telegram_tg_client.py` β€” 276 righe
35
+ **Ruolo:** Telegram Bot API puro. Layer 0 senza dipendenze interne.
36
+
37
+ | Simbolo | Descrizione |
38
+ |---------|-------------|
39
+ | `_get_bot_token()` | Legge `TELEGRAM_BOT_TOKEN` dall'env |
40
+ | `_log_tg_exc(task)` | Log eccezioni fire-and-forget (Gap-2.6) |
41
+ | `_fmt_elapsed(sec)` | Formatta durata in human-readable |
42
+ | `_tg_reply(chat_id, text)` | Invia messaggio con parse_mode=HTML |
43
+ | `_tg_send(chat_id, text)` | Come reply, ritorna `message_id` |
44
+ | `_tg_edit(chat_id, msg_id, text)` | Modifica messaggio esistente |
45
+ | `_tg_photo(chat_id, url, caption)` | Invia foto via URL |
46
+ | `_tg_typing(chat_id)` | Invia `sendChatAction typing` |
47
+ | `_tg_react(chat_id, msg_id, emoji)` | Imposta reaction emoji |
48
+ | `_tg_answer_callback(callback_id)` | Risponde a `callback_query` (≀3s) |
49
+
50
+ **Import:** `asyncio, html, logging, os, time, httpx`
51
+
52
+ ---
53
+
54
+ ### `telegram_keyboards.py` β€” 109 righe
55
+ **Ruolo:** Costanti UI e keyboards. Zero logica, zero import interni.
56
+
57
+ | Simbolo | Tipo | Descrizione |
58
+ |---------|------|-------------|
59
+ | `_QUICK_PICK_KB` | `dict` | Inline keyboard selezione task rapida |
60
+ | `_MAIN_KB` | `dict` | Reply keyboard principale |
61
+ | `_BENCH_ACTION_KB` | `dict` | Keyboard post-benchmark |
62
+ | `_TASK_MENU_KB` | `dict` | Sub-menu task |
63
+ | `_STATUS_MENU_KB` | `dict` | Sub-menu stato sistema |
64
+ | `_PERF_MENU_KB` | `dict` | Sub-menu performance |
65
+ | `_HEALTH_MENU_KB` | `dict` | Sub-menu health |
66
+ | `_DEV_MENU_KB` | `dict` | Sub-menu developer |
67
+ | `_LAST_GOAL` | `dict[int,str]` | Memoria per bottone πŸ” Rifai |
68
+ | `_BENCH_CACHE` | `dict[int,dict]` | Ultimo run benchmark per chat |
69
+ | `_after_task_kb(chat_id)` | func | Keyboard dinamica post-task |
70
+
71
+ **Import:** solo `from __future__ import annotations`
72
+
73
+ ---
74
+
75
+ ### `telegram_cmd_monitoring.py` β€” 456 righe
76
+ **Ruolo:** Comandi di monitoraggio, stato e diagnostica.
77
+
78
+ | Comando / Funzione | Trigger Telegram |
79
+ |--------------------|-----------------|
80
+ | `_cmd_help(chat_id)` | `/start` `/help` `tgw_help` |
81
+ | `_cmd_logs(chat_id)` | `/logs` `tgw_logs` |
82
+ | `_cmd_status(chat_id)` | `/stato` `tgw_status` |
83
+ | `_cmd_commit_summary(chat_id)` | `/commit` `tgw_commits` |
84
+ | `_cmd_check(chat_id)` | `/salute` `tgw_health` |
85
+ | `_cmd_tasks(chat_id)` | `/attivitΓ ` `tgw_tasks` |
86
+ | `_cmd_git(chat_id, args)` | `/git` `tgw_git` |
87
+ | `_cmd_coord(chat_id)` | `/coord` `tgw_coord` |
88
+ | `_cmd_scan_now(chat_id)` | `/scan` `tgw_scan` |
89
+ | `_cmd_telemetry(chat_id)` | `/telemetria` `tgw_telemetry` |
90
+
91
+ **Import:** stdlib + `telegram_tg_client` + `telegram_keyboards`
92
+
93
+ ---
94
+
95
+ ### `telegram_cmd_ai.py` β€” 1152 righe
96
+ **Ruolo:** Comandi AI, LLM e operativi. Modulo piΓΉ pesante β€” contiene lo streaming loop.
97
+
98
+ | Comando / Funzione | Trigger Telegram |
99
+ |--------------------|-----------------|
100
+ | `_cmd_do(chat_id, goal)` | `/avvia` β€” lancia task AI con streaming |
101
+ | `_cmd_autofix(chat_id)` | `/fix` `tgw_autofix` |
102
+ | `_cmd_nota(chat_id, text)` | `/nota` `tgw_nota` |
103
+ | `_cmd_cerca(chat_id, query)` | `/cerca` `tgw_cerca` |
104
+ | `_cmd_meteo(chat_id, city)` | `/meteo` `tgw_meteo` |
105
+ | `_cmd_riepilogo(chat_id)` | `/riepilogo` `tgw_briefing` |
106
+ | `_cmd_score(chat_id)` | `/score` `tgw_score` |
107
+ | `_cmd_bench(chat_id)` | `/bench` `tgw_bench` |
108
+ | `_cmd_improve(chat_id, target)` | `/migliora` `tgw_improve` |
109
+
110
+ **Import:** stdlib + `telegram_tg_client` + `telegram_keyboards`
111
+
112
+ ---
113
+
114
+ ### `telegram_callbacks.py` β€” 353 righe
115
+ **Ruolo:** Smista `callback_query` e `inline_query` in arrivo da Telegram.
116
+
117
+ | Funzione | Descrizione |
118
+ |----------|-------------|
119
+ | `_handle_inline(iq, token)` | Risponde alle inline query (`@ARJagent_ap_bot testo`) |
120
+ | `_handle_callback(cb, token)` | Dispatch per tutti i `callback_data` `tgw_*` / `agent` / `qp_*` |
121
+
122
+ **Import:** `telegram_tg_client` + `telegram_keyboards` + `telegram_cmd_ai` + `telegram_cmd_monitoring`
123
+
124
+ > ⚠️ **Questo modulo crea dipendenze circolari potenziali** β€” non importare
125
+ > `telegram_callbacks` da `telegram_cmd_ai` o `telegram_cmd_monitoring`.
126
+
127
+ ---
128
+
129
+ ### `telegram_webhook.py` β€” 605 righe *(era 2844)*
130
+ **Ruolo:** Router FastAPI puro. Solo endpoint HTTP, zero logica di business.
131
+
132
+ | Endpoint | Metodo | Descrizione |
133
+ |----------|--------|-------------|
134
+ | `/api/telegram/webhook` | POST | Ricezione update getUpdates (polling daemon) |
135
+ | `/api/telegram/process` | POST | Endpoint alternativo per CF Pages proxy |
136
+ | `/api/telegram/config/invalidate` | POST | Invalida cache config bot |
137
+
138
+ **Import:** FastAPI + tutti i 5 moduli sopra
139
+ **Montato in:** `backend/main.py` β†’ `app.include_router(_tg_webhook_router)`
140
+
141
+ ---
142
+
143
+ ## Regole per gli hotfix
144
+
145
+ | Vuoi cambiare... | Tocca solo... |
146
+ |------------------|---------------|
147
+ | Aspetto di un bottone / label | `telegram_keyboards.py` |
148
+ | Risposta a un comando /help, /stato… | `telegram_cmd_monitoring.py` |
149
+ | Logica task AI / streaming / bench | `telegram_cmd_ai.py` |
150
+ | Routing dei bottoni inline (tgw_*) | `telegram_callbacks.py` |
151
+ | Helpers HTTP verso api.telegram.org | `telegram_tg_client.py` |
152
+ | Routing endpoint FastAPI | `telegram_webhook.py` |
153
+
154
+ ---
155
+
156
+ ## Daemon Node.js (scripts/lib/daemon-callbacks.mjs)
157
+
158
+ Il daemon usa **getUpdates polling** (non webhook registrato).
159
+ Riceve gli update, li smista ai command handler Python via HTTP interno.
160
+ Il flusso `callback_data tgw_*` viene elaborato da `telegram_callbacks.py::_handle_callback`.
161
+
162
+ ```
163
+ Telegram ──► getUpdates daemon ──► POST /api/telegram/process
164
+ β”‚
165
+ telegram_webhook.py (router)
166
+ β”‚
167
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
168
+ message? callback_query?
169
+ β”‚ β”‚
170
+ telegram_cmd_*.py telegram_callbacks.py
171
+ ::_handle_callback
172
+ ```
api/agent_task_routes.py CHANGED
@@ -23,6 +23,7 @@ from .state import (
23
  write_ahead_task_created,
24
  )
25
  from .speculative import fire_speculative_tools
 
26
  try:
27
  from .quality_guardian import run_quality_check as _run_quality_check
28
  except Exception:
@@ -98,6 +99,8 @@ async def create_agent_task(body: AgentTaskIn):
98
  # S361: Speculative Tool Firing β€” pre-fires read-only tools in parallel
99
  # while the main model processes. Results cached for _run_direct_tools to consume.
100
  asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
 
 
101
  return {'taskId': task_id, 'status': 'QUEUED'}
102
 
103
 
@@ -794,3 +797,4 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
794
  'X-Accel-Buffering': 'no',
795
  }
796
  )
 
 
23
  write_ahead_task_created,
24
  )
25
  from .speculative import fire_speculative_tools
26
+ from .prewarm import fire_predictive_prewarm # S950
27
  try:
28
  from .quality_guardian import run_quality_check as _run_quality_check
29
  except Exception:
 
99
  # S361: Speculative Tool Firing β€” pre-fires read-only tools in parallel
100
  # while the main model processes. Results cached for _run_direct_tools to consume.
101
  asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
102
+ # S950: Predictive Pre-warming cluster 4x4
103
+ fire_predictive_prewarm(body.goal)
104
  return {'taskId': task_id, 'status': 'QUEUED'}
105
 
106
 
 
797
  'X-Accel-Buffering': 'no',
798
  }
799
  )
800
+
api/cache_endpoints.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cache_endpoints.py β€” Endpoint API per Gestione Cache
3
+
4
+ Endpoint:
5
+ GET /api/cache/stats β€” Statistiche cache
6
+ POST /api/cache/invalidate β€” Invalida un entry
7
+ GET /api/cache/health β€” Health check cache
8
+ POST /api/cache/reset-stats β€” Resetta statistiche
9
+ """
10
+
11
+ from fastapi import APIRouter, HTTPException
12
+ from pydantic import BaseModel
13
+ from typing import Optional
14
+ import logging
15
+
16
+ # Import cache manager
17
+ try:
18
+ from backend.api.cache_manager import (
19
+ get_cache_stats,
20
+ reset_cache_stats,
21
+ invalidate_cache,
22
+ CacheStrategy,
23
+ CACHE_ENABLED,
24
+ )
25
+ except ImportError:
26
+ from cache_manager import (
27
+ get_cache_stats,
28
+ reset_cache_stats,
29
+ invalidate_cache,
30
+ CacheStrategy,
31
+ CACHE_ENABLED,
32
+ )
33
+
34
+ _logger = logging.getLogger("cache_endpoints")
35
+
36
+ router = APIRouter(prefix="/api/cache", tags=["cache"])
37
+
38
+
39
+ # ── Modelli Pydantic ──────────────────────────────────────────────────────
40
+ class InvalidateCacheRequest(BaseModel):
41
+ strategy: str # "query", "memory", "embedding", "conversation", "analytics"
42
+ identifier: str
43
+
44
+
45
+ class CacheStatsResponse(BaseModel):
46
+ enabled: bool
47
+ hits: int
48
+ misses: int
49
+ sets: int
50
+ deletes: int
51
+ evictions: int
52
+ hit_rate_percent: float
53
+ total_requests: int
54
+
55
+
56
+ class CacheHealthResponse(BaseModel):
57
+ ok: bool
58
+ cache_enabled: bool
59
+ message: str
60
+
61
+
62
+ # ── Endpoint: Statistiche Cache ────────────────────────────────────────────
63
+ @router.get("/stats", response_model=CacheStatsResponse)
64
+ async def cache_stats():
65
+ """Ritorna le statistiche del cache layer."""
66
+ try:
67
+ stats = get_cache_stats()
68
+ return CacheStatsResponse(**stats)
69
+ except Exception as exc:
70
+ _logger.error(f"Error fetching cache stats: {exc}")
71
+ raise HTTPException(500, "Error fetching cache stats")
72
+
73
+
74
+ # ── Endpoint: Invalida Cache ──────────────────────────────────────────────
75
+ @router.post("/invalidate")
76
+ async def invalidate_cache_entry(req: InvalidateCacheRequest):
77
+ """Invalida un entry specifico dalla cache."""
78
+ try:
79
+ # Valida strategy
80
+ try:
81
+ strategy = CacheStrategy(req.strategy)
82
+ except ValueError:
83
+ raise HTTPException(
84
+ 400,
85
+ f"Invalid strategy. Must be one of: {', '.join([s.value for s in CacheStrategy])}",
86
+ )
87
+
88
+ # Invalida
89
+ success = await invalidate_cache(strategy, req.identifier)
90
+
91
+ return {
92
+ "ok": success,
93
+ "strategy": req.strategy,
94
+ "identifier": req.identifier,
95
+ "message": "Cache entry invalidated" if success else "Failed to invalidate cache entry",
96
+ }
97
+ except HTTPException:
98
+ raise
99
+ except Exception as exc:
100
+ _logger.error(f"Error invalidating cache: {exc}")
101
+ raise HTTPException(500, "Error invalidating cache")
102
+
103
+
104
+ # ── Endpoint: Health Check ────────────────────────────────────────────────
105
+ @router.get("/health", response_model=CacheHealthResponse)
106
+ async def cache_health():
107
+ """Health check per il cache layer."""
108
+ try:
109
+ stats = get_cache_stats()
110
+
111
+ return CacheHealthResponse(
112
+ ok=True,
113
+ cache_enabled=CACHE_ENABLED,
114
+ message=f"Cache layer operational. Hit rate: {stats.get('hit_rate_percent', 0):.1f}%",
115
+ )
116
+ except Exception as exc:
117
+ _logger.error(f"Cache health check failed: {exc}")
118
+ return CacheHealthResponse(
119
+ ok=False,
120
+ cache_enabled=CACHE_ENABLED,
121
+ message=f"Cache health check failed: {str(exc)}",
122
+ )
123
+
124
+
125
+ # ── Endpoint: Reset Statistiche ───────────────────────────────────────────
126
+ @router.post("/reset-stats")
127
+ async def reset_stats():
128
+ """Resetta le statistiche del cache."""
129
+ try:
130
+ reset_cache_stats()
131
+ return {
132
+ "ok": True,
133
+ "message": "Cache statistics reset",
134
+ }
135
+ except Exception as exc:
136
+ _logger.error(f"Error resetting cache stats: {exc}")
137
+ raise HTTPException(500, "Error resetting cache stats")
138
+
139
+
140
+ # ── Endpoint: Info Cache ──────────────────────────────────────────────────
141
+ @router.get("/info")
142
+ async def cache_info():
143
+ """Ritorna informazioni sulla configurazione del cache."""
144
+ try:
145
+ from cache_manager import (
146
+ CACHE_ENABLED,
147
+ CACHE_TTL_DEFAULT,
148
+ CACHE_MAX_SIZE,
149
+ CACHE_TTL_BY_STRATEGY,
150
+ SUPABASE_A_URL,
151
+ SUPABASE_B_URL,
152
+ )
153
+
154
+ return {
155
+ "enabled": CACHE_ENABLED,
156
+ "ttl_default_seconds": CACHE_TTL_DEFAULT,
157
+ "max_size": CACHE_MAX_SIZE,
158
+ "ttl_by_strategy": {k.value: v for k, v in CACHE_TTL_BY_STRATEGY.items()},
159
+ "supabase_a_configured": bool(SUPABASE_A_URL),
160
+ "supabase_b_configured": bool(SUPABASE_B_URL),
161
+ }
162
+ except Exception as exc:
163
+ _logger.error(f"Error fetching cache info: {exc}")
164
+ raise HTTPException(500, "Error fetching cache info")
api/cache_manager.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cache_manager.py β€” Cache Layer Distribuito su Supabase A
3
+
4
+ Architettura:
5
+ - Supabase A funge da archivio cache distribuito (read-only, non-critical)
6
+ - TTL configurabile per invalidazione automatica
7
+ - Fallback a B se A non disponibile
8
+ - Supporta cache per query, memoria, embeddings, conversazioni
9
+
10
+ Strategie di Caching:
11
+ 1. Query Cache: Risultati query SELECT memorizzati con TTL
12
+ 2. Memory Cache: Memoria agente memorizzata per accesso veloce
13
+ 3. Embedding Cache: Embeddings pre-calcolati per RAG
14
+ 4. Conversation Cache: Conversazioni recenti per accesso veloce
15
+
16
+ Invalidazione:
17
+ - TTL-based: Scadenza automatica dopo TTL
18
+ - Event-based: Invalidazione su INSERT/UPDATE/DELETE su B
19
+ - Manual: Invalidazione esplicita via API
20
+
21
+ Statistiche:
22
+ - Hit rate: % di richieste servite da cache
23
+ - Miss rate: % di richieste non in cache
24
+ - Eviction rate: % di entry rimosse per TTL
25
+ """
26
+
27
+ import os
28
+ import asyncio
29
+ import logging
30
+ import hashlib
31
+ import json
32
+ from typing import Optional, Dict, List, Any, Callable
33
+ from datetime import datetime, timedelta
34
+ from enum import Enum
35
+ import time
36
+
37
+ _logger = logging.getLogger("cache_manager")
38
+
39
+ # ── Configurazione Cache ───────────────────────────────────────────────────
40
+ CACHE_ENABLED = os.getenv("SUPABASE_CACHE_ENABLED", "true").lower() == "true"
41
+ CACHE_TTL_DEFAULT = int(os.getenv("SUPABASE_CACHE_TTL", "3600")) # 1 ora
42
+ CACHE_MAX_SIZE = int(os.getenv("SUPABASE_CACHE_MAX_SIZE", "10000")) # max entries
43
+ CACHE_STATS_ENABLED = os.getenv("SUPABASE_CACHE_STATS_ENABLED", "true").lower() == "true"
44
+
45
+ # ── Enumerazione Strategie Cache ───────────────────────────────────────────
46
+ class CacheStrategy(str, Enum):
47
+ QUERY = "query" # Cache risultati query
48
+ MEMORY = "memory" # Cache memoria agente
49
+ EMBEDDING = "embedding" # Cache embeddings
50
+ CONVERSATION = "conversation" # Cache conversazioni
51
+ ANALYTICS = "analytics" # Cache analytics/reporting
52
+
53
+
54
+ # ── TTL per Strategia ─────────────────────────────────────────────────────
55
+ CACHE_TTL_BY_STRATEGY = {
56
+ CacheStrategy.QUERY: int(os.getenv("CACHE_TTL_QUERY", "600")), # 10 min
57
+ CacheStrategy.MEMORY: int(os.getenv("CACHE_TTL_MEMORY", "1800")), # 30 min
58
+ CacheStrategy.EMBEDDING: int(os.getenv("CACHE_TTL_EMBEDDING", "7200")), # 2 ore
59
+ CacheStrategy.CONVERSATION: int(os.getenv("CACHE_TTL_CONVERSATION", "3600")), # 1 ora
60
+ CacheStrategy.ANALYTICS: int(os.getenv("CACHE_TTL_ANALYTICS", "300")), # 5 min
61
+ }
62
+
63
+ # ── Configurazione Supabase A ──────────────────────────────────────────────
64
+ SUPABASE_A_URL = os.getenv("SUPABASE_URL_A", "")
65
+ SUPABASE_A_KEY = os.getenv("SUPABASE_KEY_A", "")
66
+ SUPABASE_B_URL = os.getenv("SUPABASE_URL", "")
67
+ SUPABASE_B_KEY = os.getenv("SUPABASE_KEY", "")
68
+
69
+
70
+ # ── Client Cache ───────────────────────────────────────────────────────────
71
+ class CacheClient:
72
+ """Client per gestire cache su Supabase A."""
73
+
74
+ def __init__(self, url: str, key: str):
75
+ self.url = url
76
+ self.key = key
77
+ self.base_url = f"{url}/rest/v1" if url else None
78
+ self._stats = {
79
+ "hits": 0,
80
+ "misses": 0,
81
+ "sets": 0,
82
+ "deletes": 0,
83
+ "evictions": 0,
84
+ }
85
+
86
+ def _generate_cache_key(self, strategy: str, identifier: str) -> str:
87
+ """Genera una chiave cache univoca."""
88
+ combined = f"{strategy}:{identifier}"
89
+ return hashlib.sha256(combined.encode()).hexdigest()[:32]
90
+
91
+ async def get(self, strategy: CacheStrategy, identifier: str) -> Optional[Dict]:
92
+ """Recupera un valore dalla cache."""
93
+ if not CACHE_ENABLED or not self.base_url:
94
+ return None
95
+
96
+ cache_key = self._generate_cache_key(strategy.value, identifier)
97
+
98
+ try:
99
+ import httpx
100
+
101
+ url = f"{self.base_url}/cache_entries?cache_key=eq.{cache_key}"
102
+ headers = {
103
+ "apikey": self.key,
104
+ "Authorization": f"Bearer {self.key}",
105
+ "Content-Type": "application/json",
106
+ }
107
+
108
+ async with httpx.AsyncClient() as client:
109
+ response = await client.get(url, headers=headers, timeout=5.0)
110
+
111
+ if response.status_code == 200:
112
+ rows = response.json()
113
+ if rows:
114
+ entry = rows[0]
115
+
116
+ # Verifica TTL
117
+ if self._is_expired(entry):
118
+ # Elimina entry scaduta
119
+ await self._delete_entry(cache_key)
120
+ if CACHE_STATS_ENABLED:
121
+ self._stats["evictions"] += 1
122
+ return None
123
+
124
+ # Hit
125
+ if CACHE_STATS_ENABLED:
126
+ self._stats["hits"] += 1
127
+
128
+ return {
129
+ "value": entry.get("value"),
130
+ "cached_at": entry.get("created_at"),
131
+ "ttl_remaining": self._get_ttl_remaining(entry),
132
+ }
133
+
134
+ # Miss
135
+ if CACHE_STATS_ENABLED:
136
+ self._stats["misses"] += 1
137
+ return None
138
+
139
+ except Exception as exc:
140
+ _logger.error(f"Cache get error: {exc}")
141
+ return None
142
+
143
+ async def set(
144
+ self,
145
+ strategy: CacheStrategy,
146
+ identifier: str,
147
+ value: Any,
148
+ ttl: Optional[int] = None,
149
+ ) -> bool:
150
+ """Memorizza un valore nella cache."""
151
+ if not CACHE_ENABLED or not self.base_url:
152
+ return False
153
+
154
+ cache_key = self._generate_cache_key(strategy.value, identifier)
155
+ ttl = ttl or CACHE_TTL_BY_STRATEGY.get(strategy, CACHE_TTL_DEFAULT)
156
+
157
+ try:
158
+ import httpx
159
+
160
+ url = f"{self.base_url}/cache_entries"
161
+ headers = {
162
+ "apikey": self.key,
163
+ "Authorization": f"Bearer {self.key}",
164
+ "Content-Type": "application/json",
165
+ }
166
+
167
+ data = {
168
+ "cache_key": cache_key,
169
+ "strategy": strategy.value,
170
+ "identifier": identifier,
171
+ "value": json.dumps(value) if not isinstance(value, str) else value,
172
+ "ttl_seconds": ttl,
173
+ "created_at": datetime.now().isoformat(),
174
+ "expires_at": (datetime.now() + timedelta(seconds=ttl)).isoformat(),
175
+ }
176
+
177
+ async with httpx.AsyncClient() as client:
178
+ response = await client.post(url, json=data, headers=headers, timeout=5.0)
179
+
180
+ if response.status_code in (200, 201):
181
+ if CACHE_STATS_ENABLED:
182
+ self._stats["sets"] += 1
183
+ return True
184
+ else:
185
+ _logger.warning(f"Cache set failed: {response.status_code}")
186
+ return False
187
+
188
+ except Exception as exc:
189
+ _logger.error(f"Cache set error: {exc}")
190
+ return False
191
+
192
+ async def delete(self, strategy: CacheStrategy, identifier: str) -> bool:
193
+ """Elimina un valore dalla cache."""
194
+ if not CACHE_ENABLED or not self.base_url:
195
+ return False
196
+
197
+ cache_key = self._generate_cache_key(strategy.value, identifier)
198
+ return await self._delete_entry(cache_key)
199
+
200
+ async def _delete_entry(self, cache_key: str) -> bool:
201
+ """Elimina un entry dalla cache per chiave."""
202
+ try:
203
+ import httpx
204
+
205
+ url = f"{self.base_url}/cache_entries?cache_key=eq.{cache_key}"
206
+ headers = {
207
+ "apikey": self.key,
208
+ "Authorization": f"Bearer {self.key}",
209
+ }
210
+
211
+ async with httpx.AsyncClient() as client:
212
+ response = await client.delete(url, headers=headers, timeout=5.0)
213
+
214
+ if response.status_code in (200, 204):
215
+ if CACHE_STATS_ENABLED:
216
+ self._stats["deletes"] += 1
217
+ return True
218
+ return False
219
+
220
+ except Exception as exc:
221
+ _logger.error(f"Cache delete error: {exc}")
222
+ return False
223
+
224
+ def _is_expired(self, entry: Dict) -> bool:
225
+ """Verifica se un entry Γ¨ scaduto."""
226
+ expires_at = entry.get("expires_at")
227
+ if not expires_at:
228
+ return False
229
+
230
+ try:
231
+ expires_dt = datetime.fromisoformat(expires_at)
232
+ return datetime.now() > expires_dt
233
+ except:
234
+ return False
235
+
236
+ def _get_ttl_remaining(self, entry: Dict) -> int:
237
+ """Calcola il TTL rimanente in secondi."""
238
+ expires_at = entry.get("expires_at")
239
+ if not expires_at:
240
+ return 0
241
+
242
+ try:
243
+ expires_dt = datetime.fromisoformat(expires_at)
244
+ remaining = (expires_dt - datetime.now()).total_seconds()
245
+ return max(0, int(remaining))
246
+ except:
247
+ return 0
248
+
249
+ def get_stats(self) -> Dict[str, Any]:
250
+ """Ritorna statistiche cache."""
251
+ total = self._stats["hits"] + self._stats["misses"]
252
+ hit_rate = (self._stats["hits"] / total * 100) if total > 0 else 0
253
+
254
+ return {
255
+ "enabled": CACHE_ENABLED,
256
+ "hits": self._stats["hits"],
257
+ "misses": self._stats["misses"],
258
+ "sets": self._stats["sets"],
259
+ "deletes": self._stats["deletes"],
260
+ "evictions": self._stats["evictions"],
261
+ "hit_rate_percent": round(hit_rate, 2),
262
+ "total_requests": total,
263
+ }
264
+
265
+ def reset_stats(self):
266
+ """Resetta le statistiche."""
267
+ self._stats = {
268
+ "hits": 0,
269
+ "misses": 0,
270
+ "sets": 0,
271
+ "deletes": 0,
272
+ "evictions": 0,
273
+ }
274
+
275
+
276
+ # ── Cache Wrapper con Fallback ─────────────────────────────────────────────
277
+ class CacheManager:
278
+ """Gestore cache con fallback intelligente."""
279
+
280
+ def __init__(self):
281
+ self.cache_client = CacheClient(SUPABASE_A_URL, SUPABASE_A_KEY) if SUPABASE_A_URL else None
282
+ self.fallback_client = CacheClient(SUPABASE_B_URL, SUPABASE_B_KEY) if SUPABASE_B_URL else None
283
+
284
+ async def get_or_fetch(
285
+ self,
286
+ strategy: CacheStrategy,
287
+ identifier: str,
288
+ fetch_fn: Callable,
289
+ ttl: Optional[int] = None,
290
+ ) -> Any:
291
+ """
292
+ Recupera valore da cache o lo genera con fetch_fn.
293
+
294
+ Logica:
295
+ 1. Prova a leggere da cache A
296
+ 2. Se miss, chiama fetch_fn
297
+ 3. Memorizza risultato in cache A
298
+ 4. Ritorna valore
299
+ """
300
+ # Prova cache A
301
+ if self.cache_client:
302
+ cached = await self.cache_client.get(strategy, identifier)
303
+ if cached:
304
+ _logger.debug(f"Cache hit: {strategy.value}:{identifier}")
305
+ return json.loads(cached["value"]) if isinstance(cached["value"], str) else cached["value"]
306
+
307
+ # Cache miss β†’ fetch
308
+ _logger.debug(f"Cache miss: {strategy.value}:{identifier}, fetching...")
309
+ try:
310
+ value = await fetch_fn() if asyncio.iscoroutinefunction(fetch_fn) else fetch_fn()
311
+ except Exception as exc:
312
+ _logger.error(f"Fetch error: {exc}")
313
+ return None
314
+
315
+ # Memorizza in cache A
316
+ if self.cache_client and value is not None:
317
+ ttl = ttl or CACHE_TTL_BY_STRATEGY.get(strategy, CACHE_TTL_DEFAULT)
318
+ await self.cache_client.set(strategy, identifier, value, ttl)
319
+
320
+ return value
321
+
322
+ async def invalidate(self, strategy: CacheStrategy, identifier: str) -> bool:
323
+ """Invalida un entry cache."""
324
+ if self.cache_client:
325
+ return await self.cache_client.delete(strategy, identifier)
326
+ return False
327
+
328
+ async def invalidate_pattern(self, strategy: CacheStrategy, pattern: str) -> int:
329
+ """Invalida tutti gli entry che corrispondono a un pattern."""
330
+ # TODO: Implementare pattern matching
331
+ return 0
332
+
333
+ def get_stats(self) -> Dict[str, Any]:
334
+ """Ritorna statistiche cache."""
335
+ if self.cache_client:
336
+ return self.cache_client.get_stats()
337
+ return {"enabled": False}
338
+
339
+ def reset_stats(self):
340
+ """Resetta statistiche."""
341
+ if self.cache_client:
342
+ self.cache_client.reset_stats()
343
+
344
+
345
+ # ── Istanza Globale ───────────────────────────────────────────────────────
346
+ _cache_manager = CacheManager()
347
+
348
+
349
+ # ── Funzioni Pubbliche ────────────────────────────────────────────────────
350
+ async def get_cached(
351
+ strategy: CacheStrategy,
352
+ identifier: str,
353
+ fetch_fn: Callable,
354
+ ttl: Optional[int] = None,
355
+ ) -> Any:
356
+ """Recupera valore da cache o lo genera."""
357
+ return await _cache_manager.get_or_fetch(strategy, identifier, fetch_fn, ttl)
358
+
359
+
360
+ async def invalidate_cache(strategy: CacheStrategy, identifier: str) -> bool:
361
+ """Invalida un entry cache."""
362
+ return await _cache_manager.invalidate(strategy, identifier)
363
+
364
+
365
+ def get_cache_stats() -> Dict[str, Any]:
366
+ """Ritorna statistiche cache."""
367
+ return _cache_manager.get_stats()
368
+
369
+
370
+ def reset_cache_stats():
371
+ """Resetta statistiche cache."""
372
+ _cache_manager.reset_stats()
373
+
374
+
375
+ # ── Decorator per Caching Automatico ───────────────────────────────────────
376
+ def cached(strategy: CacheStrategy, ttl: Optional[int] = None):
377
+ """
378
+ Decorator per caching automatico di funzioni.
379
+
380
+ Uso:
381
+ @cached(CacheStrategy.QUERY, ttl=600)
382
+ async def get_user_data(user_id: str):
383
+ return await fetch_user_from_db(user_id)
384
+ """
385
+ def decorator(func: Callable):
386
+ async def wrapper(*args, **kwargs):
387
+ # Genera identifier da args/kwargs
388
+ identifier = f"{func.__name__}:{str(args)}:{str(kwargs)}"
389
+
390
+ # Usa cache manager
391
+ return await _cache_manager.get_or_fetch(
392
+ strategy,
393
+ identifier,
394
+ lambda: func(*args, **kwargs),
395
+ ttl,
396
+ )
397
+
398
+ return wrapper
399
+
400
+ return decorator
api/database_router.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ database_router.py β€” Router Query Intelligente per Separazione Workload Supabase A/B/C/D
3
+
4
+ Architettura:
5
+ A: Analytics/Cache/Read-Heavy (reporting, dashboard, cache distribuito)
6
+ B: Sync/State/Transazioni (stato globale, sincronizzazione cluster β€” PRIMARY)
7
+ C: Memory/RAG/Embeddings (backend memoria, vector search, skill index)
8
+ D: Audit/Logging/Compliance (event log, audit trail, compliance records)
9
+
10
+ Routing Logic:
11
+ 1. Query di LETTURA (SELECT) β†’ Preferisci A (read replica), fallback a B
12
+ 2. Query di SCRITTURA (INSERT/UPDATE) β†’ Usa B (PRIMARY)
13
+ 3. Query su MEMORIA/RAG (skill_memory, embeddings, conversations) β†’ Usa C
14
+ 4. Query su AUDIT/LOG (audit_events, compliance_log) β†’ Usa D
15
+ 5. Query di SINCRONIZZAZIONE (cluster_state, global_state) β†’ Usa B
16
+ """
17
+
18
+ import asyncio
19
+ import os
20
+ import logging
21
+ import re as _re
22
+ from typing import Optional, Literal
23
+ from enum import Enum
24
+ from fastapi import APIRouter, HTTPException, Request
25
+ from pydantic import BaseModel
26
+
27
+ router = APIRouter(prefix="/api/database", tags=["database"])
28
+ _logger = logging.getLogger("database_router")
29
+
30
+ # ─── Enumerazione Nodi Supabase ───────────────────────────────────────────
31
+ class SupabaseNode(str, Enum):
32
+ A = "A" # Analytics/Cache
33
+ B = "B" # PRIMARY (Sync/State)
34
+ C = "C" # Memory/RAG
35
+ D = "D" # Audit/Logging
36
+
37
+
38
+ # ─── Configurazione Nodi ──────────────────────────────────────────────────
39
+ SUPABASE_CONFIG = {
40
+ "A": {
41
+ "url": os.getenv("SUPABASE_URL_A", ""),
42
+ "key": os.getenv("SUPABASE_KEY_A", ""),
43
+ "role": "Analytics/Cache (Read-Heavy)",
44
+ "priority": 1, # Preferito per letture
45
+ },
46
+ "B": {
47
+ "url": os.getenv("SUPABASE_URL", ""), # PRIMARY
48
+ "key": os.getenv("SUPABASE_KEY", ""),
49
+ "role": "Sync/State (PRIMARY)",
50
+ "priority": 0, # Fallback universale
51
+ },
52
+ "C": {
53
+ "url": os.getenv("SUPABASE_URL_C", ""),
54
+ "key": os.getenv("SUPABASE_KEY_C", ""),
55
+ "role": "Memory/RAG/Embeddings",
56
+ "priority": 2,
57
+ },
58
+ "D": {
59
+ "url": os.getenv("SUPABASE_URL_D", ""),
60
+ "key": os.getenv("SUPABASE_KEY_D", ""),
61
+ "role": "Audit/Logging/Compliance",
62
+ "priority": 3,
63
+ },
64
+ }
65
+
66
+ # ─── Keyword Pericolosi (per read-only) ───────────────────────────────────
67
+ _DANGEROUS = frozenset(
68
+ {"drop", "truncate", "delete", "update", "insert", "alter", "create", "grant", "revoke"}
69
+ )
70
+ _MAX_ROWS = 500
71
+
72
+
73
+ # ─── Modelli Pydantic ─────────────────────────────────────────────────────
74
+ class QueryRequest(BaseModel):
75
+ sql: str
76
+ params: list = []
77
+ read_only: bool = True
78
+ preferred_node: Optional[SupabaseNode] = None # Override routing logic
79
+
80
+
81
+ class QueryResponse(BaseModel):
82
+ ok: bool
83
+ rows: list = []
84
+ columns: list = []
85
+ count: int = 0
86
+ truncated: bool = False
87
+ node_used: Optional[str] = None
88
+ error: Optional[str] = None
89
+
90
+
91
+ # ─── Funzioni Utility ─────────────────────────────────────────────────────
92
+ def _is_dangerous(sql: str) -> Optional[str]:
93
+ """Rilevamento keyword pericolose robusto contro CTE e multi-spazio."""
94
+ s_norm = " ".join(sql.strip().lower().split())
95
+ tokens = _re.split(r"[\s\(\),;]+", s_norm)
96
+ for tok in tokens:
97
+ if tok in _DANGEROUS:
98
+ return tok
99
+ for m in _re.finditer(r"\bas\s*\(\s*(\w+)", s_norm):
100
+ first_word = m.group(1).lower()
101
+ if first_word in _DANGEROUS:
102
+ return first_word
103
+ return None
104
+
105
+
106
+ def _detect_query_type(sql: str) -> Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"]:
107
+ """Rileva il tipo di query (SELECT, INSERT, UPDATE, DELETE, OTHER)."""
108
+ s_norm = " ".join(sql.strip().upper().split())
109
+ if s_norm.startswith("SELECT"):
110
+ return "SELECT"
111
+ elif s_norm.startswith("INSERT"):
112
+ return "INSERT"
113
+ elif s_norm.startswith("UPDATE"):
114
+ return "UPDATE"
115
+ elif s_norm.startswith("DELETE"):
116
+ return "DELETE"
117
+ return "OTHER"
118
+
119
+
120
+ def _detect_table_context(sql: str) -> Optional[str]:
121
+ """Rileva il contesto della tabella per routing intelligente."""
122
+ sql_lower = sql.lower()
123
+
124
+ # Tabelle di memoria/RAG β†’ Nodo C
125
+ if any(t in sql_lower for t in ["skill_memory", "embeddings", "conversations", "rag_index", "vector_store"]):
126
+ return "C"
127
+
128
+ # Tabelle di audit/logging β†’ Nodo D
129
+ if any(t in sql_lower for t in ["audit_events", "audit_log", "compliance_log", "event_log", "activity_log"]):
130
+ return "D"
131
+
132
+ # Tabelle di stato globale β†’ Nodo B
133
+ if any(t in sql_lower for t in ["cluster_state", "global_state", "sync_state", "agent_state", "daemon_status"]):
134
+ return "B"
135
+
136
+ return None
137
+
138
+
139
+ def _choose_node(
140
+ query_type: Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"],
141
+ table_context: Optional[str],
142
+ preferred_node: Optional[SupabaseNode],
143
+ ) -> SupabaseNode:
144
+ """
145
+ Logica di routing intelligente per scegliere il nodo Supabase.
146
+
147
+ PrioritΓ :
148
+ 1. preferred_node (override esplicito)
149
+ 2. table_context (rilevamento tabella)
150
+ 3. query_type (tipo di query)
151
+ 4. Fallback a B (PRIMARY)
152
+ """
153
+ # 1. Override esplicito
154
+ if preferred_node:
155
+ return preferred_node
156
+
157
+ # 2. Routing per contesto tabella
158
+ if table_context:
159
+ return SupabaseNode(table_context)
160
+
161
+ # 3. Routing per tipo query
162
+ if query_type == "SELECT":
163
+ # Preferisci A (read replica) se disponibile, altrimenti B
164
+ if SUPABASE_CONFIG["A"]["url"]:
165
+ return SupabaseNode.A
166
+ return SupabaseNode.B
167
+ elif query_type in ("INSERT", "UPDATE", "DELETE"):
168
+ # Sempre su B (PRIMARY)
169
+ return SupabaseNode.B
170
+
171
+ # 4. Fallback a B (PRIMARY)
172
+ return SupabaseNode.B
173
+
174
+
175
+ # ─── Endpoint Principale ──────────────────────────────────────────────────
176
+ @router.post("/query", response_model=QueryResponse)
177
+ async def database_query(req: QueryRequest, request: Request):
178
+ """
179
+ Endpoint query con routing intelligente tra nodi Supabase A/B/C/D.
180
+
181
+ Parametri:
182
+ - sql: query SQL
183
+ - params: parametri query
184
+ - read_only: blocca query pericolose (default: true)
185
+ - preferred_node: forza un nodo specifico (opzionale)
186
+
187
+ Ritorna:
188
+ - ok: successo
189
+ - rows: righe risultato
190
+ - columns: nomi colonne
191
+ - count: numero righe
192
+ - truncated: se risultato Γ¨ stato troncato
193
+ - node_used: nodo Supabase utilizzato
194
+ """
195
+ # Verifica token interno
196
+ _internal_token = os.getenv("INTERNAL_TOKEN", "")
197
+ if _internal_token and request.headers.get("X-Internal-Token") != _internal_token:
198
+ raise HTTPException(401, "Unauthorized")
199
+
200
+ # Rileva tipo query e contesto
201
+ query_type = _detect_query_type(req.sql)
202
+ table_context = _detect_table_context(req.sql)
203
+
204
+ # Scegli nodo
205
+ chosen_node = _choose_node(query_type, table_context, req.preferred_node)
206
+
207
+ # Verifica configurazione nodo
208
+ node_config = SUPABASE_CONFIG.get(chosen_node.value)
209
+ if not node_config or not node_config["url"]:
210
+ # Fallback a B se nodo non configurato
211
+ if chosen_node != SupabaseNode.B:
212
+ _logger.warning(
213
+ f"Nodo {chosen_node.value} non configurato, fallback a B. "
214
+ f"Configura SUPABASE_URL_{chosen_node.value} e SUPABASE_KEY_{chosen_node.value}."
215
+ )
216
+ chosen_node = SupabaseNode.B
217
+ node_config = SUPABASE_CONFIG["B"]
218
+
219
+ if not node_config["url"]:
220
+ return QueryResponse(
221
+ ok=False,
222
+ error=f"Nodo {chosen_node.value} non configurato. Imposta SUPABASE_URL_{chosen_node.value}.",
223
+ node_used=chosen_node.value,
224
+ )
225
+
226
+ # Verifica read-only
227
+ if req.read_only:
228
+ kw = _is_dangerous(req.sql)
229
+ if kw:
230
+ return QueryResponse(
231
+ ok=False,
232
+ error=f"Query bloccata (read-only): '{kw.upper()}' non consentito.",
233
+ node_used=chosen_node.value,
234
+ )
235
+
236
+ # Esegui query
237
+ try:
238
+ result = await _execute_query(
239
+ node_config["url"],
240
+ node_config["key"],
241
+ req.sql,
242
+ req.params,
243
+ )
244
+ result["node_used"] = chosen_node.value
245
+ return QueryResponse(**result)
246
+ except Exception as e:
247
+ _logger.error(f"Errore query su nodo {chosen_node.value}: {str(e)}")
248
+ return QueryResponse(
249
+ ok=False,
250
+ error=str(e)[:400],
251
+ node_used=chosen_node.value,
252
+ )
253
+
254
+
255
+ # ─── Esecuzione Query (Supabase PostgreSQL) ───────────────────────────────
256
+ async def _execute_query(url: str, key: str, sql: str, params: list) -> dict:
257
+ """Esegue query su Supabase PostgreSQL."""
258
+ try:
259
+ import psycopg2
260
+ import psycopg2.extras
261
+ except ImportError:
262
+ return {
263
+ "ok": False,
264
+ "error": "psycopg2 non installato. Aggiungi 'psycopg2-binary' a requirements.txt.",
265
+ }
266
+
267
+ def _run():
268
+ conn = psycopg2.connect(url)
269
+ try:
270
+ cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
271
+ cur.execute(sql, params or None)
272
+ try:
273
+ rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
274
+ cols = [d.name for d in (cur.description or [])]
275
+ except psycopg2.ProgrammingError:
276
+ rows, cols = [], []
277
+ conn.commit()
278
+ finally:
279
+ conn.close()
280
+ return rows, cols
281
+
282
+ rows, cols = await asyncio.to_thread(_run)
283
+ return {
284
+ "ok": True,
285
+ "rows": rows,
286
+ "columns": cols,
287
+ "count": len(rows),
288
+ "truncated": len(rows) == _MAX_ROWS,
289
+ }
290
+
291
+
292
+ # ─── Endpoint Debug (info nodi) ───────────────────────────────────────────
293
+ @router.get("/nodes/status")
294
+ async def nodes_status():
295
+ """Ritorna lo stato di configurazione di tutti i nodi Supabase."""
296
+ status = {}
297
+ for node_id, config in SUPABASE_CONFIG.items():
298
+ status[node_id] = {
299
+ "role": config["role"],
300
+ "configured": bool(config["url"]),
301
+ "url_preview": config["url"][:30] + "..." if config["url"] else "NOT SET",
302
+ }
303
+ return {"nodes": status}
api/global_state_sync_optimized.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ global_state_sync_optimized.py β€” Sincronizzazione Selettiva Ottimizzata per Workload Separati
3
+
4
+ Architettura Separazione Workload:
5
+ A: Analytics/Cache/Read-Heavy (NO sincronizzazione, solo letture)
6
+ B: Sync/State/Transazioni (PRIMARY β€” sincronizzazione completa)
7
+ C: Memory/RAG/Embeddings (sincronizzazione memoria e embeddings)
8
+ D: Audit/Logging/Compliance (sincronizzazione audit-only, append-only)
9
+
10
+ Strategia Sincronizzazione:
11
+ 1. B ← A: Nessuna (A Γ¨ read-only, non genera stato)
12
+ 2. B ← C: Sincronizzazione memoria/skill (bidirezionale)
13
+ 3. B ← D: Sincronizzazione audit (unidirezionale, D β†’ B)
14
+ 4. C ← B: Sincronizzazione stato globale (unidirezionale, B β†’ C)
15
+ 5. D ← B: Sincronizzazione eventi (unidirezionale, B β†’ D)
16
+
17
+ Benefici:
18
+ - Riduce traffico di sincronizzazione (solo tabelle rilevanti)
19
+ - Evita conflitti di write (ogni nodo scrive su tabelle specifiche)
20
+ - Mantiene audit immutabile (D append-only)
21
+ - Massimizza throughput (parallelizzazione per nodo)
22
+ """
23
+
24
+ import os
25
+ import asyncio
26
+ import logging
27
+ from typing import Optional, Dict, List, Any, Literal
28
+ from datetime import datetime, timedelta
29
+ from enum import Enum
30
+ import json
31
+
32
+ _logger = logging.getLogger("global_state_sync_optimized")
33
+
34
+ # ── Enumerazione Nodi ──────────────────────────────────────────────────────
35
+ class SupabaseNode(str, Enum):
36
+ A = "A" # Analytics/Cache
37
+ B = "B" # PRIMARY (Sync/State)
38
+ C = "C" # Memory/RAG
39
+ D = "D" # Audit/Logging
40
+
41
+
42
+ # ── Configurazione Nodi ───────────────────────────────────────────────────
43
+ SUPABASE_CONFIG = {
44
+ "A": {
45
+ "url": os.getenv("SUPABASE_URL_A", ""),
46
+ "key": os.getenv("SUPABASE_KEY_A", ""),
47
+ "role": "Analytics/Cache",
48
+ "sync_enabled": False, # A non sincronizza (read-only)
49
+ },
50
+ "B": {
51
+ "url": os.getenv("SUPABASE_URL", ""), # PRIMARY
52
+ "key": os.getenv("SUPABASE_KEY", ""),
53
+ "role": "Sync/State (PRIMARY)",
54
+ "sync_enabled": True,
55
+ },
56
+ "C": {
57
+ "url": os.getenv("SUPABASE_URL_C", ""),
58
+ "key": os.getenv("SUPABASE_KEY_C", ""),
59
+ "role": "Memory/RAG",
60
+ "sync_enabled": True,
61
+ },
62
+ "D": {
63
+ "url": os.getenv("SUPABASE_URL_D", ""),
64
+ "key": os.getenv("SUPABASE_KEY_D", ""),
65
+ "role": "Audit/Logging",
66
+ "sync_enabled": True,
67
+ },
68
+ }
69
+
70
+ # ── Mapping Tabelle β†’ Nodi ─────────────────────────────────────────────────
71
+ TABLE_NODE_MAPPING = {
72
+ # Nodo B (PRIMARY) β€” Stato Globale
73
+ "cluster_state": "B",
74
+ "global_state": "B",
75
+ "sync_state": "B",
76
+ "agent_state": "B",
77
+ "daemon_status": "B",
78
+
79
+ # Nodo C β€” Memoria e RAG
80
+ "agent_memory": "C",
81
+ "skill_memory": "C",
82
+ "embeddings": "C",
83
+ "conversations": "C",
84
+ "rag_index": "C",
85
+ "vector_store": "C",
86
+
87
+ # Nodo D β€” Audit e Logging
88
+ "audit_events": "D",
89
+ "audit_log": "D",
90
+ "compliance_log": "D",
91
+ "event_log": "D",
92
+ "activity_log": "D",
93
+ }
94
+
95
+ # ── Configurazione Sincronizzazione ────────────────────────────────────────
96
+ SYNC_RULES = {
97
+ # (source_node, target_node): [tabelle da sincronizzare]
98
+ ("B", "C"): ["agent_memory", "skill_memory", "cluster_state"], # B β†’ C: stato globale
99
+ ("C", "B"): ["agent_memory", "skill_memory"], # C β†’ B: memoria aggiornata
100
+ ("B", "D"): ["audit_events", "event_log"], # B β†’ D: eventi
101
+ ("D", "B"): [], # D β†’ B: nessuna (audit Γ¨ append-only)
102
+ }
103
+
104
+ SYNC_ENABLED = os.getenv("GLOBAL_STATE_SYNC_ENABLED", "true").lower() == "true"
105
+ SYNC_INTERVAL_SECONDS = int(os.getenv("SYNC_INTERVAL_SECONDS", "30"))
106
+
107
+
108
+ # ── Client Supabase ───────────────────────────────────────────────────────
109
+ class SupabaseClient:
110
+ """Client per accedere a un singolo database Supabase."""
111
+
112
+ def __init__(self, url: str, key: str, node_id: str):
113
+ self.url = url
114
+ self.key = key
115
+ self.node_id = node_id
116
+ self.base_url = f"{url}/rest/v1" if url else None
117
+
118
+ async def query(self, table: str, filters: Optional[Dict] = None) -> List[Dict]:
119
+ """Esegue una query SELECT su una tabella."""
120
+ if not self.base_url:
121
+ return []
122
+
123
+ import httpx
124
+
125
+ url = f"{self.base_url}/{table}"
126
+ headers = {
127
+ "apikey": self.key,
128
+ "Authorization": f"Bearer {self.key}",
129
+ "Content-Type": "application/json",
130
+ }
131
+
132
+ try:
133
+ async with httpx.AsyncClient() as client:
134
+ response = await client.get(url, headers=headers, timeout=10.0)
135
+ if response.status_code == 200:
136
+ return response.json()
137
+ else:
138
+ _logger.warning(f"Supabase {self.node_id} query failed: {response.status_code}")
139
+ return []
140
+ except Exception as exc:
141
+ _logger.error(f"Supabase {self.node_id} error: {exc}")
142
+ return []
143
+
144
+ async def insert(self, table: str, data: Dict) -> bool:
145
+ """Inserisce un record in una tabella."""
146
+ if not self.base_url:
147
+ return False
148
+
149
+ import httpx
150
+
151
+ url = f"{self.base_url}/{table}"
152
+ headers = {
153
+ "apikey": self.key,
154
+ "Authorization": f"Bearer {self.key}",
155
+ "Content-Type": "application/json",
156
+ }
157
+
158
+ try:
159
+ async with httpx.AsyncClient() as client:
160
+ response = await client.post(url, json=data, headers=headers, timeout=10.0)
161
+ return response.status_code in (200, 201)
162
+ except Exception as exc:
163
+ _logger.error(f"Supabase {self.node_id} insert error: {exc}")
164
+ return False
165
+
166
+
167
+ # ── Sincronizzazione Selettiva ─────────────────────────────────────────────
168
+ class SelectiveSyncManager:
169
+ """Gestisce la sincronizzazione selettiva tra nodi."""
170
+
171
+ def __init__(self):
172
+ self.clients = {}
173
+ self._enabled = SYNC_ENABLED
174
+ self._last_sync = {}
175
+
176
+ for node_id, config in SUPABASE_CONFIG.items():
177
+ if config["url"] and config["key"]:
178
+ self.clients[node_id] = SupabaseClient(config["url"], config["key"], node_id)
179
+
180
+ async def sync_all(self) -> Dict[str, Any]:
181
+ """Esegue la sincronizzazione completa tra tutti i nodi."""
182
+ if not self._enabled or not self.clients:
183
+ return {"ok": False, "error": "Sync disabled or no clients configured"}
184
+
185
+ results = {
186
+ "ok": True,
187
+ "synced_at": datetime.now().isoformat(),
188
+ "syncs": [],
189
+ }
190
+
191
+ # Esegui sincronizzazioni secondo le regole
192
+ for (source, target), tables in SYNC_RULES.items():
193
+ if not tables:
194
+ continue # Salta se nessuna tabella da sincronizzare
195
+
196
+ source_client = self.clients.get(source)
197
+ target_client = self.clients.get(target)
198
+
199
+ if not source_client or not target_client:
200
+ continue
201
+
202
+ sync_result = await self._sync_nodes(source_client, target_client, tables)
203
+ results["syncs"].append(sync_result)
204
+
205
+ return results
206
+
207
+ async def _sync_nodes(
208
+ self,
209
+ source: SupabaseClient,
210
+ target: SupabaseClient,
211
+ tables: List[str],
212
+ ) -> Dict[str, Any]:
213
+ """Sincronizza tabelle specifiche da source a target."""
214
+ result = {
215
+ "source": source.node_id,
216
+ "target": target.node_id,
217
+ "tables": tables,
218
+ "synced_count": 0,
219
+ "errors": [],
220
+ }
221
+
222
+ for table in tables:
223
+ try:
224
+ # Leggi da source
225
+ rows = await source.query(table)
226
+ if not rows:
227
+ continue
228
+
229
+ # Scrivi su target (upsert logic)
230
+ for row in rows:
231
+ success = await target.insert(table, row)
232
+ if success:
233
+ result["synced_count"] += 1
234
+ else:
235
+ result["errors"].append(f"Failed to sync {table} row")
236
+
237
+ except Exception as exc:
238
+ result["errors"].append(f"Error syncing {table}: {str(exc)}")
239
+ _logger.error(f"Sync error {source.node_id} β†’ {target.node_id} ({table}): {exc}")
240
+
241
+ return result
242
+
243
+ async def sync_memory(self, session_id: str) -> Dict[str, Any]:
244
+ """Sincronizza memoria per una sessione specifica (B ↔ C)."""
245
+ if not self._enabled:
246
+ return {"ok": False}
247
+
248
+ source_client = self.clients.get("B")
249
+ target_client = self.clients.get("C")
250
+
251
+ if not source_client or not target_client:
252
+ return {"ok": False, "error": "B or C not configured"}
253
+
254
+ try:
255
+ # Leggi memoria da B
256
+ rows = await source_client.query("agent_memory", {"session_id": session_id})
257
+
258
+ # Scrivi su C
259
+ for row in rows:
260
+ await target_client.insert("agent_memory", row)
261
+
262
+ return {
263
+ "ok": True,
264
+ "session_id": session_id,
265
+ "synced_rows": len(rows),
266
+ }
267
+ except Exception as exc:
268
+ _logger.error(f"Memory sync error: {exc}")
269
+ return {"ok": False, "error": str(exc)}
270
+
271
+ async def log_audit_event(self, event: Dict) -> bool:
272
+ """Registra un evento di audit su D (append-only)."""
273
+ if not self._enabled:
274
+ return False
275
+
276
+ target_client = self.clients.get("D")
277
+ if not target_client:
278
+ return False
279
+
280
+ event["timestamp"] = datetime.now().isoformat()
281
+ return await target_client.insert("audit_events", event)
282
+
283
+
284
+ # ── Scheduler Sincronizzazione ─────────────────────────────────────────────
285
+ class SyncScheduler:
286
+ """Scheduler per sincronizzazione periodica."""
287
+
288
+ def __init__(self, manager: SelectiveSyncManager):
289
+ self.manager = manager
290
+ self._running = False
291
+
292
+ async def start(self):
293
+ """Avvia il scheduler di sincronizzazione."""
294
+ self._running = True
295
+ _logger.info(f"Starting sync scheduler (interval: {SYNC_INTERVAL_SECONDS}s)")
296
+
297
+ while self._running:
298
+ try:
299
+ result = await self.manager.sync_all()
300
+ if result["ok"]:
301
+ _logger.debug(f"Sync completed: {len(result['syncs'])} operations")
302
+ else:
303
+ _logger.warning(f"Sync failed: {result.get('error')}")
304
+ except Exception as exc:
305
+ _logger.error(f"Sync scheduler error: {exc}")
306
+
307
+ await asyncio.sleep(SYNC_INTERVAL_SECONDS)
308
+
309
+ def stop(self):
310
+ """Ferma il scheduler."""
311
+ self._running = False
312
+ _logger.info("Sync scheduler stopped")
313
+
314
+
315
+ # ── Istanza Globale ───────────────────────────────────────────────────────
316
+ _sync_manager = SelectiveSyncManager()
317
+ _sync_scheduler = SyncScheduler(_sync_manager)
318
+
319
+
320
+ # ── Funzioni Pubbliche ────────────────────────────────────────────────────
321
+ async def get_unified_memory(session_id: str) -> Dict[str, Any]:
322
+ """Recupera memoria unificata per una sessione."""
323
+ return await _sync_manager.sync_memory(session_id)
324
+
325
+
326
+ async def sync_all() -> Dict[str, Any]:
327
+ """Esegue sincronizzazione completa."""
328
+ return await _sync_manager.sync_all()
329
+
330
+
331
+ async def log_audit(event: Dict) -> bool:
332
+ """Registra un evento di audit."""
333
+ return await _sync_manager.log_audit_event(event)
334
+
335
+
336
+ async def start_scheduler():
337
+ """Avvia il scheduler di sincronizzazione."""
338
+ await _sync_scheduler.start()
339
+
340
+
341
+ def stop_scheduler():
342
+ """Ferma il scheduler."""
343
+ _sync_scheduler.stop()
api/global_state_sync_with_oracle.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ global_state_sync_with_oracle.py β€” Sincronizzazione Globale con Nodo E (Oracle)
3
+
4
+ Integra il Nodo E (Oracle Cloud) nella logica di sincronizzazione del cluster.
5
+
6
+ Architettura:
7
+ B (PRIMARY): Stato globale, transazioni critiche
8
+ C (Memory/RAG): Embeddings, conversazioni
9
+ D (Audit): Event log, compliance
10
+ E (Compute): Task distribuiti, carichi pesanti
11
+ A (Cache): Read-only, analytics
12
+
13
+ Flussi di Sincronizzazione:
14
+ - B ↔ C: Memoria bidirezionale
15
+ - B β†’ D: Audit unidirezionale
16
+ - B β†’ E: Task distribuiti
17
+ - E β†’ B: Risultati task
18
+ - A: Nessuna (read-only)
19
+ """
20
+
21
+ import os
22
+ import asyncio
23
+ import logging
24
+ from typing import Dict, List, Any, Optional
25
+ from datetime import datetime, timedelta
26
+ from enum import Enum
27
+ import json
28
+
29
+ _logger = logging.getLogger("global_state_sync_oracle")
30
+
31
+ # ── Configurazione Nodi ────────────────────────────────────────────────────
32
+ ORACLE_NODE_E_ENABLED = os.getenv("ORACLE_NODE_E_ENABLED", "true").lower() == "true"
33
+ ORACLE_NODE_E_IP = os.getenv("ORACLE_NODE_E_IP", "80.225.89.217")
34
+ ORACLE_NODE_E_PORT = int(os.getenv("ORACLE_NODE_E_PORT", "8080"))
35
+
36
+ SYNC_INTERVAL_SECONDS = int(os.getenv("SYNC_INTERVAL_SECONDS", "30"))
37
+ SYNC_BATCH_SIZE = int(os.getenv("SYNC_BATCH_SIZE", "100"))
38
+ SYNC_TIMEOUT_SECONDS = int(os.getenv("SYNC_TIMEOUT_SECONDS", "60"))
39
+
40
+
41
+ # ── Enumerazione Tipo Sincronizzazione ─────────────────────────────────────
42
+ class SyncType(str, Enum):
43
+ MEMORY = "memory" # B ↔ C
44
+ AUDIT = "audit" # B β†’ D
45
+ TASK = "task" # B β†’ E
46
+ RESULT = "result" # E β†’ B
47
+ CACHE = "cache" # A (read-only)
48
+
49
+
50
+ # ── Classe Sincronizzatore ────────────────────────────────────────────────
51
+ class GlobalStateSyncWithOracle:
52
+ """Gestore sincronizzazione globale con Nodo E."""
53
+
54
+ def __init__(self):
55
+ self.sync_history = []
56
+ self.pending_tasks = []
57
+ self.last_sync_time = {}
58
+ self.sync_stats = {
59
+ "total_syncs": 0,
60
+ "successful_syncs": 0,
61
+ "failed_syncs": 0,
62
+ "total_items_synced": 0,
63
+ }
64
+
65
+ async def sync_memory(self, session_id: str) -> Dict[str, Any]:
66
+ """Sincronizza memoria tra B e C."""
67
+ try:
68
+ _logger.info(f"Sincronizzazione memoria: {session_id}")
69
+
70
+ # Recupera memoria da B
71
+ memory_b = await self._fetch_from_node_b(f"agent_memory:{session_id}")
72
+
73
+ # Sincronizza su C
74
+ await self._sync_to_node_c(f"agent_memory:{session_id}", memory_b)
75
+
76
+ # Sincronizza su E (se abilitato e ha task correlati)
77
+ if ORACLE_NODE_E_ENABLED:
78
+ await self._sync_to_node_e(f"memory:{session_id}", memory_b)
79
+
80
+ self.sync_stats["successful_syncs"] += 1
81
+ self.sync_stats["total_items_synced"] += 1
82
+
83
+ return {
84
+ "ok": True,
85
+ "session_id": session_id,
86
+ "synced_to": ["B", "C", "E"] if ORACLE_NODE_E_ENABLED else ["B", "C"],
87
+ }
88
+ except Exception as exc:
89
+ _logger.error(f"Errore sincronizzazione memoria: {exc}")
90
+ self.sync_stats["failed_syncs"] += 1
91
+ return {"ok": False, "error": str(exc)}
92
+
93
+ async def sync_audit(self, event: Dict[str, Any]) -> Dict[str, Any]:
94
+ """Sincronizza evento audit da B a D."""
95
+ try:
96
+ _logger.info(f"Sincronizzazione audit: {event.get('event_type')}")
97
+
98
+ # Sincronizza su D (append-only)
99
+ await self._sync_to_node_d("audit_event", event)
100
+
101
+ # Log anche su E se Γ¨ un evento critico
102
+ if ORACLE_NODE_E_ENABLED and event.get("severity") in ["critical", "error"]:
103
+ await self._sync_to_node_e("audit_log", event)
104
+
105
+ self.sync_stats["successful_syncs"] += 1
106
+ self.sync_stats["total_items_synced"] += 1
107
+
108
+ return {
109
+ "ok": True,
110
+ "event_id": event.get("id"),
111
+ "synced_to": ["D", "E"] if ORACLE_NODE_E_ENABLED else ["D"],
112
+ }
113
+ except Exception as exc:
114
+ _logger.error(f"Errore sincronizzazione audit: {exc}")
115
+ self.sync_stats["failed_syncs"] += 1
116
+ return {"ok": False, "error": str(exc)}
117
+
118
+ async def distribute_task_to_oracle(self, task: Dict[str, Any]) -> Dict[str, Any]:
119
+ """Distribuisce un task computazionale al Nodo E (Oracle)."""
120
+ if not ORACLE_NODE_E_ENABLED:
121
+ return {"ok": False, "error": "Oracle Node E disabilitato"}
122
+
123
+ try:
124
+ _logger.info(f"Distribuzione task a Nodo E: {task.get('id')}")
125
+
126
+ # Salva task in B (PRIMARY)
127
+ task_id = await self._save_task_to_node_b(task)
128
+
129
+ # Invia task a E
130
+ result = await self._send_task_to_node_e(task_id, task)
131
+
132
+ if result.get("ok"):
133
+ self.pending_tasks.append({
134
+ "task_id": task_id,
135
+ "node": "E",
136
+ "created_at": datetime.now().isoformat(),
137
+ "status": "pending",
138
+ })
139
+ self.sync_stats["successful_syncs"] += 1
140
+ else:
141
+ self.sync_stats["failed_syncs"] += 1
142
+
143
+ return result
144
+ except Exception as exc:
145
+ _logger.error(f"Errore distribuzione task: {exc}")
146
+ self.sync_stats["failed_syncs"] += 1
147
+ return {"ok": False, "error": str(exc)}
148
+
149
+ async def collect_task_results(self) -> Dict[str, Any]:
150
+ """Raccoglie i risultati dei task dal Nodo E."""
151
+ if not ORACLE_NODE_E_ENABLED:
152
+ return {"ok": False, "error": "Oracle Node E disabilitato"}
153
+
154
+ try:
155
+ _logger.info("Raccolta risultati task da Nodo E...")
156
+
157
+ results = []
158
+ for task_info in self.pending_tasks[:]:
159
+ if task_info["node"] == "E":
160
+ result = await self._fetch_task_result_from_node_e(task_info["task_id"])
161
+
162
+ if result.get("ok") and result.get("status") == "completed":
163
+ # Sincronizza risultato su B
164
+ await self._sync_result_to_node_b(task_info["task_id"], result)
165
+
166
+ results.append(result)
167
+ self.pending_tasks.remove(task_info)
168
+ self.sync_stats["successful_syncs"] += 1
169
+
170
+ return {
171
+ "ok": True,
172
+ "results_collected": len(results),
173
+ "pending_tasks": len(self.pending_tasks),
174
+ "results": results,
175
+ }
176
+ except Exception as exc:
177
+ _logger.error(f"Errore raccolta risultati: {exc}")
178
+ return {"ok": False, "error": str(exc)}
179
+
180
+ async def sync_all(self) -> Dict[str, Any]:
181
+ """Sincronizzazione completa tra tutti i nodi."""
182
+ try:
183
+ _logger.info("Sincronizzazione completa iniziata...")
184
+
185
+ start_time = datetime.now()
186
+
187
+ # 1. Sincronizza memoria (B ↔ C)
188
+ memory_sync = await self._sync_all_memory()
189
+
190
+ # 2. Sincronizza audit (B β†’ D)
191
+ audit_sync = await self._sync_all_audit()
192
+
193
+ # 3. Raccoglie risultati task da E
194
+ task_results = None
195
+ if ORACLE_NODE_E_ENABLED:
196
+ task_results = await self.collect_task_results()
197
+
198
+ elapsed = (datetime.now() - start_time).total_seconds()
199
+
200
+ self.sync_stats["total_syncs"] += 1
201
+ self.last_sync_time["all"] = datetime.now().isoformat()
202
+
203
+ return {
204
+ "ok": True,
205
+ "memory_synced": memory_sync.get("count", 0),
206
+ "audit_synced": audit_sync.get("count", 0),
207
+ "task_results_collected": task_results.get("results_collected", 0) if task_results else 0,
208
+ "elapsed_seconds": elapsed,
209
+ "stats": self.sync_stats,
210
+ }
211
+ except Exception as exc:
212
+ _logger.error(f"Errore sincronizzazione completa: {exc}")
213
+ self.sync_stats["failed_syncs"] += 1
214
+ return {"ok": False, "error": str(exc)}
215
+
216
+ # ── Metodi Privati ────────────────────────────────────────────────────
217
+
218
+ async def _fetch_from_node_b(self, key: str) -> Any:
219
+ """Recupera dato da Nodo B (PRIMARY)."""
220
+ # Implementazione: Query Supabase B
221
+ _logger.debug(f"Fetch da B: {key}")
222
+ return {} # Placeholder
223
+
224
+ async def _sync_to_node_c(self, key: str, data: Any) -> bool:
225
+ """Sincronizza dato a Nodo C (Memory/RAG)."""
226
+ # Implementazione: Insert/Update Supabase C
227
+ _logger.debug(f"Sync a C: {key}")
228
+ return True # Placeholder
229
+
230
+ async def _sync_to_node_d(self, key: str, data: Any) -> bool:
231
+ """Sincronizza evento audit a Nodo D (Audit)."""
232
+ # Implementazione: Append-only insert Supabase D
233
+ _logger.debug(f"Sync a D: {key}")
234
+ return True # Placeholder
235
+
236
+ async def _sync_to_node_e(self, key: str, data: Any) -> bool:
237
+ """Sincronizza dato a Nodo E (Oracle Compute)."""
238
+ if not ORACLE_NODE_E_ENABLED:
239
+ return False
240
+
241
+ try:
242
+ import httpx
243
+
244
+ async with httpx.AsyncClient() as client:
245
+ response = await client.post(
246
+ f"http://{ORACLE_NODE_E_IP}:{ORACLE_NODE_E_PORT}/api/sync/receive",
247
+ json={"key": key, "data": data},
248
+ timeout=SYNC_TIMEOUT_SECONDS,
249
+ )
250
+ return response.status_code == 200
251
+ except Exception as exc:
252
+ _logger.error(f"Errore sync a E: {exc}")
253
+ return False
254
+
255
+ async def _save_task_to_node_b(self, task: Dict[str, Any]) -> str:
256
+ """Salva task in B."""
257
+ # Implementazione: Insert Supabase B
258
+ _logger.debug(f"Save task a B: {task.get('id')}")
259
+ return task.get("id", "task_unknown") # Placeholder
260
+
261
+ async def _send_task_to_node_e(self, task_id: str, task: Dict[str, Any]) -> Dict[str, Any]:
262
+ """Invia task a Nodo E."""
263
+ if not ORACLE_NODE_E_ENABLED:
264
+ return {"ok": False, "error": "Oracle Node E disabilitato"}
265
+
266
+ try:
267
+ import httpx
268
+
269
+ async with httpx.AsyncClient() as client:
270
+ response = await client.post(
271
+ f"http://{ORACLE_NODE_E_IP}:{ORACLE_NODE_E_PORT}/api/tasks/submit",
272
+ json={"task_id": task_id, "task": task},
273
+ timeout=SYNC_TIMEOUT_SECONDS,
274
+ )
275
+ return response.json() if response.status_code == 200 else {"ok": False}
276
+ except Exception as exc:
277
+ _logger.error(f"Errore invio task a E: {exc}")
278
+ return {"ok": False, "error": str(exc)}
279
+
280
+ async def _fetch_task_result_from_node_e(self, task_id: str) -> Dict[str, Any]:
281
+ """Recupera risultato task da Nodo E."""
282
+ if not ORACLE_NODE_E_ENABLED:
283
+ return {"ok": False}
284
+
285
+ try:
286
+ import httpx
287
+
288
+ async with httpx.AsyncClient() as client:
289
+ response = await client.get(
290
+ f"http://{ORACLE_NODE_E_IP}:{ORACLE_NODE_E_PORT}/api/tasks/{task_id}/result",
291
+ timeout=SYNC_TIMEOUT_SECONDS,
292
+ )
293
+ return response.json() if response.status_code == 200 else {"ok": False}
294
+ except Exception as exc:
295
+ _logger.error(f"Errore fetch risultato da E: {exc}")
296
+ return {"ok": False}
297
+
298
+ async def _sync_result_to_node_b(self, task_id: str, result: Dict[str, Any]) -> bool:
299
+ """Sincronizza risultato task a B."""
300
+ # Implementazione: Update Supabase B
301
+ _logger.debug(f"Sync risultato a B: {task_id}")
302
+ return True # Placeholder
303
+
304
+ async def _sync_all_memory(self) -> Dict[str, Any]:
305
+ """Sincronizza tutta la memoria."""
306
+ _logger.debug("Sincronizzazione memoria completa...")
307
+ return {"count": 0} # Placeholder
308
+
309
+ async def _sync_all_audit(self) -> Dict[str, Any]:
310
+ """Sincronizza tutti gli audit."""
311
+ _logger.debug("Sincronizzazione audit completa...")
312
+ return {"count": 0} # Placeholder
313
+
314
+
315
+ # ── Istanza Globale ───────────────────────────────────────────────────────
316
+ _sync_manager = GlobalStateSyncWithOracle()
317
+
318
+
319
+ # ── Scheduler Sincronizzazione ────────────────────────────────────────────
320
+ async def start_sync_scheduler():
321
+ """Avvia lo scheduler di sincronizzazione periodica."""
322
+ _logger.info(f"Avvio scheduler sincronizzazione (intervallo: {SYNC_INTERVAL_SECONDS}s)...")
323
+
324
+ while True:
325
+ try:
326
+ await asyncio.sleep(SYNC_INTERVAL_SECONDS)
327
+ result = await _sync_manager.sync_all()
328
+
329
+ if result.get("ok"):
330
+ _logger.info(f"Sincronizzazione completata: {result}")
331
+ else:
332
+ _logger.warning(f"Sincronizzazione parziale: {result}")
333
+ except Exception as exc:
334
+ _logger.error(f"Errore scheduler: {exc}")
335
+
336
+
337
+ # ── Funzioni Pubbliche ────────────────────────────────────────────────────
338
+ async def sync_memory(session_id: str) -> Dict[str, Any]:
339
+ """Sincronizza memoria."""
340
+ return await _sync_manager.sync_memory(session_id)
341
+
342
+
343
+ async def sync_audit(event: Dict[str, Any]) -> Dict[str, Any]:
344
+ """Sincronizza audit."""
345
+ return await _sync_manager.sync_audit(event)
346
+
347
+
348
+ async def distribute_task(task: Dict[str, Any]) -> Dict[str, Any]:
349
+ """Distribuisce task a Nodo E."""
350
+ return await _sync_manager.distribute_task_to_oracle(task)
351
+
352
+
353
+ async def get_sync_stats() -> Dict[str, Any]:
354
+ """Ritorna statistiche sincronizzazione."""
355
+ return _sync_manager.sync_stats
api/job_queue.py CHANGED
@@ -4,21 +4,28 @@ import time
4
  import asyncio
5
  import logging
6
  import uuid
 
 
 
 
 
7
  from typing import Optional, List, Dict, Any
8
  from fastapi import APIRouter, HTTPException, Request
9
  from pydantic import BaseModel
 
10
  import httpx
11
 
12
  _logger = logging.getLogger("agente_ai.jq")
13
  router = APIRouter(prefix="/jq", tags=["job_queue"])
14
 
15
  # ── Configurazione ────────────────────────────────────────────────────────────
16
- # S42: Grid Orchestrator β€” Cluster 4x4 (A, B, C, D)
17
- # SPACE_ROLE: brain | hands | memory | audit
18
- _SPACE_ROLE = os.getenv("SPACE_ROLE", "unknown")
19
- _JQ_ENABLED = os.getenv("JQ_ENABLED", "0") == "1"
20
  _INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
21
 
 
 
 
22
  # Redis keys (Upstash)
23
  _K_PENDING = "jq:pending"
24
  _K_WAKE = "jq:wake"
@@ -65,14 +72,23 @@ async def _lrange(key: str, start: int, end: int = -1) -> List[str]:
65
 
66
  # ── Core Logic ────────────────────────────────────────────────────────────────
67
  async def publish_load_metrics():
68
- """Pubblica il carico corrente su Redis per il bilanciamento."""
 
 
69
  if not _redis_ok(): return
 
 
 
 
 
 
 
70
  data = {
71
- "role": _SPACE_ROLE,
72
- "ts": int(time.time() * 1000),
73
- "active_tasks": len(asyncio.all_tasks()) - 5, # Stima approssimativa
74
- "cpu": 0, # Placeholder per metrics reali
75
- "mem": 0
76
  }
77
  await _rcmd(["SET", _K_LOAD(_SPACE_ROLE), json.dumps(data), "EX", "60"])
78
 
@@ -87,6 +103,7 @@ async def _load_publisher_loop():
87
 
88
  async def _hands_consumer_loop():
89
  """Loop consumer reale per nodi distribuiti (S42)."""
 
90
  _logger.info("[jq] consumer loop avviato per ruolo: %s", _SPACE_ROLE)
91
  _consecutive_errors = 0
92
  _MAX_CONSECUTIVE_ERRORS = 3 # INV-R1: dopo 3 errori infra β†’ pausa 30s
@@ -96,21 +113,25 @@ async def _hands_consumer_loop():
96
  # 1. Heartbeat consumer
97
  await _rcmd(["SET", _K_CONSUMER, "1", "EX", "15"])
98
 
99
- # 2. Prelievo job (RPOP per coda FIFO)
100
  res = await _rcmd(["RPOP", _K_PENDING])
101
  if res and res.get("result"):
102
  job_raw = res["result"]
103
  try:
104
  job_data = json.loads(job_raw)
105
- except json.JSONDecodeError as _jd_err:
106
- _logger.error("[jq] JSON malformato in coda: %s | raw: %.120s", _jd_err, job_raw)
107
- continue
 
 
 
 
 
108
  task_id = job_data.get("taskId", str(uuid.uuid4()))
109
  goal = job_data.get("goal", "")
110
  context_raw = job_data.get("context", {})
111
  _logger.info("[jq] job ricevuto: %s | goal: %.80s", task_id, goal)
112
 
113
- # Normalizza context β†’ stringa
114
  if isinstance(context_raw, dict):
115
  context_str = "\n".join(f"{k}: {v}" for k, v in context_raw.items() if v)
116
  elif isinstance(context_raw, str):
@@ -118,7 +139,6 @@ async def _hands_consumer_loop():
118
  else:
119
  context_str = ""
120
 
121
- # S429 β€” Esecuzione REALE via UnifiedAgentLoop
122
  result_payload: Dict[str, Any] = {}
123
  try:
124
  from agents.unified_loop import UnifiedAgentLoop
@@ -136,7 +156,6 @@ async def _hands_consumer_loop():
136
  _critic = None
137
  _verifier = None
138
 
139
- # Accumula step per tracciabilitΓ  nel risultato Redis
140
  _steps: list = []
141
 
142
  async def _on_step(step: dict) -> None:
@@ -154,17 +173,21 @@ async def _hands_consumer_loop():
154
  planner=_get_planner(),
155
  )
156
 
157
- # max_steps: clampato 5–16, scalato per prioritΓ  job
158
  _priority = int(job_data.get("priority", 1))
159
  _max_steps = max(5, min(4 + _priority * 2, 16))
160
 
161
- raw_result = await loop_inst.run(
162
- goal=goal,
163
- context=context_str,
164
- max_steps=_max_steps,
165
- on_step=_on_step,
166
- session_id=task_id,
167
- )
 
 
 
 
 
168
 
169
  output = raw_result.get("output", "") if isinstance(raw_result, dict) else str(raw_result)
170
  success = bool(raw_result.get("success", False)) if isinstance(raw_result, dict) else bool(output)
@@ -184,7 +207,6 @@ async def _hands_consumer_loop():
184
  _logger.info("[jq] job completato: %s | engine: %s | success: %s", task_id, engine, success)
185
 
186
  except (ImportError, ModuleNotFoundError) as imp_err:
187
- # UnifiedAgentLoop non disponibile su questo nodo
188
  _logger.warning("[jq] agents.unified_loop non disponibile su %s: %s", _SPACE_ROLE, imp_err)
189
  result_payload = {
190
  "taskId": task_id,
@@ -204,7 +226,6 @@ async def _hands_consumer_loop():
204
  "error": str(exec_err)[:500],
205
  "ts": int(time.time() * 1000),
206
  }
207
- # INV-R1: 3 errori consecutivi infra β†’ pausa 30s, log esplicito
208
  if _consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
209
  _logger.error(
210
  "[jq] INV-R1: %d errori consecutivi β€” pausa 30s (nodo: %s)",
@@ -217,7 +238,6 @@ async def _hands_consumer_loop():
217
  _logger.info("[jq] risultato Redis: %s β†’ %s", task_id, result_payload.get("status"))
218
 
219
  else:
220
- # Coda vuota β†’ reset errori consecutivi, polling leggero via REST
221
  _consecutive_errors = 0
222
  await asyncio.sleep(2)
223
 
@@ -231,7 +251,6 @@ async def start_job_queue_consumer() -> None:
231
  _logger.warning("[jq] Redis non configurato β€” job queue disabilitato")
232
  return
233
  _bg_tasks.append(asyncio.create_task(_load_publisher_loop()))
234
- # Grid Orchestrator: Consumer specializzati (HANDS, MEMORY, AUDIT)
235
  if _SPACE_ROLE in ("hands", "memory", "audit", "unknown"):
236
  _bg_tasks.append(asyncio.create_task(_hands_consumer_loop()))
237
  else:
@@ -240,13 +259,13 @@ async def start_job_queue_consumer() -> None:
240
  # ── FastAPI endpoints ──────────────────────────────────────────────────────────
241
  @router.get("/status")
242
  async def jq_status():
243
- result = {
244
  "space_role": _SPACE_ROLE,
245
  "jq_enabled": _JQ_ENABLED,
246
  "redis_configured": _redis_ok(),
 
247
  "ts": int(time.time() * 1000),
248
  }
249
- return result
250
 
251
  @router.get("/load/{role}")
252
  async def jq_load(role: str):
@@ -262,56 +281,46 @@ async def jq_load(role: str):
262
 
263
  @router.post("/submit")
264
  async def jq_submit(job: JobPayload, request: Request):
265
- """
266
- S42 β€” Grid Orchestrator: sottomissione job reale su Redis.
267
- Inserisce il job in coda LPUSH (FIFO con RPOP dal consumer).
268
- Restituisce taskId e lunghezza coda corrente.
269
- """
270
  if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
271
  raise HTTPException(401, "Unauthorized")
272
-
273
  if not _redis_ok():
274
  raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
275
 
276
- # Normalizza taskId: usa quello fornito o genera uno nuovo
277
  task_id = job.taskId if job.taskId else str(uuid.uuid4())
278
 
 
 
 
 
279
  job_payload = {
280
- "taskId": task_id,
281
- "goal": job.goal,
282
- "context": job.context or {},
283
- "priority": job.priority,
284
- "submittedAt": int(time.time() * 1000),
285
- "submittedBy": _SPACE_ROLE,
 
286
  }
287
-
288
- # LPUSH β†’ consumer usa RPOP (FIFO)
289
  res = await _rcmd(["LPUSH", _K_PENDING, json.dumps(job_payload)])
290
  if res is None:
291
  raise HTTPException(503, "Errore Redis durante la sottomissione del job")
292
 
293
  queue_len = int(res.get("result", 0)) if res else 0
294
-
295
- # Segnale di wake per consumer (TTL breve)
296
  await _rcmd(["SET", _K_WAKE, "1", "EX", "10"])
297
-
298
  _logger.info("[jq] job sottomesso: %s (coda: %d)", task_id, queue_len)
299
  return {
300
- "taskId": task_id,
301
- "status": "queued",
302
  "queueLength": queue_len,
303
- "ts": int(time.time() * 1000),
304
  }
305
 
306
  @router.get("/result/{task_id}")
307
  async def jq_result(task_id: str, request: Request):
308
- """
309
- S429 β€” Tool Success Contract: recupero risultato job per taskId.
310
- Restituisce il risultato se disponibile, altrimenti pending.
311
- """
312
  if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
313
  raise HTTPException(401, "Unauthorized")
314
-
315
  if not _redis_ok():
316
  raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
317
 
@@ -323,3 +332,6 @@ async def jq_result(task_id: str, request: Request):
323
  except json.JSONDecodeError as _je:
324
  _logger.error("[jq] risultato Redis corrotto task=%s: %s", task_id, _je)
325
  raise HTTPException(500, f"Risultato corrotto in Redis per {task_id}: {_je}")
 
 
 
 
4
  import asyncio
5
  import logging
6
  import uuid
7
+ try:
8
+ import resource as _resource
9
+ _HAS_RESOURCE = True
10
+ except ImportError:
11
+ _HAS_RESOURCE = False
12
  from typing import Optional, List, Dict, Any
13
  from fastapi import APIRouter, HTTPException, Request
14
  from pydantic import BaseModel
15
+ from .load_balancer import balancer # S951
16
  import httpx
17
 
18
  _logger = logging.getLogger("agente_ai.jq")
19
  router = APIRouter(prefix="/jq", tags=["job_queue"])
20
 
21
  # ── Configurazione ────────────────────────────────────────────────────────────
22
+ _SPACE_ROLE = os.getenv("SPACE_ROLE", "unknown")
23
+ _JQ_ENABLED = os.getenv("JQ_ENABLED", "0") == "1"
 
 
24
  _INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
25
 
26
+ # C2-FIX: contatore reale di job attivi β€” incrementato/decrementato intorno a loop_inst.run()
27
+ _active_job_count: int = 0
28
+
29
  # Redis keys (Upstash)
30
  _K_PENDING = "jq:pending"
31
  _K_WAKE = "jq:wake"
 
72
 
73
  # ── Core Logic ────────────────────────────────────────────────────────────────
74
  async def publish_load_metrics():
75
+ """Pubblica il carico corrente su Redis per il bilanciamento (S951).
76
+ C2-FIX: usa _active_job_count (contatore reale) invece di asyncio.all_tasks()-5.
77
+ """
78
  if not _redis_ok(): return
79
+ # Memoria processo in MB (Linux: ru_maxrss Γ¨ in kB)
80
+ mem_mb = 0
81
+ if _HAS_RESOURCE:
82
+ try:
83
+ mem_mb = _resource.getrusage(_resource.RUSAGE_SELF).ru_maxrss // 1024
84
+ except Exception:
85
+ mem_mb = 0
86
  data = {
87
+ "role": _SPACE_ROLE,
88
+ "ts": int(time.time() * 1000),
89
+ "active_tasks": max(0, _active_job_count), # mai negativo
90
+ "cpu": 0, # placeholder β€” psutil non installato
91
+ "mem": mem_mb,
92
  }
93
  await _rcmd(["SET", _K_LOAD(_SPACE_ROLE), json.dumps(data), "EX", "60"])
94
 
 
103
 
104
  async def _hands_consumer_loop():
105
  """Loop consumer reale per nodi distribuiti (S42)."""
106
+ global _active_job_count
107
  _logger.info("[jq] consumer loop avviato per ruolo: %s", _SPACE_ROLE)
108
  _consecutive_errors = 0
109
  _MAX_CONSECUTIVE_ERRORS = 3 # INV-R1: dopo 3 errori infra β†’ pausa 30s
 
113
  # 1. Heartbeat consumer
114
  await _rcmd(["SET", _K_CONSUMER, "1", "EX", "15"])
115
 
116
+ # 2. Prelievo job
117
  res = await _rcmd(["RPOP", _K_PENDING])
118
  if res and res.get("result"):
119
  job_raw = res["result"]
120
  try:
121
  job_data = json.loads(job_raw)
122
+ # S951: Check assignment
123
+ assigned_to = job_data.get("assignedTo", "hands")
124
+ if assigned_to != _SPACE_ROLE and _SPACE_ROLE != "brain":
125
+ await _rcmd(["LPUSH", _K_PENDING, job_raw])
126
+ await asyncio.sleep(1)
127
+ continue
128
+ except Exception:
129
+ pass
130
  task_id = job_data.get("taskId", str(uuid.uuid4()))
131
  goal = job_data.get("goal", "")
132
  context_raw = job_data.get("context", {})
133
  _logger.info("[jq] job ricevuto: %s | goal: %.80s", task_id, goal)
134
 
 
135
  if isinstance(context_raw, dict):
136
  context_str = "\n".join(f"{k}: {v}" for k, v in context_raw.items() if v)
137
  elif isinstance(context_raw, str):
 
139
  else:
140
  context_str = ""
141
 
 
142
  result_payload: Dict[str, Any] = {}
143
  try:
144
  from agents.unified_loop import UnifiedAgentLoop
 
156
  _critic = None
157
  _verifier = None
158
 
 
159
  _steps: list = []
160
 
161
  async def _on_step(step: dict) -> None:
 
173
  planner=_get_planner(),
174
  )
175
 
 
176
  _priority = int(job_data.get("priority", 1))
177
  _max_steps = max(5, min(4 + _priority * 2, 16))
178
 
179
+ # C2-FIX: incrementa contatore PRIMA di run(), decrementa in finally
180
+ _active_job_count += 1
181
+ try:
182
+ raw_result = await loop_inst.run(
183
+ goal=goal,
184
+ context=context_str,
185
+ max_steps=_max_steps,
186
+ on_step=_on_step,
187
+ session_id=task_id,
188
+ )
189
+ finally:
190
+ _active_job_count = max(0, _active_job_count - 1)
191
 
192
  output = raw_result.get("output", "") if isinstance(raw_result, dict) else str(raw_result)
193
  success = bool(raw_result.get("success", False)) if isinstance(raw_result, dict) else bool(output)
 
207
  _logger.info("[jq] job completato: %s | engine: %s | success: %s", task_id, engine, success)
208
 
209
  except (ImportError, ModuleNotFoundError) as imp_err:
 
210
  _logger.warning("[jq] agents.unified_loop non disponibile su %s: %s", _SPACE_ROLE, imp_err)
211
  result_payload = {
212
  "taskId": task_id,
 
226
  "error": str(exec_err)[:500],
227
  "ts": int(time.time() * 1000),
228
  }
 
229
  if _consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
230
  _logger.error(
231
  "[jq] INV-R1: %d errori consecutivi β€” pausa 30s (nodo: %s)",
 
238
  _logger.info("[jq] risultato Redis: %s β†’ %s", task_id, result_payload.get("status"))
239
 
240
  else:
 
241
  _consecutive_errors = 0
242
  await asyncio.sleep(2)
243
 
 
251
  _logger.warning("[jq] Redis non configurato β€” job queue disabilitato")
252
  return
253
  _bg_tasks.append(asyncio.create_task(_load_publisher_loop()))
 
254
  if _SPACE_ROLE in ("hands", "memory", "audit", "unknown"):
255
  _bg_tasks.append(asyncio.create_task(_hands_consumer_loop()))
256
  else:
 
259
  # ── FastAPI endpoints ──────────────────────────────────────────────────────────
260
  @router.get("/status")
261
  async def jq_status():
262
+ return {
263
  "space_role": _SPACE_ROLE,
264
  "jq_enabled": _JQ_ENABLED,
265
  "redis_configured": _redis_ok(),
266
+ "active_tasks": max(0, _active_job_count),
267
  "ts": int(time.time() * 1000),
268
  }
 
269
 
270
  @router.get("/load/{role}")
271
  async def jq_load(role: str):
 
281
 
282
  @router.post("/submit")
283
  async def jq_submit(job: JobPayload, request: Request):
284
+ """S42 β€” Grid Orchestrator: sottomissione job reale su Redis."""
 
 
 
 
285
  if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
286
  raise HTTPException(401, "Unauthorized")
 
287
  if not _redis_ok():
288
  raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
289
 
 
290
  task_id = job.taskId if job.taskId else str(uuid.uuid4())
291
 
292
+ # S951: Dynamic Load Balancing
293
+ target_role = job.context.get('preferred_role', 'hands') if job.context else 'hands'
294
+ assigned_role = await balancer.get_best_node(target_role)
295
+
296
  job_payload = {
297
+ 'taskId': task_id,
298
+ 'goal': job.goal,
299
+ 'context': job.context or {},
300
+ 'priority': job.priority,
301
+ 'submittedAt': int(time.time() * 1000),
302
+ 'submittedBy': _SPACE_ROLE,
303
+ 'assignedTo': assigned_role,
304
  }
 
 
305
  res = await _rcmd(["LPUSH", _K_PENDING, json.dumps(job_payload)])
306
  if res is None:
307
  raise HTTPException(503, "Errore Redis durante la sottomissione del job")
308
 
309
  queue_len = int(res.get("result", 0)) if res else 0
 
 
310
  await _rcmd(["SET", _K_WAKE, "1", "EX", "10"])
 
311
  _logger.info("[jq] job sottomesso: %s (coda: %d)", task_id, queue_len)
312
  return {
313
+ "taskId": task_id,
314
+ "status": "queued",
315
  "queueLength": queue_len,
316
+ "ts": int(time.time() * 1000),
317
  }
318
 
319
  @router.get("/result/{task_id}")
320
  async def jq_result(task_id: str, request: Request):
321
+ """S429 β€” Tool Success Contract: recupero risultato job per taskId."""
 
 
 
322
  if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
323
  raise HTTPException(401, "Unauthorized")
 
324
  if not _redis_ok():
325
  raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
326
 
 
332
  except json.JSONDecodeError as _je:
333
  _logger.error("[jq] risultato Redis corrotto task=%s: %s", task_id, _je)
334
  raise HTTPException(500, f"Risultato corrotto in Redis per {task_id}: {_je}")
335
+
336
+ # Lista dei background tasks (popolata da start_job_queue_consumer)
337
+ _bg_tasks: List[asyncio.Task] = []
api/load_balancer.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/load_balancer.py β€” Dynamic Load Balancer (S951)
3
+ Analizza il carico dei nodi (A, B, C, D) e decide a chi assegnare il task.
4
+ Supporta il Cross-Node Offloading: se il nodo target Γ¨ saturo, delega al nodo piΓΉ scarico.
5
+ M1-FIX: timeout Redis configurabile via env REDIS_TIMEOUT_S (default 5.0s, allineato a job_queue e state_sync).
6
+ """
7
+ import json
8
+ import logging
9
+ import os
10
+ import time
11
+ from typing import Optional, Dict, Any, List
12
+ import httpx
13
+
14
+ _logger = logging.getLogger("agente_ai.balancer")
15
+
16
+ # M1-FIX: timeout Redis allineato agli altri moduli (job_queue: 5.0s, state_sync: 5.0s)
17
+ _REDIS_TIMEOUT = float(os.getenv("REDIS_TIMEOUT_S", "5.0"))
18
+
19
+ # Ruoli del cluster
20
+ ROLES = ["brain", "hands", "memory", "audit"]
21
+
22
+ class LoadBalancer:
23
+ def __init__(self):
24
+ self.redis_url = os.getenv("UPSTASH_REDIS_REST_URL")
25
+ self.redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN")
26
+ self.internal_token = os.getenv("INTERNAL_TOKEN", "")
27
+
28
+ async def _rcmd(self, cmd: List[Any]) -> Optional[Dict[str, Any]]:
29
+ if not self.redis_url or not self.redis_token: return None
30
+ try:
31
+ async with httpx.AsyncClient() as client:
32
+ r = await client.post(
33
+ self.redis_url,
34
+ headers={"Authorization": f"Bearer {self.redis_token}"},
35
+ json=cmd,
36
+ timeout=_REDIS_TIMEOUT, # M1-FIX: era hardcodato 2.0
37
+ )
38
+ return r.json()
39
+ except Exception as e:
40
+ _logger.error("[S951] Redis error: %s", e)
41
+ return None
42
+
43
+ async def get_node_load(self, role: str) -> Optional[Dict[str, Any]]:
44
+ """Recupera le metriche di carico per un nodo specifico."""
45
+ res = await self._rcmd(["GET", f"jq:load:{role}"])
46
+ if res and res.get("result"):
47
+ try:
48
+ return json.loads(res["result"])
49
+ except (json.JSONDecodeError, TypeError):
50
+ return None
51
+ return None
52
+
53
+ async def get_best_node(self, preferred_role: str = "hands") -> str:
54
+ """
55
+ Determina il miglior nodo per un task.
56
+ Se il preferred_role Γ¨ sotto soglia di carico, lo mantiene.
57
+ Altrimenti cerca il nodo con meno active_tasks.
58
+ """
59
+ pref_load = await self.get_node_load(preferred_role)
60
+ if pref_load:
61
+ active = pref_load.get("active_tasks", 0)
62
+ if active < 3:
63
+ return preferred_role
64
+
65
+ _logger.info("[S951] Nodo %s saturo o offline. Ricerca miglior nodo alternativo...", preferred_role)
66
+
67
+ best_role = preferred_role
68
+ min_load = 999
69
+
70
+ for role in ROLES:
71
+ load = await self.get_node_load(role)
72
+ if load:
73
+ active = load.get("active_tasks", 0)
74
+ ts = load.get("ts", 0)
75
+ if (time.time() * 1000) - ts < 90000:
76
+ if active < min_load:
77
+ min_load = active
78
+ best_role = role
79
+
80
+ if best_role != preferred_role:
81
+ _logger.info("[S951] Offloading dinamico: %s -> %s (carico: %d)", preferred_role, best_role, min_load)
82
+
83
+ return best_role
84
+
85
+ async def get_cluster_status(self) -> Dict[str, Any]:
86
+ """Ritorna lo stato di salute di tutto il cluster."""
87
+ status = {}
88
+ for role in ROLES:
89
+ load = await self.get_node_load(role)
90
+ if load:
91
+ ts = load.get("ts", 0)
92
+ is_online = (time.time() * 1000) - ts < 90000
93
+ status[role] = {
94
+ "online": is_online,
95
+ "active_tasks": load.get("active_tasks", 0),
96
+ "mem_mb": load.get("mem", 0),
97
+ "last_seen": ts,
98
+ }
99
+ else:
100
+ status[role] = {"online": False}
101
+ return status
102
+
103
+ # Singleton instance
104
+ balancer = LoadBalancer()
api/oci_gateway_manager.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ from typing import Dict, Any, Optional
4
+ import httpx
5
+
6
+ # Configurazione OCI API Gateway
7
+ OCI_GATEWAY_ID = "ocid1.apigatewayapi.oc1.eu-milan-1.amaaaaaavx6czpaabfdwezue3fo7jpfsrxqwlo2nt7pwhs7o2ezvqztuaffa"
8
+ ORACLE_NODE_E_IP = os.getenv("ORACLE_NODE_E_IP", "80.225.89.217")
9
+ ORACLE_NODE_E_PORT = int(os.getenv("ORACLE_NODE_E_PORT", "8080"))
10
+ INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
11
+
12
+ logger = logging.getLogger("agente_ai.oci_gateway")
13
+
14
+ class OCIGatewayManager:
15
+ """
16
+ Gestore per Oracle Cloud API Gateway.
17
+ Funge da ponte tra il cluster AI e il Nodo E (Milano) per bypassare
18
+ il Network Firewall (Palo Alto) tramite endpoint HTTPS gestiti.
19
+ """
20
+
21
+ def __init__(self):
22
+ self.gateway_id = OCI_GATEWAY_ID
23
+ self.base_url = f"https://{self.gateway_id}.apigateway.eu-milan-1.oci.customer-oci.com"
24
+ self.token = INTERNAL_TOKEN
25
+
26
+ async def get_gateway_status(self) -> Dict[str, Any]:
27
+ """Verifica lo stato del gateway e la connettivitΓ  verso il Nodo E."""
28
+ try:
29
+ async with httpx.AsyncClient() as client:
30
+ # Nota: L'URL reale dipenderΓ  dal deployment specifico su OCI
31
+ # Questo Γ¨ un placeholder per la logica di health check via gateway
32
+ response = await client.get(
33
+ f"{self.base_url}/health",
34
+ headers={"Authorization": f"Bearer {self.token}"},
35
+ timeout=10.0
36
+ )
37
+ return {
38
+ "ok": response.status_code == 200,
39
+ "gateway_id": self.gateway_id,
40
+ "status_code": response.status_code,
41
+ "provider": "Oracle Cloud API Gateway"
42
+ }
43
+ except Exception as e:
44
+ logger.error(f"Errore health check gateway: {e}")
45
+ return {"ok": False, "error": str(e)}
46
+
47
+ async def proxy_to_node_e(self, path: str, method: str = "GET", body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
48
+ """Invia una richiesta al Nodo E passando attraverso l'API Gateway."""
49
+ url = f"{self.base_url}{path}"
50
+ try:
51
+ async with httpx.AsyncClient() as client:
52
+ if method == "POST":
53
+ response = await client.post(url, json=body, headers={"Authorization": f"Bearer {self.token}"})
54
+ else:
55
+ response = await client.get(url, headers={"Authorization": f"Bearer {self.token}"})
56
+
57
+ return response.json() if response.status_code == 200 else {"ok": False, "status": response.status_code}
58
+ except Exception as e:
59
+ logger.error(f"Errore proxy gateway: {e}")
60
+ return {"ok": False, "error": str(e)}
61
+
62
+ # Istanza globale
63
+ oci_gateway = OCIGatewayManager()
api/oracle_endpoints.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ oracle_endpoints.py β€” Endpoint API per Gestione Nodo E (Oracle Cloud)
3
+
4
+ Endpoint:
5
+ GET /api/oracle/health β€” Health check Nodo E
6
+ GET /api/oracle/metrics β€” Metriche Nodo E
7
+ POST /api/oracle/sync β€” Sincronizzazione cluster
8
+ POST /api/oracle/restart β€” Riavvio servizi
9
+ GET /api/oracle/logs β€” Log backend remoto
10
+ """
11
+
12
+ from fastapi import APIRouter, HTTPException
13
+ from pydantic import BaseModel
14
+ from typing import Optional
15
+ import logging
16
+
17
+ # Import oracle manager
18
+ try:
19
+ from backend.api.oracle_node_manager import (
20
+ initialize_oracle_node,
21
+ get_oracle_health,
22
+ sync_oracle_cluster,
23
+ get_oracle_metrics,
24
+ restart_oracle_services,
25
+ ORACLE_NODE_E_ENABLED,
26
+ ORACLE_NODE_E_IP,
27
+ )
28
+ except ImportError:
29
+ from oracle_node_manager import (
30
+ initialize_oracle_node,
31
+ get_oracle_health,
32
+ sync_oracle_cluster,
33
+ get_oracle_metrics,
34
+ restart_oracle_services,
35
+ ORACLE_NODE_E_ENABLED,
36
+ ORACLE_NODE_E_IP,
37
+ )
38
+
39
+ _logger = logging.getLogger("oracle_endpoints")
40
+
41
+ router = APIRouter(prefix="/api/oracle", tags=["oracle"])
42
+
43
+
44
+ # ── Modelli Pydantic ──────────────────────────────────────────────────────
45
+ class OracleHealthResponse(BaseModel):
46
+ ok: bool
47
+ status: str
48
+ metrics: Optional[dict] = None
49
+ error: Optional[str] = None
50
+
51
+
52
+ class OracleSyncResponse(BaseModel):
53
+ ok: bool
54
+ message: Optional[str] = None
55
+ error: Optional[str] = None
56
+
57
+
58
+ # ── Endpoint: Health Check ────────────────────────────────────────────────
59
+ @router.get("/health", response_model=OracleHealthResponse)
60
+ async def oracle_health():
61
+ """Ritorna lo stato di salute del Nodo E."""
62
+ if not ORACLE_NODE_E_ENABLED:
63
+ return OracleHealthResponse(
64
+ ok=False,
65
+ status="disabled",
66
+ error="Oracle Node E Γ¨ disabilitato",
67
+ )
68
+
69
+ try:
70
+ health = await get_oracle_health()
71
+ return OracleHealthResponse(**health)
72
+ except Exception as exc:
73
+ _logger.error(f"Error getting oracle health: {exc}")
74
+ raise HTTPException(500, "Error getting oracle health")
75
+
76
+
77
+ # ── Endpoint: Metriche ────────────────────────────────────────────────────
78
+ @router.get("/metrics")
79
+ async def oracle_metrics():
80
+ """Ritorna le metriche del Nodo E."""
81
+ if not ORACLE_NODE_E_ENABLED:
82
+ return {
83
+ "ok": False,
84
+ "error": "Oracle Node E Γ¨ disabilitato",
85
+ }
86
+
87
+ try:
88
+ metrics = await get_oracle_metrics()
89
+ return {
90
+ "ok": True,
91
+ "data": metrics,
92
+ }
93
+ except Exception as exc:
94
+ _logger.error(f"Error getting oracle metrics: {exc}")
95
+ raise HTTPException(500, "Error getting oracle metrics")
96
+
97
+
98
+ # ── Endpoint: Sincronizzazione ───────────────────────────────────────────
99
+ @router.post("/sync", response_model=OracleSyncResponse)
100
+ async def oracle_sync():
101
+ """Sincronizza il Nodo E con il cluster principale."""
102
+ if not ORACLE_NODE_E_ENABLED:
103
+ return OracleSyncResponse(
104
+ ok=False,
105
+ error="Oracle Node E Γ¨ disabilitato",
106
+ )
107
+
108
+ try:
109
+ result = await sync_oracle_cluster()
110
+ return OracleSyncResponse(
111
+ ok=result.get("ok", False),
112
+ message=result.get("message", result.get("output", "")),
113
+ error=result.get("error"),
114
+ )
115
+ except Exception as exc:
116
+ _logger.error(f"Error syncing oracle cluster: {exc}")
117
+ raise HTTPException(500, "Error syncing oracle cluster")
118
+
119
+
120
+ # ── Endpoint: Riavvio Servizi ────────────────────────────────────────────
121
+ @router.post("/restart")
122
+ async def oracle_restart():
123
+ """Riavvia i servizi sul Nodo E."""
124
+ if not ORACLE_NODE_E_ENABLED:
125
+ return {
126
+ "ok": False,
127
+ "error": "Oracle Node E Γ¨ disabilitato",
128
+ }
129
+
130
+ try:
131
+ result = await restart_oracle_services()
132
+ return {
133
+ "ok": result.get("ok", False),
134
+ "message": result.get("message"),
135
+ "health": result.get("health"),
136
+ "error": result.get("error"),
137
+ }
138
+ except Exception as exc:
139
+ _logger.error(f"Error restarting oracle services: {exc}")
140
+ raise HTTPException(500, "Error restarting oracle services")
141
+
142
+
143
+ # ── Endpoint: Log ────────────────────────────────────────────────────────
144
+ @router.get("/logs")
145
+ async def oracle_logs(lines: int = 100):
146
+ """Ritorna i log del backend remoto."""
147
+ if not ORACLE_NODE_E_ENABLED:
148
+ return {
149
+ "ok": False,
150
+ "error": "Oracle Node E Γ¨ disabilitato",
151
+ }
152
+
153
+ try:
154
+ from backend.api.oracle_node_manager import _oracle_manager
155
+
156
+ logs = await _oracle_manager.get_logs(lines)
157
+ return {
158
+ "ok": True,
159
+ "logs": logs,
160
+ "lines": lines,
161
+ }
162
+ except Exception as exc:
163
+ _logger.error(f"Error getting oracle logs: {exc}")
164
+ raise HTTPException(500, "Error getting oracle logs")
165
+
166
+
167
+ # ── Endpoint: Info ────────────────────────────────────────────────────────
168
+ @router.get("/info")
169
+ async def oracle_info():
170
+ """Ritorna informazioni sul Nodo E."""
171
+ try:
172
+ from backend.api.oracle_node_manager import (
173
+ ORACLE_NODE_E_ROLE,
174
+ ORACLE_NODE_E_REGION,
175
+ ORACLE_NODE_E_PORT,
176
+ )
177
+
178
+ return {
179
+ "enabled": ORACLE_NODE_E_ENABLED,
180
+ "ip": ORACLE_NODE_E_IP,
181
+ "role": ORACLE_NODE_E_ROLE,
182
+ "region": ORACLE_NODE_E_REGION,
183
+ "port": ORACLE_NODE_E_PORT,
184
+ "node_id": "E",
185
+ "type": "Oracle Cloud (OCI)",
186
+ }
187
+ except Exception as exc:
188
+ _logger.error(f"Error getting oracle info: {exc}")
189
+ raise HTTPException(500, "Error getting oracle info")
api/oracle_node_manager.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ oracle_node_manager.py β€” Gestore Nodo E (Oracle Cloud)
3
+
4
+ FunzionalitΓ :
5
+ - Monitoraggio stato del server Oracle
6
+ - Sincronizzazione automatica con cluster principale
7
+ - Gestione task distribuiti
8
+ - Health check e failover
9
+ - Logging e metriche
10
+
11
+ Utilizzo:
12
+ from backend.api.oracle_node_manager import OracleNodeManager
13
+ manager = OracleNodeManager()
14
+ await manager.sync_with_cluster()
15
+ """
16
+
17
+ import os
18
+ import asyncio
19
+ import logging
20
+ from .oci_gateway_manager import oci_gateway
21
+ import json
22
+ from typing import Optional, Dict, List, Any
23
+ from datetime import datetime, timedelta
24
+ from enum import Enum
25
+ import httpx
26
+ import paramiko
27
+
28
+ _logger = logging.getLogger("oracle_node_manager")
29
+
30
+ # ── Configurazione Oracle Node E ───────────────────────────────────────────
31
+ ORACLE_NODE_E_ENABLED = os.getenv("ORACLE_NODE_E_ENABLED", "true").lower() == "true"
32
+ ORACLE_NODE_E_IP = os.getenv("ORACLE_NODE_E_IP", "80.225.89.217")
33
+ ORACLE_NODE_E_USER = os.getenv("ORACLE_NODE_E_USER", "opc")
34
+ ORACLE_NODE_E_SSH_KEY_PATH = os.getenv("ORACLE_NODE_E_SSH_KEY_PATH", "/app/secrets/oracle_private_key.pem")
35
+ ORACLE_NODE_E_PORT = int(os.getenv("ORACLE_NODE_E_PORT", "8080"))
36
+ ORACLE_NODE_E_ROLE = os.getenv("ORACLE_NODE_E_ROLE", "Compute/AI Workload")
37
+ ORACLE_NODE_E_REGION = os.getenv("ORACLE_NODE_E_REGION", "Italy Northwest (Milan)")
38
+
39
+ # ── Enumerazione Stato Nodo ────────────────────────────────────────────────
40
+ class NodeStatus(str, Enum):
41
+ HEALTHY = "healthy"
42
+ DEGRADED = "degraded"
43
+ UNHEALTHY = "unhealthy"
44
+ OFFLINE = "offline"
45
+
46
+
47
+ # ── SSH Client per Oracle ──────────────────────────────────────────────────
48
+ class OracleSSHClient:
49
+ """Client SSH per gestire il server Oracle."""
50
+
51
+ def __init__(self, host: str, user: str, key_path: str):
52
+ self.host = host
53
+ self.user = user
54
+ self.key_path = key_path
55
+ self.client = None
56
+
57
+ async def connect(self) -> bool:
58
+ """Connette al server Oracle via SSH."""
59
+ try:
60
+ self.client = paramiko.SSHClient()
61
+ self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
62
+
63
+ # Carica chiave privata
64
+ if not os.path.exists(self.key_path):
65
+ _logger.error(f"Chiave SSH non trovata: {self.key_path}")
66
+ return False
67
+
68
+ pkey = paramiko.RSAKey.from_private_key_file(self.key_path)
69
+ self.client.connect(self.host, username=self.user, pkey=pkey, timeout=10)
70
+
71
+ _logger.info(f"Connesso a Oracle Node E: {self.host}")
72
+ return True
73
+ except Exception as exc:
74
+ _logger.error(f"Errore connessione SSH: {exc}")
75
+ return False
76
+
77
+ async def execute_command(self, cmd: str) -> tuple[bool, str]:
78
+ """Esegue un comando remoto."""
79
+ try:
80
+ if not self.client:
81
+ return False, "Non connesso"
82
+
83
+ stdin, stdout, stderr = self.client.exec_command(cmd, timeout=30)
84
+ output = stdout.read().decode('utf-8')
85
+ error = stderr.read().decode('utf-8')
86
+
87
+ return True, output if output else error
88
+ except Exception as exc:
89
+ _logger.error(f"Errore esecuzione comando: {exc}")
90
+ return False, str(exc)
91
+
92
+ async def disconnect(self):
93
+ """Disconnette dal server."""
94
+ if self.client:
95
+ self.client.close()
96
+ _logger.info("Disconnesso da Oracle Node E")
97
+
98
+
99
+ # ── Oracle Node Manager ────────────────────────────────────────────────────
100
+ class OracleNodeManager:
101
+ """Gestore del Nodo E (Oracle Cloud)."""
102
+
103
+ def __init__(self):
104
+ self.ssh_client = OracleSSHClient(ORACLE_NODE_E_IP, ORACLE_NODE_E_USER, ORACLE_NODE_E_SSH_KEY_PATH)
105
+ self.status = NodeStatus.OFFLINE
106
+ self._last_sync = None
107
+ self._metrics = {
108
+ "cpu_usage": 0.0,
109
+ "memory_usage": 0.0,
110
+ "disk_usage": 0.0,
111
+ "uptime_seconds": 0,
112
+ "sync_lag_seconds": 0,
113
+ }
114
+
115
+ async def initialize(self) -> bool:
116
+ """Inizializza la connessione al Nodo E."""
117
+ if not ORACLE_NODE_E_ENABLED:
118
+ _logger.info("Oracle Node E disabilitato")
119
+ return False
120
+
121
+ connected = await self.ssh_client.connect()
122
+ if connected:
123
+ self.status = NodeStatus.HEALTHY
124
+ return True
125
+ else:
126
+ self.status = NodeStatus.OFFLINE
127
+ return False
128
+
129
+ async def health_check(self) -> Dict[str, Any]:
130
+ # Tentativo tramite Gateway se la connessione diretta fallisce
131
+ gateway_res = await oci_gateway.get_gateway_status()
132
+ if gateway_res.get("ok"):
133
+ _logger.info("Connessione via OCI API Gateway attiva")
134
+ """Esegue health check del Nodo E."""
135
+ if not self.ssh_client.client:
136
+ return {
137
+ "ok": False,
138
+ "status": NodeStatus.OFFLINE.value,
139
+ "error": "Non connesso",
140
+ }
141
+
142
+ try:
143
+ # Verifica backend
144
+ backend_ok, _ = await self.ssh_client.execute_command(
145
+ f"curl -s http://localhost:{ORACLE_NODE_E_PORT}/api/health || echo 'FAIL'"
146
+ )
147
+
148
+ # Verifica risorse
149
+ cpu_ok, cpu_output = await self.ssh_client.execute_command("top -bn1 | grep 'Cpu(s)' | awk '{print $2}'")
150
+ mem_ok, mem_output = await self.ssh_client.execute_command("free | grep Mem | awk '{printf \"%.1f\", ($3/$2)*100}'")
151
+ disk_ok, disk_output = await self.ssh_client.execute_command("df -h / | tail -1 | awk '{print $5}'")
152
+
153
+ # Aggiorna metriche
154
+ self._metrics["cpu_usage"] = float(cpu_output.strip()) if cpu_ok else 0.0
155
+ self._metrics["memory_usage"] = float(mem_output.strip()) if mem_ok else 0.0
156
+ self._metrics["disk_usage"] = float(disk_output.strip().rstrip('%')) if disk_ok else 0.0
157
+
158
+ # Determina stato
159
+ if backend_ok and self._metrics["cpu_usage"] < 80 and self._metrics["memory_usage"] < 85:
160
+ self.status = NodeStatus.HEALTHY
161
+ elif self._metrics["cpu_usage"] < 95 and self._metrics["memory_usage"] < 95:
162
+ self.status = NodeStatus.DEGRADED
163
+ else:
164
+ self.status = NodeStatus.UNHEALTHY
165
+
166
+ return {
167
+ "ok": True,
168
+ "status": self.status.value,
169
+ "metrics": self._metrics,
170
+ "timestamp": datetime.now().isoformat(),
171
+ }
172
+ except Exception as exc:
173
+ _logger.error(f"Errore health check: {exc}")
174
+ self.status = NodeStatus.UNHEALTHY
175
+ return {
176
+ "ok": False,
177
+ "status": NodeStatus.UNHEALTHY.value,
178
+ "error": str(exc),
179
+ }
180
+
181
+ async def sync_with_cluster(self) -> Dict[str, Any]:
182
+ """Sincronizza il Nodo E con il cluster principale."""
183
+ if not self.ssh_client.client:
184
+ return {"ok": False, "error": "Non connesso"}
185
+
186
+ try:
187
+ # Esegui script di sincronizzazione remoto
188
+ sync_cmd = "cd /home/opc/ai-cluster/repo && node scripts/sync-cluster-state.mjs --node E"
189
+ success, output = await self.ssh_client.execute_command(sync_cmd)
190
+
191
+ if success:
192
+ self._last_sync = datetime.now()
193
+ _logger.info(f"Sincronizzazione completata: {output}")
194
+ return {
195
+ "ok": True,
196
+ "message": "Sincronizzazione completata",
197
+ "output": output,
198
+ "timestamp": datetime.now().isoformat(),
199
+ }
200
+ else:
201
+ return {
202
+ "ok": False,
203
+ "error": output,
204
+ }
205
+ except Exception as exc:
206
+ _logger.error(f"Errore sincronizzazione: {exc}")
207
+ return {
208
+ "ok": False,
209
+ "error": str(exc),
210
+ }
211
+
212
+ async def get_logs(self, lines: int = 100) -> str:
213
+ """Recupera i log del backend remoto."""
214
+ if not self.ssh_client.client:
215
+ return "Non connesso"
216
+
217
+ try:
218
+ cmd = f"tail -n {lines} /home/opc/ai-cluster/backend.log"
219
+ success, output = await self.ssh_client.execute_command(cmd)
220
+ return output if success else "Errore lettura log"
221
+ except Exception as exc:
222
+ return f"Errore: {str(exc)}"
223
+
224
+ async def restart_services(self) -> Dict[str, Any]:
225
+ """Riavvia i servizi sul Nodo E."""
226
+ if not self.ssh_client.client:
227
+ return {"ok": False, "error": "Non connesso"}
228
+
229
+ try:
230
+ # Ferma servizi
231
+ await self.ssh_client.execute_command("pkill -f 'npm start' || true")
232
+ await asyncio.sleep(2)
233
+
234
+ # Avvia servizi
235
+ start_cmd = "cd /home/opc/ai-cluster/repo/backend && nohup npm start > /home/opc/ai-cluster/backend.log 2>&1 &"
236
+ success, output = await self.ssh_client.execute_command(start_cmd)
237
+
238
+ await asyncio.sleep(3)
239
+
240
+ # Verifica health
241
+ health = await self.health_check()
242
+
243
+ return {
244
+ "ok": True,
245
+ "message": "Servizi riavviati",
246
+ "health": health,
247
+ "timestamp": datetime.now().isoformat(),
248
+ }
249
+ except Exception as exc:
250
+ _logger.error(f"Errore riavvio servizi: {exc}")
251
+ return {
252
+ "ok": False,
253
+ "error": str(exc),
254
+ }
255
+
256
+ async def get_metrics(self) -> Dict[str, Any]:
257
+ """Ritorna metriche del Nodo E."""
258
+ return {
259
+ "node": "E",
260
+ "ip": ORACLE_NODE_E_IP,
261
+ "role": ORACLE_NODE_E_ROLE,
262
+ "region": ORACLE_NODE_E_REGION,
263
+ "status": self.status.value,
264
+ "metrics": self._metrics,
265
+ "last_sync": self._last_sync.isoformat() if self._last_sync else None,
266
+ "timestamp": datetime.now().isoformat(),
267
+ }
268
+
269
+ async def cleanup(self):
270
+ """Pulizia risorse."""
271
+ await self.ssh_client.disconnect()
272
+
273
+
274
+ # ── Istanza Globale ───────────────────────────────────────────────────────
275
+ _oracle_manager = OracleNodeManager()
276
+
277
+
278
+ # ── Funzioni Pubbliche ────────────────────────────────────────────────────
279
+ async def initialize_oracle_node() -> bool:
280
+ """Inizializza il Nodo E."""
281
+ return await _oracle_manager.initialize()
282
+
283
+
284
+ async def get_oracle_health() -> Dict[str, Any]:
285
+ """Ritorna health check del Nodo E."""
286
+ return await _oracle_manager.health_check()
287
+
288
+
289
+ async def sync_oracle_cluster() -> Dict[str, Any]:
290
+ """Sincronizza il Nodo E con il cluster."""
291
+ return await _oracle_manager.sync_with_cluster()
292
+
293
+
294
+ async def get_oracle_metrics() -> Dict[str, Any]:
295
+ """Ritorna metriche del Nodo E."""
296
+ return await _oracle_manager.get_metrics()
297
+
298
+
299
+ async def restart_oracle_services() -> Dict[str, Any]:
300
+ """Riavvia servizi sul Nodo E."""
301
+ return await _oracle_manager.restart_services()
api/prewarm.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/prewarm.py β€” Predictive Pre-warming (S950)
3
+ Rileva se un task richiederΓ  i nodi HANDS (B) o MEMORY (C) e invia segnali di risveglio preventivi.
4
+ Integrato con fire_speculative_tools per latenza zero.
5
+ """
6
+ import asyncio
7
+ import logging
8
+ import os
9
+ import re
10
+ import httpx
11
+
12
+ _logger = logging.getLogger("agente_ai.prewarm")
13
+
14
+ # Configurazione nodi (Sync con daemon-health-autofix.mjs)
15
+ WAKE_CONFIG = {
16
+ "B": {"owner": "Baida07", "name": "ai-backend-collab", "token": "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", "url": "https://baida07-ai-backend-collab.hf.space"},
17
+ "C": {"owner": "Baida07", "name": "ai-memory-backend", "token": "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", "url": "https://baida07-ai-memory-backend.hf.space"}
18
+ }
19
+
20
+ # Regex per rilevare necessitΓ  di nodi specifici
21
+ RE_HANDS = re.compile(r"\b(crea|fai|scrivi|build|deploy|git|push|commit|shell|terminal|esegui|run|installa|npm|pip|pnpm|setup)\b", re.I)
22
+ RE_MEMORY = re.compile(r"\b(ricorda|memoria|storia|passato|semantica|rag|conoscenza|database|vecchi|precedenti|documenti)\b", re.I)
23
+
24
+ async def _wake_hf_space(node_id: str):
25
+ cfg = WAKE_CONFIG.get(node_id)
26
+ if not cfg: return
27
+
28
+ _logger.info(f"[S950] Pre-warming Nodo {node_id} ({cfg['owner']}/{cfg['name']})...")
29
+ try:
30
+ async with httpx.AsyncClient() as client:
31
+ # 1. Soft Ping
32
+ try: await client.get(f"{cfg['url']}/health", timeout=2.0)
33
+ except: pass
34
+ # 2. Hard Wake (API Restart)
35
+ url = f"https://huggingface.co/api/spaces/{cfg['owner']}/{cfg['name']}/restart"
36
+ headers = {"Authorization": f"Bearer {cfg['token']}"}
37
+ await client.post(url, headers=headers, timeout=5.0)
38
+ except Exception as e:
39
+ _logger.error(f"[S950] Errore pre-warming {node_id}: {e}")
40
+
41
+ async def predictive_prewarm(goal: str):
42
+ tasks = []
43
+ if RE_HANDS.search(goal): tasks.append(_wake_hf_space("B"))
44
+ if RE_MEMORY.search(goal): tasks.append(_wake_hf_space("C"))
45
+ if tasks: await asyncio.gather(*tasks)
46
+
47
+ def fire_predictive_prewarm(goal: str):
48
+ try: asyncio.create_task(predictive_prewarm(goal))
49
+ except Exception as e: _logger.error(f"[S950] Impossibile avviare task prewarm: {e}")
api/telegram_callbacks.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/telegram_callbacks.py β€” Gestione callback_query e inline_query Telegram.
2
+
3
+ Handler:
4
+ _handle_inline β€” inline query (@bot testo in chat)
5
+ _handle_callback β€” callback_data dei bottoni inline
6
+ """
7
+ from __future__ import annotations
8
+ import asyncio, html, logging, os, time
9
+
10
+ from .telegram_tg_client import (
11
+ _get_bot_token, _tg_reply, _tg_send, _tg_edit,
12
+ _tg_typing, _tg_answer_callback, _fmt_elapsed,
13
+ _log_tg_exc, _BACK_KB,
14
+ )
15
+ from .telegram_keyboards import (
16
+ _MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL,
17
+ )
18
+ from .telegram_cmd_ai import (
19
+ _cmd_do, _cmd_autofix, _cmd_score, _cmd_bench, _cmd_improve,
20
+ _cmd_scan_now, _cmd_telemetry, _cmd_coord, _cmd_git,
21
+ )
22
+ from .telegram_cmd_monitoring import (
23
+ _cmd_help, _cmd_status, _cmd_tasks, _cmd_check,
24
+ _cmd_logs,
25
+ )
26
+
27
+ _logger = logging.getLogger("api.telegram_webhook")
28
+
29
+ # ── callback_query handler β€” inline buttons da notify_task_done ───────────────
30
+
31
+ async def _handle_inline(iq: dict, token: str) -> None:
32
+ """Gestisce inline query: @ARJagent_ap_bot <testo> in qualsiasi chat.
33
+ Abilita tramite @BotFather β†’ /setinline β†’ ARJagent_ap_bot.
34
+ """
35
+ bot_token = token or _get_bot_token()
36
+ if not bot_token: return
37
+ iq_id = iq.get("id","")
38
+ query = (iq.get("query") or "").strip()
39
+ q60 = query[:60]
40
+ if not query:
41
+ results = [
42
+ {"type":"article","id":"help","title":"πŸ€– Comandi Agente AI",
43
+ "description":"Lista tutti i comandi",
44
+ "input_message_content":{"message_text":
45
+ "πŸ€– <b>Agente AI</b>\n/do &lt;goal&gt; β€” task streaming\n/ask &lt;q&gt; β€” risposta veloce\n"
46
+ "/autofix β€” fix errori auto\n/status β€” stato\n\nagente-ai.pages.dev","parse_mode":"HTML"}},
47
+ {"type":"article","id":"do","title":"πŸš€ Nuovo Task AI",
48
+ "description":"/do <goal> β€” lancia task complesso",
49
+ "input_message_content":{"message_text":"/do "}},
50
+ {"type":"article","id":"ask","title":"🧠 Domanda AI",
51
+ "description":"/ask <domanda>",
52
+ "input_message_content":{"message_text":"/ask "}},
53
+ ]
54
+ else:
55
+ results = [
56
+ {"type":"article","id":"ask","title":f"🧠 Chiedi: {q60}",
57
+ "description":f"/ask {q60}",
58
+ "input_message_content":{"message_text":f"/ask {query}"}},
59
+ {"type":"article","id":"do","title":f"πŸš€ Task: {q60}",
60
+ "description":f"/do {q60}",
61
+ "input_message_content":{"message_text":f"/do {query}"}},
62
+ {"type":"article","id":"fix","title":f"πŸ”§ AutoFix: {q60}",
63
+ "description":f"/autofix {q60}",
64
+ "input_message_content":{"message_text":f"/autofix {query}"}},
65
+ ]
66
+ try:
67
+ import httpx as _hx
68
+ async with _hx.AsyncClient(timeout=5.0) as c:
69
+ await c.post(f"https://api.telegram.org/bot{bot_token}/answerInlineQuery",
70
+ json={"inline_query_id":iq_id,"results":results,"cache_time":30,"is_personal":True})
71
+ except Exception as exc:
72
+ _logger.debug("inline answer error: %s", exc)
73
+
74
+
75
+ async def _handle_callback(callback_query: dict, token: str) -> None:
76
+ """Gestisce i callback_data dei bottoni inline (notify_task_done, menu)."""
77
+ cq_id = callback_query.get("id", "")
78
+ chat_id = callback_query.get("message", {}).get("chat", {}).get("id", 0)
79
+ data = (callback_query.get("data") or "").strip()
80
+
81
+ # Risponde subito per chiudere il loading Telegram
82
+ await _tg_answer_callback(cq_id, token=token)
83
+
84
+ if not chat_id or not data:
85
+ return
86
+
87
+ # ── task_sum:<tid> β€” riepilogo task da Supabase ──────────────────────────
88
+ if data.startswith("task_sum:"):
89
+ tid = data[9:].strip()
90
+ try:
91
+ from api.state import _sb
92
+ if _sb:
93
+ res = await asyncio.to_thread(
94
+ lambda: _sb.table("agent_tasks")
95
+ .select("task_id,goal,status,created_at")
96
+ .eq("task_id", tid)
97
+ .limit(1)
98
+ .execute()
99
+ )
100
+ rows = res.data or []
101
+ if rows:
102
+ r = rows[0]
103
+ st = r.get("status", "?")
104
+ gol = html.escape((r.get("goal") or "")[:200])
105
+ em = {"SUCCESS":"βœ…","ERROR":"❌","RUNNING":"βš™οΈ","QUEUED":"⏳"}.get(st,"β€’")
106
+ ca = r.get("created_at", 0)
107
+ age = _fmt_elapsed(ca) if isinstance(ca, int) and ca > 1_000_000 else "?"
108
+ await _tg_reply(
109
+ chat_id,
110
+ "πŸ“‹ <b>Riepilogo task</b>\n\n"
111
+ "<b>ID:</b> <code>" + html.escape(tid) + "</code>\n"
112
+ "<b>Stato:</b> " + em + " " + st + "\n"
113
+ "<b>Goal:</b> " + gol + "\n"
114
+ "<b>Avviato:</b> " + age,
115
+ token=token,
116
+ )
117
+ return
118
+ await _tg_reply(chat_id, "⚠️ Task <code>" + html.escape(tid) + "</code> non trovato in Supabase.", token=token)
119
+ except Exception as exc:
120
+ await _tg_reply(chat_id, "❌ Errore: " + html.escape(str(exc)[:200]), token=token)
121
+
122
+ # ── task_wins:<tid> β€” quick wins (non persisti β€” riepilogo generico) ─────
123
+ elif data.startswith("task_wins:"):
124
+ tid = data[10:].strip()
125
+ await _tg_reply(
126
+ chat_id,
127
+ "πŸ’‘ <b>Quick Wins</b> β€” <code>" + html.escape(tid) + "</code>\n\n"
128
+ "I quick wins vengono inclusi nella notifica del task.\n"
129
+ "Avvia <b>/tasks</b> per vedere i task recenti o usa il pannello web per dettagli completi.\n\n"
130
+ "<a href='https://agente-ai.pages.dev'>Apri Dashboard β†’</a>",
131
+ token=token,
132
+ )
133
+
134
+ # ── agent β€” nuovo task (quick-pick) ──────────────────────────────────────
135
+ elif data == "agent":
136
+ await _tg_reply(
137
+ chat_id,
138
+ "πŸš€ <b>Avvia Task</b> β€” scegli un template o scrivi il tuo obiettivo:",
139
+ token=token, keyboard=_QUICK_PICK_KB,
140
+ )
141
+
142
+ # ── tgw_status / tgw_tasks / tgw_health / tgw_help β€” menu comandi ────────
143
+ elif data == "tgw_chart":
144
+ await _tg_reply(chat_id,
145
+ "πŸ“ˆ <b>Grafici</b>\n\nHeatmap commit, burndown sprint e molto altro nella Dashboard:",
146
+ token=token, keyboard=_WEBAPP_KB)
147
+ elif data == "tgw_ask":
148
+ await _tg_reply(chat_id,
149
+ "🧠 <b>Chiedi all'AI</b>\n\n"
150
+ "Scrivi la domanda direttamente o usa:\n"
151
+ "<code>/ask &lt;domanda&gt;</code>\n\n"
152
+ "<i>Esempio: /ask come ottimizzare una query PostgreSQL?</i>",
153
+ token=token, keyboard=_BACK_KB)
154
+ elif data == "tgw_bench":
155
+ asyncio.create_task(_cmd_bench(chat_id)).add_done_callback(_log_tg_exc)
156
+ elif data == "tgw_autofix":
157
+ asyncio.create_task(_cmd_autofix(chat_id,"")).add_done_callback(_log_tg_exc)
158
+ elif data == "tgw_logs":
159
+ asyncio.create_task(_cmd_logs(chat_id,"WARNING")).add_done_callback(_log_tg_exc)
160
+ elif data == "tgw_bench_fix":
161
+ # Legge gap dall'ultimo run (cache) e chiede all'agente un fix mirato
162
+ cached = _BENCH_CACHE.get(chat_id, {})
163
+ goal = (
164
+ "Analizza i gap del benchmark-extended (bench.yml) piΓΉ recente. "
165
+ "Identifica le categorie con score piΓΉ basso e genera patch mirate "
166
+ "(prompt rules, retry logic, tool selection) per migliorare ogni area debole. "
167
+ "PrioritΓ : agentic β†’ coding β†’ reasoning. Fai push delle modifiche su GitHub."
168
+ )
169
+ asyncio.create_task(_cmd_do(chat_id, goal)).add_done_callback(_log_tg_exc)
170
+ elif data == "tgw_bench_run":
171
+ # Rilancia bench
172
+ asyncio.create_task(_cmd_bench(chat_id)).add_done_callback(_log_tg_exc)
173
+ elif data == "tgw_score":
174
+ asyncio.create_task(_cmd_score(chat_id)).add_done_callback(_log_tg_exc)
175
+ elif data == "tgw_telemetry":
176
+ asyncio.create_task(_cmd_telemetry(chat_id)).add_done_callback(_log_tg_exc)
177
+ elif data == "tgw_improve":
178
+ asyncio.create_task(_cmd_improve(chat_id)).add_done_callback(_log_tg_exc)
179
+ elif data == "tgw_status":
180
+ await _cmd_status(chat_id)
181
+ elif data == "tgw_tasks":
182
+ await _cmd_tasks(chat_id)
183
+ elif data == "tgw_health":
184
+ await _cmd_scan_now(chat_id)
185
+ elif data == "tgw_check":
186
+ asyncio.create_task(_cmd_check(chat_id)).add_done_callback(_log_tg_exc)
187
+ elif data == "tgw_help":
188
+ await _cmd_help(chat_id)
189
+ elif data == "tgw_coord":
190
+ asyncio.create_task(_cmd_coord(chat_id)).add_done_callback(_log_tg_exc)
191
+ elif data == "tgw_git":
192
+ asyncio.create_task(_cmd_git(chat_id)).add_done_callback(_log_tg_exc)
193
+
194
+ # ── Sub-menu dispatchers (Reply Keyboard β†’ inline sub-menu) ─────────────────
195
+ elif data in ("menu_task",):
196
+ await _tg_reply(chat_id, "πŸš€ <b>Task AI</b> β€” scegli un'azione:", token=token, keyboard=_TASK_MENU_KB)
197
+ elif data == "menu_status":
198
+ await _cmd_status(chat_id)
199
+ await _tg_reply(chat_id, "πŸ“Š <b>Stato Sistema</b> β€” altre opzioni:", token=token, keyboard=_STATUS_MENU_KB)
200
+ elif data == "menu_perf":
201
+ await _tg_reply(chat_id, "πŸ“ˆ <b>Performance</b> β€” scegli:", token=token, keyboard=_PERF_MENU_KB)
202
+ elif data == "menu_health":
203
+ asyncio.create_task(_cmd_scan_now(chat_id)).add_done_callback(_log_tg_exc)
204
+ elif data == "menu_dev":
205
+ await _tg_reply(chat_id, "πŸ›  <b>Dev Tools</b> β€” scegli:", token=token, keyboard=_DEV_MENU_KB)
206
+ elif data == "tgw_home":
207
+ asyncio.create_task(_cmd_help(chat_id)).add_done_callback(_log_tg_exc)
208
+
209
+ # ── Nuovi callback task/utility ────────────────────────────────────────────
210
+ elif data == "tgw_do":
211
+ await _tg_reply(chat_id,
212
+ "πŸš€ <b>Nuovo Task AI</b>\n\n"
213
+ "Scrivi il tuo obiettivo nella chat β€” elaboro tutto come task AI.\n\n"
214
+ "<i>Esempi:</i>\n"
215
+ " <code>analizza bug in backend/api/state.py</code>\n"
216
+ " <code>genera test per providers.py</code>\n"
217
+ " <code>ottimizza le query Supabase piΓΉ lente</code>\n\n"
218
+ "Oppure: <code>/do &lt;goal&gt;</code>",
219
+ token=token, keyboard=_BACK_KB)
220
+ elif data == "tgw_briefing":
221
+ asyncio.create_task(_cmd_riepilogo(chat_id)).add_done_callback(_log_tg_exc)
222
+ elif data == "tgw_nota":
223
+ await _tg_reply(chat_id,
224
+ "πŸ“ <b>Salva Nota</b>\n\n"
225
+ "Usa: <code>/nota &lt;testo da ricordare&gt;</code>\n\n"
226
+ "<i>Esempio: /nota domani rivedere il deploy HF Space</i>",
227
+ token=token, keyboard=_BACK_KB)
228
+ elif data == "tgw_cerca":
229
+ await _tg_reply(chat_id,
230
+ "πŸ” <b>Cerca sul web</b>\n\n"
231
+ "Usa: <code>/cerca &lt;query&gt;</code>\n\n"
232
+ "<i>Esempio: /cerca best practices FastAPI async Python</i>",
233
+ token=token, keyboard=_BACK_KB)
234
+ elif data == "tgw_meteo":
235
+ await _tg_reply(chat_id,
236
+ "🌀 <b>Meteo</b>\n\n"
237
+ "Usa: <code>/meteo &lt;cittΓ &gt;</code>\n\n"
238
+ "<i>Esempio: /meteo Milano</i>",
239
+ token=token, keyboard=_BACK_KB)
240
+ elif data == "tgw_providers":
241
+ try:
242
+ from api.providers import _heartbeat_state as _hs_pv
243
+ providers = (_hs_pv or {}).get("providers") or []
244
+ if providers:
245
+ lines = ["πŸ”Œ <b>Provider AI</b>\n"]
246
+ best = next((p for p in providers if p.get("ok")), None)
247
+ for p in providers:
248
+ ok = p.get("ok", False)
249
+ icon = "βœ…" if ok else ("πŸ”‘" if "401" in str(p.get("error","")) else ("πŸ’³" if "429" in str(p.get("error","")) else "❌"))
250
+ ms = p.get("latency_ms")
251
+ ms_str = f"{ms}ms" if ms else "–"
252
+ name = p.get("name","?")
253
+ star = " ⭐" if (best and p.get("name") == best.get("name")) else ""
254
+ lines.append(f"{icon} <b>{html.escape(name)}</b>{star} β€” {ms_str}")
255
+ if not ok and p.get("error"):
256
+ lines.append(f" <i>{html.escape(str(p['error'])[:80])}</i>")
257
+ import time as _tpv
258
+ last = (_hs_pv or {}).get("last_run_at",0)
259
+ age = f"{int(_tpv.time()-last)}s fa" if last else "?"
260
+ lines.append(f"\n<i>⏱ Heartbeat {age}</i>")
261
+ await _tg_reply(chat_id, "\n".join(lines), token=token, keyboard=_HEALTH_MENU_KB)
262
+ else:
263
+ await _tg_reply(chat_id,
264
+ "⚠️ Dati provider non ancora disponibili (~90s al primo heartbeat).",
265
+ token=token, keyboard=_BACK_KB)
266
+ except Exception as exc:
267
+ await _tg_reply(chat_id,
268
+ "❌ Errore: <code>" + html.escape(str(exc)[:200]) + "</code>",
269
+ token=token, keyboard=_BACK_KB)
270
+ elif data == "tgw_snap":
271
+ try:
272
+ from .integrity_manager import handle_snap_cmd as _hi_snap
273
+ asyncio.create_task(_hi_snap(chat_id, _tg_reply, None)).add_done_callback(_log_tg_exc)
274
+ except Exception:
275
+ await _tg_reply(chat_id, "⚠️ Integrity manager non disponibile.", token=token, keyboard=_BACK_KB)
276
+ elif data == "tgw_verify":
277
+ try:
278
+ from .integrity_manager import handle_verify_cmd as _hi_verify
279
+ asyncio.create_task(_hi_verify(chat_id, _tg_reply, None)).add_done_callback(_log_tg_exc)
280
+ except Exception:
281
+ await _tg_reply(chat_id, "⚠️ Integrity manager non disponibile.", token=token, keyboard=_BACK_KB)
282
+ elif data == "tgw_heal":
283
+ try:
284
+ from .integrity_manager import handle_heal_cmd as _hi_heal
285
+ asyncio.create_task(_hi_heal(chat_id, _tg_reply, None)).add_done_callback(_log_tg_exc)
286
+ except Exception:
287
+ await _tg_reply(chat_id, "⚠️ Integrity manager non disponibile.", token=token, keyboard=_BACK_KB)
288
+ elif data == "tgw_ping":
289
+ ry = os.getenv("RAILWAY_URL","https://ai-production-4c06.up.railway.app")
290
+ try:
291
+ async with httpx.AsyncClient(timeout=8.0) as _hxc:
292
+ r = await _hxc.get(f"{ry}/health")
293
+ j = r.json()
294
+ ok = "βœ…" if j.get("status")=="ok" else "⚠️"
295
+ await _tg_reply(chat_id,
296
+ f"πŸ“ <b>Pong!</b> {ok}\nv{j.get('version','?')} β€” Railway live",
297
+ token=token, keyboard=_DEV_MENU_KB)
298
+ except Exception as exc:
299
+ await _tg_reply(chat_id,
300
+ "❌ Railway non raggiungibile\n<code>"+html.escape(str(exc)[:150])+"</code>",
301
+ token=token, keyboard=_BACK_KB)
302
+
303
+ # ── Quick-pick task templates (MX-QUICKPICK) ─────────────────────────────
304
+ elif data == "qp_bug":
305
+ asyncio.create_task(_cmd_do(chat_id,
306
+ "Leggi i log di Railway/HF degli ultimi 30 minuti. Identifica gli errori principali, "
307
+ "trova la root cause reale e suggerisci un fix concreto con codice.")).add_done_callback(_log_tg_exc)
308
+ elif data == "qp_db":
309
+ asyncio.create_task(_cmd_do(chat_id,
310
+ "Analizza le query Supabase piΓΉ lente del progetto (agent_tasks, agent_logs, semantic_memory). "
311
+ "Identifica query N+1, indici mancanti, e proponi le ottimizzazioni con SQL concreto.")).add_done_callback(_log_tg_exc)
312
+ elif data == "qp_autofix":
313
+ asyncio.create_task(_cmd_autofix(chat_id, "")).add_done_callback(_log_tg_exc)
314
+ elif data == "qp_commits":
315
+ asyncio.create_task(_cmd_do(chat_id,
316
+ "Leggi gli ultimi 10 commit su Baida98/AI via GitHub API e produci un riepilogo in italiano: "
317
+ "cosa Γ¨ stato fatto, da chi, e qual Γ¨ lo stato attuale del progetto.")).add_done_callback(_log_tg_exc)
318
+ elif data == "qp_docs":
319
+ asyncio.create_task(_cmd_do(chat_id,
320
+ "Genera la documentazione degli endpoint API principali del backend Railway: "
321
+ "/api/telegram, /api/agent, /health. Per ognuno: metodo HTTP, parametri, risposta attesa, esempi.")).add_done_callback(_log_tg_exc)
322
+ elif data == "qp_tests":
323
+ asyncio.create_task(_cmd_do(chat_id,
324
+ "Genera test pytest per i moduli critici del backend: "
325
+ "api/telegram_webhook.py, agents/unified_loop.py, providers. "
326
+ "PrioritΓ : test di integrazione per i path piΓΉ usati e i casi di errore.")).add_done_callback(_log_tg_exc)
327
+ elif data == "qp_custom":
328
+ await _tg_reply(chat_id,
329
+ "✍️ <b>Scrivi il tuo obiettivo</b>\n"
330
+ "Scrivi liberamente nella chat β€” lo eseguo come task AI.\n"
331
+ "<i>Esempi:</i>\n"
332
+ " <code>ottimizza le query Supabase lente</code>\n"
333
+ " <code>analizza providers.py e suggerisci fix</code>",
334
+ token=token, keyboard=_BACK_KB)
335
+ # ── tgw_retry β€” rilancia l'ultimo task ───────────────────────────────────
336
+ elif data == "tgw_retry":
337
+ last = _LAST_GOAL.get(chat_id)
338
+ if last:
339
+ asyncio.create_task(_cmd_do(chat_id, last)).add_done_callback(_log_tg_exc)
340
+ else:
341
+ await _tg_reply(chat_id,
342
+ "⚠️ Nessun task precedente da ripetere. Avvia un nuovo task:",
343
+ token=token, keyboard=_QUICK_PICK_KB)
344
+
345
+ # ── m β€” menu principale ───────────────────────────────────────────────────
346
+ elif data == "m":
347
+ await _tg_reply(
348
+ chat_id,
349
+ "πŸ€– <b>Menu principale</b>\n\nScegli un'azione:",
350
+ token=token,
351
+ keyboard=_MAIN_KB,
352
+ )
353
+
354
+ # ── fallback β€” command non gestito ───────────────────────────────────────
355
+ else:
356
+ _logger.debug("callback_query unhandled: %s", data)
357
+
358
+
api/telegram_cmd_ai.py ADDED
@@ -0,0 +1,1154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/telegram_cmd_ai.py β€” Comandi Telegram AI e operativi.
2
+
3
+ Comandi:
4
+ _cmd_do, _cmd_autofix, _cmd_nota, _cmd_cerca, _cmd_meteo,
5
+ _cmd_riepilogo, _cmd_score, _cmd_bench, _cmd_improve
6
+ """
7
+ from __future__ import annotations
8
+ import asyncio, html, logging, os, re, time
9
+
10
+ from .telegram_tg_client import (
11
+ _get_bot_token, _tg_reply, _tg_send, _tg_edit,
12
+ _tg_typing, _tg_react, _tg_photo, _fmt_elapsed,
13
+ _log_tg_exc, _BACK_KB,
14
+ )
15
+ from .telegram_keyboards import (
16
+ _MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL,
17
+ _BENCH_ACTION_KB,
18
+ )
19
+
20
+ _logger = logging.getLogger("api.telegram_webhook")
21
+
22
+ async def _cmd_do(chat_id: int, goal: str) -> None:
23
+ """Lancia task AI con streaming progressivo via editMessageText.
24
+
25
+ Flusso: invia msg iniziale β†’ salva message_id β†’ on_step accumula token
26
+ β†’ edit throttled ogni 1.5s β†’ flush finale con output + keyboard.
27
+ Rate-limit sicuro: max ~40 edit/min totali, Telegram consente 20 edit/min/chat.
28
+ """
29
+ if not goal.strip():
30
+ await _tg_reply(chat_id, "⚠️ Usa il menu per scegliere un task:", keyboard=_QUICK_PICK_KB)
31
+ return
32
+
33
+ _LAST_GOAL[chat_id] = goal # salva per retry
34
+ await _tg_typing(chat_id)
35
+ msg_id = await _tg_send(
36
+ chat_id,
37
+ "πŸš€ <b>Avvio task…</b>\n\n"
38
+ "🎯 <i>" + html.escape(goal[:200]) + "</i>\n\n"
39
+ "⏳ <i>Sto elaborando, un momento…</i>",
40
+ )
41
+
42
+ _buf: list[str] = []
43
+ _last_edit: list[float] = [0.0] # list per mutabilitΓ  in closure
44
+ _EDIT_INTERVAL = 1.5
45
+ _MAX_LEN = 3600
46
+
47
+ async def _flush(final: bool = False) -> None:
48
+ if not msg_id:
49
+ return
50
+ content = "".join(_buf).strip()
51
+ if not content:
52
+ return
53
+ now = time.monotonic()
54
+ if not final and (now - _last_edit[0]) < _EDIT_INTERVAL:
55
+ return
56
+ prefix = "🧠 <b>Risposta AI</b>\n<b>Goal:</b> " + html.escape(goal[:80]) + "\n\n"
57
+ suffix = "" if final else "\n⏳"
58
+ body = html.escape(content[: _MAX_LEN - len(prefix) - len(suffix)])
59
+ await _tg_edit(chat_id, msg_id, prefix + body + suffix)
60
+ _last_edit[0] = time.monotonic()
61
+
62
+ async def _on_step(event: dict) -> None:
63
+ if event.get("action") == "text_chunk":
64
+ tok = event.get("token", "")
65
+ if tok:
66
+ _buf.append(tok)
67
+ await _flush(final=False)
68
+
69
+ try:
70
+ from agents.unified_loop import UnifiedAgentLoop
71
+ from api.state import _get_ai_client, _get_mem_manager_async, _get_executor, _get_planner
72
+ client = _get_ai_client()
73
+ memory = await _get_mem_manager_async()
74
+ executor = _get_executor()
75
+ planner = _get_planner()
76
+ try:
77
+ from agents.critic import Critic
78
+ from agents.response_verifier import ResponseVerifier
79
+ critic = Critic(llm_client=client)
80
+ verifier = ResponseVerifier()
81
+ except Exception:
82
+ critic = verifier = None
83
+ loop = UnifiedAgentLoop(
84
+ llm_client=client, critic=critic, verifier=verifier,
85
+ memory=memory, executor=executor, planner=planner,
86
+ )
87
+ result = await asyncio.wait_for(
88
+ loop.run(goal=goal, context="", max_steps=8, on_step=_on_step),
89
+ timeout=120.0,
90
+ )
91
+ output = result.get("output", "") if isinstance(result, dict) else str(result)
92
+ await _flush(final=True)
93
+ if msg_id and output:
94
+ _out_str = str(output)
95
+ _out_esc = html.escape(_out_str[:3000])
96
+ # Usa blockquote espandibile per output lunghi (Bot API 7.4+, Jul 2024)
97
+ _out_body = (
98
+ f"<blockquote expandable>{_out_esc}</blockquote>"
99
+ if len(_out_str) > 400 else _out_esc
100
+ )
101
+ await _tg_edit(
102
+ chat_id, msg_id,
103
+ "βœ… <b>Fatto!</b>\n\n"
104
+ "🎯 <i>" + html.escape(goal[:100]) + "</i>\n\n"
105
+ + _out_body,
106
+ keyboard=_after_task_kb(chat_id),
107
+ )
108
+ await _tg_react(chat_id, msg_id, "πŸŽ‰")
109
+ try:
110
+ from .telegram_notify import notify_task_done
111
+ task_id = result.get("task_id", "tgw-do") if isinstance(result, dict) else "tgw-do"
112
+ await notify_task_done(str(task_id), goal, str(output))
113
+ except Exception:
114
+ if not msg_id:
115
+ _fb_esc = html.escape(str(output)[:800])
116
+ _fb_body = (
117
+ f"<blockquote expandable>{_fb_esc}</blockquote>"
118
+ if len(str(output)) > 400 else _fb_esc
119
+ )
120
+ await _tg_reply(
121
+ chat_id,
122
+ "βœ… <b>Fatto!</b>\n\n"
123
+ "🎯 <i>" + html.escape(goal[:80]) + "</i>\n\n"
124
+ + _fb_body,
125
+ )
126
+ except asyncio.TimeoutError:
127
+ await _flush(final=True)
128
+ err = ("⏱ <b>Timeout</b> β€” task &gt;120s.\n<b>Goal:</b> "
129
+ + html.escape(goal[:120]) + "\n\n<i>Usa il pannello web.</i>")
130
+ if msg_id: await _tg_edit(chat_id, msg_id, err, keyboard=_MAIN_KB)
131
+ else: await _tg_reply(chat_id, err)
132
+ except Exception as exc:
133
+ await _flush(final=True)
134
+ err = "❌ <b>Errore</b>\n<code>" + html.escape(str(exc)[:300]) + "</code>"
135
+ if msg_id: await _tg_edit(chat_id, msg_id, err, keyboard=_MAIN_KB)
136
+ else: await _tg_reply(chat_id, err)
137
+
138
+
139
+ async def _cmd_autofix(chat_id: int, hint: str = "") -> None:
140
+ """Comando /autofix β€” legge errori dai log backend, genera patch via AI, pusha su GitHub.
141
+
142
+ Flusso:
143
+ 1. GET /api/telegram/logs?level=ERROR β€” raccoglie ultimi errori
144
+ 2. AI loop con streaming (on_step) β€” analizza e genera patch
145
+ 3. Parsa blocco ```autofix\nFILE: path\n---\ncontent``` dall'output AI
146
+ 4. Git Data API blob->tree->commit->PATCH ref β€” push automatico
147
+ 5. Riporta commit SHA al chat + link GitHub
148
+
149
+ Env vars Railway: GITHUB_TOKEN (gia' presente), GITHUB_REPO, GITHUB_BRANCH.
150
+ """
151
+ import httpx, base64 as _b64, json as _json
152
+ await _tg_typing(chat_id, "upload_document")
153
+
154
+ msg_id = await _tg_send(
155
+ chat_id,
156
+ "πŸ”§ <b>AutoFix avviato</b>\n\n"
157
+ + (f"<i>Hint: {html.escape(hint[:100])}</i>\n\n" if hint else "")
158
+ + "⏳ <i>Step 1/4 β€” Lettura log errori…</i>",
159
+ )
160
+
161
+ async def _edit(text: str, final: bool = False) -> None:
162
+ if msg_id:
163
+ await _tg_edit(chat_id, msg_id, text, keyboard=_MAIN_KB if final else None)
164
+
165
+ # ── Step 1: leggi errori dal log endpoint ─────────────────────────────────────────
166
+ railway_url = os.getenv("RAILWAY_URL","https://ai-production-4c06.up.railway.app").rstrip("/")
167
+ try:
168
+ async with httpx.AsyncClient(timeout=10.0) as c:
169
+ resp = await c.get(f"{railway_url}/api/telegram/logs",
170
+ params={"level": "ERROR", "n": 30})
171
+ log_data = resp.json() if resp.status_code == 200 else {}
172
+ except Exception as e:
173
+ log_data = {}
174
+ _logger.warning("autofix: log fetch: %s", e)
175
+
176
+ records = log_data.get("records", [])
177
+
178
+ if not records and not hint:
179
+ await _edit(
180
+ "βœ… <b>AutoFix</b>\n\n"
181
+ "🟒 Nessun errore nei log recenti!\n\n"
182
+ "<i>Sistema stabile. Usa <code>/autofix &lt;descrizione bug&gt;</code> "
183
+ "per fix su un errore specifico.</i>",
184
+ final=True,
185
+ )
186
+ return
187
+
188
+ log_lines = "\n".join(
189
+ f"[{r.get('level','?')}] {r.get('logger','?')}: {r.get('msg','')}"
190
+ for r in records[:15]
191
+ ) if records else f"(nessun errore nei log β€” hint: {hint})"
192
+
193
+ log_preview = "\n".join(
194
+ f"β€’ [{r.get('level','?')}] {r.get('logger','?')}: {r.get('msg','')[:80]}"
195
+ for r in records[:5]
196
+ ) or f"<i>hint: {html.escape(hint[:100])}</i>"
197
+
198
+ await _edit(
199
+ "πŸ”§ <b>AutoFix</b>\n\n"
200
+ "πŸ“‹ <b>Errori trovati:</b>\n" + log_preview
201
+ + "\n\n⏳ <i>Step 2/4 β€” Analisi AI in corso…</i>",
202
+ )
203
+
204
+ # ── Step 2: AI loop genera patch ─────────────────────────────────────────────────────
205
+ BT3 = "```" # triple backtick β€” non usare literal per evitare syntax issues
206
+ goal = (
207
+ "Sei un senior engineer. Analizza questi errori dal log backend Python "
208
+ "e genera un patch preciso per risolvere il problema principale.\n\n"
209
+ "LOG ERRORI:\n" + log_lines[:2000]
210
+ + (f"\n\nHINT UTENTE: {hint}" if hint else "")
211
+ + "\n\n"
212
+ "FORMATO RISPOSTA RICHIESTO (tassativo):\n"
213
+ + BT3 + "autofix\n"
214
+ + "FILE: backend/api/<filename>.py\n"
215
+ + "---\n"
216
+ + "<contenuto completo del file modificato>\n"
217
+ + BT3 + "\n\n"
218
+ "Regole:\n"
219
+ "1. Scrivi SOLO il blocco con FILE: e contenuto completo (non un diff)\n"
220
+ "2. Per piu' file includi un blocco per file\n"
221
+ "3. Se non riesci a determinare il file, scrivi FILE: UNKNOWN e spiega"
222
+ )
223
+ context = f"Backend: {railway_url} Repo: {os.getenv('GITHUB_REPO','Baida98/AI')}"
224
+
225
+ _buf: list[str] = []
226
+ _last: list[float] = [0.0]
227
+
228
+ async def _on_step(event: dict) -> None:
229
+ if event.get("action") == "text_chunk":
230
+ tok = event.get("token", "")
231
+ if tok:
232
+ _buf.append(tok)
233
+ now = time.monotonic()
234
+ if now - _last[0] > 2.0:
235
+ preview = "".join(_buf).strip()[-300:]
236
+ await _edit(
237
+ "πŸ”§ <b>AutoFix</b> β€” Step 2/4 AI analizza…\n\n"
238
+ "<code>" + html.escape(preview) + "</code>\n\n⏳"
239
+ )
240
+ _last[0] = now
241
+
242
+ try:
243
+ from agents.unified_loop import UnifiedAgentLoop
244
+ from api.state import _get_ai_client, _get_mem_manager_async, _get_executor, _get_planner
245
+ client = _get_ai_client()
246
+ memory = await _get_mem_manager_async()
247
+ executor = _get_executor()
248
+ planner = _get_planner()
249
+ try:
250
+ from agents.critic import Critic
251
+ from agents.response_verifier import ResponseVerifier
252
+ critic = Critic(llm_client=client)
253
+ verifier = ResponseVerifier()
254
+ except Exception:
255
+ critic = verifier = None
256
+ loop = UnifiedAgentLoop(
257
+ llm_client=client, critic=critic, verifier=verifier,
258
+ memory=memory, executor=executor, planner=planner,
259
+ )
260
+ result = await asyncio.wait_for(
261
+ loop.run(goal=goal, context=context, max_steps=5, on_step=_on_step),
262
+ timeout=150.0,
263
+ )
264
+ ai_output = result.get("output", "") if isinstance(result, dict) else str(result)
265
+ except asyncio.TimeoutError:
266
+ await _edit("⏱ <b>AutoFix timeout</b> β€” AI non ha risposto in 150s.\n\n"
267
+ "<i>Riprova con un hint piΓΉ specifico.</i>", final=True)
268
+ return
269
+ except Exception as exc:
270
+ await _edit("❌ <b>AutoFix β€” errore AI</b>\n<code>"
271
+ + html.escape(str(exc)[:300]) + "</code>", final=True)
272
+ return
273
+
274
+ # ── Step 3: parsa blocco autofix dall'output AI ───────────────────────────────────
275
+ await _edit(
276
+ "πŸ”§ <b>AutoFix</b>\n\n⏳ <i>Step 3/4 β€” Parsing patch…</i>\n\n"
277
+ "<code>" + html.escape(ai_output.strip()[-300:]) + "</code>"
278
+ )
279
+
280
+ import re as _re
281
+ BLOCK_RE = _re.compile(
282
+ r"```autofix\s*\nFILE:\s*(\S+)\s*\n---\s*\n([\s\S]*?)```",
283
+ _re.MULTILINE,
284
+ )
285
+ patches: list[tuple[str, str]] = [
286
+ (m.group(1).strip(), m.group(2))
287
+ for m in BLOCK_RE.finditer(ai_output)
288
+ if m.group(1).strip() not in ("", "UNKNOWN") and m.group(2).strip()
289
+ ]
290
+
291
+ if not patches:
292
+ await _edit(
293
+ "⚠️ <b>AutoFix</b> β€” nessuna patch parsata\n\n"
294
+ "<b>Output AI:</b>\n<code>" + html.escape(ai_output.strip()[:600]) + "</code>\n\n"
295
+ "<i>L'AI non ha prodotto un blocco autofix valido.\n"
296
+ "Prova: <code>/autofix descrizione precisa del bug</code></i>",
297
+ final=True,
298
+ )
299
+ return
300
+
301
+ # ── Step 4: push su GitHub via Git Data API ─────────────────────────────────────────
302
+ gh_token = os.getenv("GITHUB_TOKEN", "")
303
+ gh_repo = os.getenv("GITHUB_REPO", "Baida98/AI")
304
+ gh_branch = os.getenv("GITHUB_BRANCH", "main")
305
+
306
+ if not gh_token:
307
+ await _edit(
308
+ "❌ <b>AutoFix</b> β€” GITHUB_TOKEN non configurato\n\n"
309
+ "<i>Aggiungi GITHUB_TOKEN nelle Railway env vars.</i>",
310
+ final=True,
311
+ )
312
+ return
313
+
314
+ await _edit(
315
+ "πŸ”§ <b>AutoFix</b>\n\n"
316
+ f"⏳ <i>Step 4/4 β€” Push {len(patches)} file su GitHub…</i>\n\n"
317
+ + "\n".join(f"β€’ <code>{p[0]}</code>" for p in patches[:6])
318
+ )
319
+
320
+ gh_hdr = {
321
+ "Authorization": f"token {gh_token}",
322
+ "Accept": "application/vnd.github.v3+json",
323
+ "Content-Type": "application/json",
324
+ "User-Agent": "agente-ai-autofix",
325
+ }
326
+ gh_base = f"https://api.github.com/repos/{gh_repo}"
327
+
328
+ try:
329
+ async with httpx.AsyncClient(timeout=20.0) as c:
330
+ r = await c.get(f"{gh_base}/git/refs/heads/{gh_branch}", headers=gh_hdr)
331
+ r.raise_for_status()
332
+ h_sha = r.json()["object"]["sha"]
333
+
334
+ r = await c.get(f"{gh_base}/git/commits/{h_sha}", headers=gh_hdr)
335
+ r.raise_for_status()
336
+ t_sha = r.json()["tree"]["sha"]
337
+
338
+ tree_items = []
339
+ for fpath, content in patches:
340
+ b64 = _b64.b64encode(content.encode()).decode()
341
+ r = await c.post(f"{gh_base}/git/blobs", headers=gh_hdr,
342
+ json={"content": b64, "encoding": "base64"})
343
+ r.raise_for_status()
344
+ tree_items.append({"path": fpath, "mode": "100644",
345
+ "type": "blob", "sha": r.json()["sha"]})
346
+
347
+ r = await c.post(f"{gh_base}/git/trees", headers=gh_hdr,
348
+ json={"base_tree": t_sha, "tree": tree_items})
349
+ r.raise_for_status()
350
+ new_t = r.json()["sha"]
351
+
352
+ err_summary = ("; ".join(rc.get("msg","")[:60] for rc in records[:2])
353
+ or hint[:60] or "autofix via /autofix command")
354
+ commit_msg = (
355
+ f"fix(autofix): {err_summary}\n\n"
356
+ f"Generato da /autofix β€” {len(patches)} file patchati\n"
357
+ + "Patch: " + ", ".join(p[0] for p in patches)
358
+ )
359
+ r = await c.post(f"{gh_base}/git/commits", headers=gh_hdr,
360
+ json={"message": commit_msg, "tree": new_t, "parents": [h_sha]})
361
+ r.raise_for_status()
362
+ c_sha = r.json()["sha"]
363
+
364
+ r = await c.patch(f"{gh_base}/git/refs/heads/{gh_branch}", headers=gh_hdr,
365
+ json={"sha": c_sha, "force": False})
366
+ r.raise_for_status()
367
+
368
+ files_list = "\n".join(f"β€’ <code>{p[0]}</code>" for p in patches[:6])
369
+ await _edit(
370
+ "βœ… <b>AutoFix completato!</b>\n\n"
371
+ f"<b>Commit:</b> <code>{c_sha[:10]}</code>\n"
372
+ f"<b>Branch:</b> <code>{gh_branch}</code>\n"
373
+ f"<b>File patchati:</b>\n{files_list}\n\n"
374
+ "U0001f680 <b>Railway deploy:</b> avviato automaticamente\n"
375
+ f"U0001f517 <a href=\"https://github.com/{gh_repo}/commit/{c_sha}\">Vedi commit</a>",
376
+ final=True,
377
+ )
378
+ _logger.info("autofix: pushed %s β€” %d files", c_sha[:10], len(patches))
379
+
380
+ except Exception as exc:
381
+ await _edit(
382
+ "❌ <b>AutoFix β€” push GitHub fallito</b>\n<code>"
383
+ + html.escape(str(exc)[:400]) + "</code>\n\n"
384
+ "<i>Verifica GITHUB_TOKEN nelle Railway env vars.</i>",
385
+ final=True,
386
+ )
387
+ _logger.error("autofix: push failed: %s", exc)
388
+
389
+
390
+ async def _cmd_nota(chat_id: int, text: str) -> None:
391
+ """Salva nota rapida nella memoria persistente dell'agente."""
392
+ goal = (
393
+ f"Salva questa nota nella tua memoria persistente usando il tool remember: "
394
+ f"Β«{text}Β». Poi confermami con 'βœ… Nota salvata' e ripeti il testo della nota."
395
+ )
396
+ await _cmd_do(chat_id, goal)
397
+
398
+
399
+ async def _cmd_cerca(chat_id: int, query: str) -> None:
400
+ """Ricerca web + sintesi AI in italiano."""
401
+ goal = (
402
+ f"Cerca su web: {query}. "
403
+ "Usa web_search e sintetizza i risultati piΓΉ rilevanti in 4-5 bullet points concisi "
404
+ "in italiano. Per ogni punto includi la fonte (dominio) tra parentesi."
405
+ )
406
+ await _cmd_do(chat_id, goal)
407
+
408
+
409
+ async def _cmd_meteo(chat_id: int, city: str) -> None:
410
+ """Meteo per cittΓ  specifica via tool get_weather."""
411
+ goal = (
412
+ f"Usa il tool get_weather per {city} e dimmi: temperatura attuale, "
413
+ "condizioni meteo, umiditΓ , previsioni per le prossime ore. Risposta concisa in italiano."
414
+ )
415
+ await _cmd_do(chat_id, goal)
416
+
417
+
418
+ async def _cmd_riepilogo(chat_id: int) -> None:
419
+ """Briefing completo: task recenti + deploy + score + prossimi passi.
420
+
421
+ GAP-TGB: usa benchmark_handler.get_smart_summary per briefing strutturato
422
+ con analisi gap da ultimo report. Fallback: agent-loop se import fallisce.
423
+ """
424
+ try:
425
+ from .benchmark_handler import get_smart_summary as _gsummary
426
+ text = await _gsummary(chat_id)
427
+ await _tg_reply(chat_id, text, keyboard=_BENCH_ACTION_KB)
428
+ except Exception as _exc:
429
+ _logger.debug("benchmark_handler fallback: %s", _exc)
430
+ goal = (
431
+ "Dammi un briefing completo dello stato attuale come assistente personale. "
432
+ "Struttura ESATTA con questi header ## obbligatori:\n"
433
+ "## πŸ“‹ Task recenti (usa recall per recuperare gli ultimi 5)\n"
434
+ "## πŸš€ Deploy status (CF Pages + Railway + HF Space β€” dati reali via tool)\n"
435
+ "## πŸ“Š Score AI (ultimo benchmark disponibile)\n"
436
+ "## ⚠️ Problemi aperti (errori, timeout, regressioni)\n"
437
+ "## βœ… Prossimi passi (3 prioritΓ  concrete)\n"
438
+ "Usa i tool per dati reali β€” mai inventare status."
439
+ )
440
+ await _cmd_do(chat_id, goal)
441
+
442
+
443
+ async def _cmd_scan_now(chat_id: int) -> None:
444
+ """Health-check via health_full() β€” AI + Supabase + Telegram."""
445
+ await _tg_reply(chat_id, "πŸ” <b>Scan in corso…</b>")
446
+ try:
447
+ from .providers import health_full
448
+ h = await health_full()
449
+ ai = h.get("ai", {})
450
+ sb = h.get("supabase", {})
451
+ tg = h.get("telegram", {})
452
+ bk = h.get("backend", {})
453
+ ai_ok = "βœ…" if ai.get("ok") else "❌"
454
+ sb_ok = "βœ…" if sb.get("ok") else "❌"
455
+ tg_ok = "βœ…" if tg.get("ok") else "❌"
456
+ over = "βœ…" if h.get("ok") else "⚠️"
457
+ sb_msg = "ok" if sb.get("ok") else str(sb.get("error", "?"))[:40]
458
+ tg_msg = ("@" + str(tg.get("username", ""))) if tg.get("username") else str(tg.get("error", "?"))[:40]
459
+ avail = str(ai.get("available", 0)) + "/" + str(ai.get("total", 0))
460
+ best = str(ai.get("best", "?"))
461
+ ntasks = str(bk.get("active_tasks", 0))
462
+ ms_str = str(h.get("elapsed_ms", "?"))
463
+ st_str = str(h.get("status", "?"))
464
+ parts = [
465
+ over + " <b>Health Check</b> β€” " + st_str,
466
+ "",
467
+ ai_ok + " <b>AI</b> β€” " + avail + " provider | best: " + best,
468
+ sb_ok + " <b>Supabase</b> β€” " + sb_msg,
469
+ tg_ok + " <b>Telegram</b> β€” " + tg_msg,
470
+ "πŸ“¦ Task attivi: " + ntasks + " πŸ•’ " + ms_str + "ms",
471
+ ]
472
+ await _tg_reply(chat_id, "\n".join(parts), keyboard=_MAIN_KB)
473
+ except Exception as exc:
474
+ await _tg_reply(
475
+ chat_id,
476
+ "❌ <b>Scan errore</b>\n<code>" + html.escape(str(exc)[:300]) + "</code>",
477
+ )
478
+
479
+
480
+ async def _cmd_coord(chat_id: int) -> None:
481
+ """πŸ”— /coord β€” sessioni agent-coord attive e file claimati (sola lettura).
482
+
483
+ Legge la tabella Supabase 'agent_tasks' dove le sessioni agent-coord.mjs
484
+ persistono con goal='__session__<uuid>'. TTL = 5 minuti senza heartbeat.
485
+ Non modifica nulla β€” pura lettura. Non interferisce con il lavoro agente.
486
+ """
487
+ import httpx as _hx_c, time as _tc, json as _jc
488
+ SB_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
489
+ SB_KEY = os.getenv("SUPABASE_KEY", "")
490
+ if not SB_KEY:
491
+ await _tg_reply(chat_id, "⚠️ <b>SUPABASE_KEY</b> non configurata su Railway.")
492
+ return
493
+ await _tg_reply(chat_id, "πŸ”— <b>Agent Coord</b> β€” lettura sessioni…")
494
+ try:
495
+ async with _hx_c.AsyncClient(timeout=8.0) as _c:
496
+ r = await _c.get(
497
+ f"{SB_URL}/rest/v1/agent_tasks",
498
+ params={"goal": "ilike.*__session__*", "select": "goal,context,updated_at"},
499
+ headers={
500
+ "apikey": SB_KEY,
501
+ "Authorization": f"Bearer {SB_KEY}",
502
+ "Content-Type": "application/json",
503
+ },
504
+ )
505
+ if not r.is_success:
506
+ await _tg_reply(chat_id, f"❌ Supabase {r.status_code}: {r.text[:200]}")
507
+ return
508
+ rows = r.json() or []
509
+ except Exception as exc:
510
+ await _tg_reply(chat_id,
511
+ "❌ <code>" + html.escape(str(exc)[:200]) + "</code>")
512
+ return
513
+
514
+ SESSION_TTL_MS = 5 * 60 * 1000 # 5 minuti senza heartbeat = morta
515
+ now_ms = int(_tc.time() * 1000)
516
+ active, stale = [], []
517
+ for row in rows:
518
+ try:
519
+ ctx = _jc.loads(row.get("context") or "{}")
520
+ except Exception:
521
+ continue
522
+ hb = int(ctx.get("lastHeartbeat", 0))
523
+ age_ms = now_ms - hb
524
+ entry = {
525
+ "name": ctx.get("sessionName", "?"),
526
+ "sprint": ctx.get("sprint", "β€”"),
527
+ "files": ctx.get("claimedFiles", []),
528
+ "age_ms": age_ms,
529
+ }
530
+ (active if age_ms < SESSION_TTL_MS else stale).append(entry)
531
+
532
+ if not rows:
533
+ await _tg_reply(chat_id,
534
+ "πŸ”— <b>Agent Coord</b>\n\n"
535
+ "ℹ️ Nessuna sessione registrata.\n<i>Tutti i file sono liberi.</i>",
536
+ keyboard=_BACK_KB)
537
+ return
538
+
539
+ def _age_str(ms: int) -> str:
540
+ s = ms // 1000
541
+ return f"{s // 60}m{s % 60:02d}s" if s >= 60 else f"{s}s"
542
+
543
+ out = ["πŸ”— <b>Agent Coord</b>", ""]
544
+ if active:
545
+ out.append(f"βœ… <b>Attive ({len(active)})</b>")
546
+ for s in active:
547
+ fnames = ", ".join(
548
+ "<code>" + html.escape(f.split("/")[-1]) + "</code>"
549
+ for f in s["files"]
550
+ ) or "<i>nessun file</i>"
551
+ out.append(
552
+ f" <b>{html.escape(s['name'])}</b> "
553
+ f"[{html.escape(s['sprint'])}] Β· {_age_str(s['age_ms'])} fa"
554
+ )
555
+ out.append(f" πŸ“Ž {fnames}")
556
+ else:
557
+ out.append("βœ… Nessuna sessione attiva β€” file liberi")
558
+ if stale:
559
+ out.append("")
560
+ out.append(f"⚫ <b>Inattive &gt;5min ({len(stale)})</b>")
561
+ for s in stale:
562
+ out.append(f" {html.escape(s['name'])} Β· {_age_str(s['age_ms'])} fa")
563
+
564
+ await _tg_reply(chat_id, "\n".join(out), keyboard=_BACK_KB)
565
+
566
+
567
+ async def _cmd_git(chat_id: int, n: int = 5) -> None:
568
+ """πŸ”€ /git [n] β€” ultimi N commit su main con SHA, messaggio, autore, eta e link diff.
569
+
570
+ Chiama GitHub API (GITHUB_TOKEN Railway). Default: ultimi 5 commit.
571
+ Max: 10. Timeout 6s. Silent fail se GITHUB_TOKEN assente.
572
+ """
573
+ import httpx as _hx_git2, datetime as _dt2
574
+ _gh_token = os.getenv("GITHUB_TOKEN", "").strip()
575
+ _gh_repo = os.getenv("GITHUB_REPO", "Baida98/AI").strip()
576
+ if not _gh_token:
577
+ await _tg_reply(chat_id,
578
+ "⚠️ <b>GITHUB_TOKEN</b> non configurato su Railway β€” /git non disponibile.")
579
+ return
580
+ n = max(1, min(n, 10))
581
+ await _tg_reply(chat_id, f"πŸ”€ <b>Git log</b> β€” ultimi {n} commit…")
582
+ try:
583
+ async with _hx_git2.AsyncClient(timeout=6.0) as _gc:
584
+ _gr = await _gc.get(
585
+ f"https://api.github.com/repos/{_gh_repo}/commits",
586
+ headers={"Authorization": f"Bearer {_gh_token}", "User-Agent": "agente-ai"},
587
+ params={"sha": "main", "per_page": str(n)},
588
+ )
589
+ if _gr.status_code != 200:
590
+ await _tg_reply(chat_id,
591
+ f"❌ GitHub API {_gr.status_code}: {html.escape(_gr.text[:200])}")
592
+ return
593
+ commits = _gr.json() or []
594
+ except Exception as exc:
595
+ await _tg_reply(chat_id,
596
+ "❌ <code>" + html.escape(str(exc)[:200]) + "</code>")
597
+ return
598
+
599
+ if not commits:
600
+ await _tg_reply(chat_id, "ℹ️ Nessun commit trovato.", keyboard=_BACK_KB)
601
+ return
602
+
603
+ _now_utc = _dt2.datetime.now(_dt2.timezone.utc)
604
+
605
+ def _age(iso: str) -> str:
606
+ try:
607
+ _ts = _dt2.datetime.fromisoformat(iso.replace("Z", "+00:00"))
608
+ _secs = int((_now_utc - _ts).total_seconds())
609
+ if _secs < 3600: return f"{_secs // 60}m fa"
610
+ if _secs < 86400: return f"{_secs // 3600}h fa"
611
+ if _secs < 604800: return f"{_secs // 86400}g fa"
612
+ return _ts.strftime("%d/%m")
613
+ except Exception:
614
+ return "?"
615
+
616
+ lines = [f"πŸ”€ <b>Git log β€” main</b> ({_gh_repo})\n"]
617
+ for c in commits:
618
+ _sha = (c.get("sha") or "")[:7]
619
+ _commit = c.get("commit") or {}
620
+ _msg_raw = (_commit.get("message") or "").split("\n")[0][:52]
621
+ _msg = html.escape(_msg_raw)
622
+ _author = html.escape((_commit.get("author") or {}).get("name", "?")[:18])
623
+ _date = (_commit.get("committer") or {}).get("date", "")
624
+ _when = _age(_date)
625
+ _url = f"https://github.com/{_gh_repo}/commit/{c.get('sha','')}"
626
+ lines.append(
627
+ f"<code>{html.escape(_sha)}</code> {_msg}\n"
628
+ f" <i>{_author} Β· {_when}</i> "
629
+ f'<a href="{_url}">diff β†’</a>'
630
+ )
631
+
632
+ await _tg_reply(chat_id, "\n".join(lines), keyboard=_BACK_KB)
633
+
634
+ async def _cmd_telemetry(chat_id: int) -> None:
635
+ """πŸ“‘ Metriche runtime live: /api/telemetry + /debug/timing da Railway."""
636
+ import httpx as _hx_t
637
+ rw_url = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/")
638
+ await _tg_reply(chat_id, "⏳ <b>Telemetria</b> β€” interrogo Railway…")
639
+ try:
640
+ async with _hx_t.AsyncClient(timeout=8.0) as _c:
641
+ tel_r, tim_r = await asyncio.gather(
642
+ _c.get(f"{rw_url}/api/telemetry"),
643
+ _c.get(f"{rw_url}/debug/timing"),
644
+ return_exceptions=True,
645
+ )
646
+ except Exception as e:
647
+ await _tg_reply(chat_id, f"❌ Railway non raggiungibile\n<code>{html.escape(str(e)[:120])}</code>", keyboard=_BACK_KB)
648
+ return
649
+
650
+ tel_d = tel_r.json() if not isinstance(tel_r, Exception) and tel_r.status_code == 200 else {}
651
+ tim_d = tim_r.json() if not isinstance(tim_r, Exception) and tim_r.status_code == 200 else {}
652
+
653
+ msg = "πŸ“‘ <b>Telemetria Runtime</b>\n\n"
654
+ timing = tel_d.get("timing", {})
655
+ if timing:
656
+ msg += "<b>⏱ Latenze per fase (avg / p90 / n):</b>\n<code>"
657
+ for phase, v in list(timing.items())[:10]:
658
+ avg_v = v.get("avg", 0); p90_v = v.get("p90", 0); n_v = v.get("n", 0)
659
+ icon = "🟒" if avg_v < 3000 else "🟑" if avg_v < 10000 else "πŸ”΄"
660
+ msg += f"{icon} {phase[:18]:<18} {avg_v:>7}ms p90:{p90_v:>7}ms Γ—{n_v}\n"
661
+ msg += "</code>\n"
662
+ else:
663
+ msg += "<i>⚠️ Nessun dato timing β€” backend idle o prima run.</i>\n"
664
+ repair = tel_d.get("repair", tim_d.get("repair_stats", {}))
665
+ if repair:
666
+ msg += "\n<b>πŸ”§ Quality & Repair:</b>\n<code>"
667
+ for k, v in list(repair.items())[:10]:
668
+ msg += f" {k[:22]:<22} {v:>6}\n"
669
+ msg += "</code>\n"
670
+ ts_d = tim_d.get("timing_stats", {})
671
+ if ts_d:
672
+ msg += "\n<b>πŸ“Š Breakdown /debug/timing:</b>\n<code>"
673
+ for label, v in list(ts_d.items())[:8]:
674
+ avg_v = v.get("avg") or 0; icon = "🟒" if avg_v < 3000 else "🟑" if avg_v < 10000 else "πŸ”΄"
675
+ msg += f"{icon} {label[:18]:<18} {avg_v:>8}ms Γ—{v.get('count',0)}\n"
676
+ msg += "</code>"
677
+ if len(msg) > 4000:
678
+ msg = msg[:4000] + "…"
679
+ await _tg_reply(chat_id, msg, keyboard=_BACK_KB)
680
+
681
+ async def _cmd_score(chat_id: int) -> None:
682
+ """πŸ† Score card dettagliata β€” chart + ranking 4 competitor + nodes + gaps + runtime telemetry."""
683
+ import httpx as _hx_sc, base64 as _b64_sc, json as _j_sc, urllib.parse as _ul_sc
684
+ gh_token = os.getenv("GITHUB_TOKEN", "").strip()
685
+ rw_url = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/")
686
+ await _tg_reply(chat_id, "⏳ <b>Score</b> β€” carico report + metriche runtime…")
687
+
688
+ report: dict | None = None
689
+ if gh_token:
690
+ try:
691
+ async with _hx_sc.AsyncClient(timeout=8.0) as _c:
692
+ _r = await _c.get(
693
+ "https://api.github.com/repos/Baida98/AI/contents/benchmark-report.json?ref=main",
694
+ headers={"Authorization": f"Bearer {gh_token}",
695
+ "Accept": "application/vnd.github.v3+json",
696
+ "User-Agent": "AgenteAI-Bot"},
697
+ )
698
+ if _r.status_code == 200:
699
+ report = _j_sc.loads(_b64_sc.b64decode(_r.json()["content"]).decode())
700
+ except Exception as _e:
701
+ _logger.warning("cmd_score fetch: %s", _e)
702
+
703
+ if not report:
704
+ await _tg_reply(chat_id,
705
+ "❌ <b>Score</b> β€” benchmark-report.json non trovato.\n"
706
+ "Avvia prima <code>/bench</code> per generare i dati.", keyboard=_BENCH_ACTION_KB)
707
+ return
708
+
709
+ s = report.get("summary", {})
710
+ tasks = report.get("tasks", [])
711
+ gaps = report.get("gapCards", [])
712
+ nodes = report.get("orchestrationNodeMap", {})
713
+ ts = (report.get("timestamp") or "")[:16].replace("T", " ")
714
+ seed = report.get("seed", "?")
715
+ ver = report.get("version", "?")
716
+ replay = report.get("replayCli", f"node benchmark-extended.mjs --seed {seed}")
717
+ mft = s.get("mft")
718
+ gap_cnt = s.get("gapCount", len(gaps))
719
+ verdict = s.get("verdict", "").replace("_", " ")
720
+ avg_ai = s.get("avgScore", 0)
721
+ avg_rpl = s.get("avgReplit", 57.9)
722
+ avg_cur = s.get("avgCursor", 64.1)
723
+ avg_dev = s.get("avgDevin", 70.1)
724
+ avg_mns = s.get("avgManus", 71.2)
725
+ w_rpl = s.get("wins_replit", "?")
726
+ w_dev = s.get("wins_devin", "?")
727
+ w_mns = s.get("wins_manus", "?")
728
+
729
+ # Runtime telemetria da Railway (best-effort)
730
+ rt_timing: dict = {}
731
+ rt_repair: dict = {}
732
+ try:
733
+ async with _hx_sc.AsyncClient(timeout=5.0) as _c:
734
+ _tr = await _c.get(f"{rw_url}/api/telemetry")
735
+ if _tr.status_code == 200:
736
+ _td = _tr.json()
737
+ rt_timing = _td.get("timing", {})
738
+ rt_repair = _td.get("repair", {})
739
+ except Exception:
740
+ pass
741
+
742
+ # ── Chart per categoria ───────────────────────────────────────
743
+ cat_map: dict = {}
744
+ for t in tasks:
745
+ cat = (t.get("cat") or "other").replace("_", " ")[:14]
746
+ cat_map.setdefault(cat, []).append(float(t.get("score", 0)))
747
+ labels_ = list(cat_map.keys())
748
+ scores_ = [round(sum(v)/len(v)) for v in cat_map.values()]
749
+ colors_ = ["#4CAF50" if sc >= avg_rpl else "#FF9800" if sc >= 50 else "#F44336" for sc in scores_]
750
+ chart_cfg = {
751
+ "type": "horizontalBar",
752
+ "data": {"labels": labels_, "datasets": [
753
+ {"label": "Agente AI", "data": scores_, "backgroundColor": colors_, "borderWidth": 1},
754
+ {"label": f"Replit {avg_rpl}",
755
+ "data": [avg_rpl]*len(labels_), "type": "line",
756
+ "borderColor": "#2196F3", "borderDash": [5,3], "pointRadius": 0, "fill": False, "borderWidth": 2},
757
+ {"label": f"Devin {avg_dev}",
758
+ "data": [avg_dev]*len(labels_), "type": "line",
759
+ "borderColor": "#FF9800", "borderDash": [5,3], "pointRadius": 0, "fill": False, "borderWidth": 2},
760
+ {"label": f"Manus {avg_mns}",
761
+ "data": [avg_mns]*len(labels_), "type": "line",
762
+ "borderColor": "#9C27B0", "borderDash": [5,3], "pointRadius": 0, "fill": False, "borderWidth": 2},
763
+ ]},
764
+ "options": {
765
+ "title": {"display": True, "text": f"Agente AI {avg_ai}% | Replit {avg_rpl}% | Devin {avg_dev}% | Manus {avg_mns}%"},
766
+ "scales": {"xAxes": [{"ticks": {"min": 0, "max": 100, "stepSize": 20}}]},
767
+ "legend": {"display": True, "position": "bottom"},
768
+ "plugins": {"datalabels": {"display": False}},
769
+ },
770
+ }
771
+ chart_url = ("https://quickchart.io/chart?c=" +
772
+ _ul_sc.quote(_j_sc.dumps(chart_cfg, separators=(",",":"))) +
773
+ "&width=760&height=440&backgroundColor=white")
774
+
775
+ # ── Caption messaggio 1 (foto, max 1024) ─────────────────────
776
+ bar_g = "β–ˆ" * round(avg_ai/10) + "β–‘" * (10 - round(avg_ai/10))
777
+ d_rpl = round(avg_ai - avg_rpl); s_rpl = ("+" if d_rpl >= 0 else "") + str(d_rpl)
778
+ d_dev = round(avg_ai - avg_dev); s_dev = ("+" if d_dev >= 0 else "") + str(d_dev)
779
+ d_mns = round(avg_ai - avg_mns); s_mns = ("+" if d_mns >= 0 else "") + str(d_mns)
780
+ d_cur = round(avg_ai - avg_cur); s_cur = ("+" if d_cur >= 0 else "") + str(d_cur)
781
+ caption = f"πŸ† <b>Score</b> β€” {ts} UTC <code>v{ver}</code>\n"
782
+ caption += f"<code>{bar_g}</code> <b>{avg_ai}%</b> {verdict}\n\n"
783
+ caption += f"<code>{'Modello':<10} {'Score':>5} {'Ξ”':>4} Wins</code>\n"
784
+ caption += f"<code>{'Agente AI':<10} {str(avg_ai)+'%':>5} {'─':>4} ─</code>\n"
785
+ caption += f"<code>{'Replit':<10} {str(avg_rpl)+'%':>5} {s_rpl:>4} {w_rpl}/10</code>\n"
786
+ caption += f"<code>{'Cursor':<10} {str(avg_cur)+'%':>5} {s_cur:>4} ─</code>\n"
787
+ caption += f"<code>{'Devin':<10} {str(avg_dev)+'%':>5} {s_dev:>4} {w_dev}/10</code>\n"
788
+ caption += f"<code>{'Manus':<10} {str(avg_mns)+'%':>5} {s_mns:>4} {w_mns}/10</code>\n\n"
789
+ for cat, vals in sorted(cat_map.items(), key=lambda x: -sum(x[1])/len(x[1])):
790
+ sc = round(sum(vals)/len(vals))
791
+ bar = "β–ˆ" * round(sc/10) + "β–‘" * (10 - round(sc/10))
792
+ d = round(sc - avg_rpl)
793
+ vs = ("+" if d >= 0 else "") + str(d)
794
+ icon = "βœ…" if sc >= avg_rpl else "⚠️" if sc >= 50 else "❌"
795
+ line = f"<code>{cat[:12]:<12} {bar} {sc:>3}% {vs:>4}</code> {icon}\n"
796
+ if len(caption) + len(line) < 1010:
797
+ caption += line
798
+ caption += f"\n🎲 Seed <code>{seed}</code> MFT <b>{mft}s</b> Gaps: <b>{gap_cnt}</b>"
799
+ await _tg_photo(chat_id, chart_url, caption=caption[:1024], keyboard=_BENCH_ACTION_KB)
800
+
801
+ # ── Messaggio 2 β€” dettaglio completo ─────────────────────────
802
+ det = "πŸ“Š <b>Score β€” Dettaglio</b>\n\n"
803
+
804
+ # Orchestration nodes
805
+ NODE_ICONS = {"planner":"🧠","executor":"βš™οΈ","reasoner":"πŸ”¬",
806
+ "recovery_manager":"πŸ›‘","robustness_layer":"πŸ”’","memory_module":"πŸ’Ύ"}
807
+ if nodes:
808
+ det += "<b>⚑ Orchestration Nodes:</b>\n<code>"
809
+ for nk, nv in nodes.items():
810
+ sr = str(nv.get("success_rate", "?"))
811
+ lat = nv.get("avg_latency_s")
812
+ ntsk = nv.get("tasks", "")
813
+ try:
814
+ icon = "βœ…" if float(sr.rstrip("%")) >= 60 else "⚠️" if float(sr.rstrip("%")) >= 30 else "❌"
815
+ except Exception:
816
+ icon = "❓"
817
+ lat_s = f" {lat}s" if lat is not None else ""
818
+ tsk_s = f" Γ—{ntsk}" if ntsk else ""
819
+ det += f"{NODE_ICONS.get(nk,'β€’')} {nk[:20]:<20} {icon} {sr:>5}{lat_s}{tsk_s}\n"
820
+ det += "</code>\n"
821
+
822
+ # Runtime telemetria (live da Railway)
823
+ if rt_timing:
824
+ det += "\n<b>⏱ Runtime Latenze (live):</b>\n<code>"
825
+ for phase, v in list(rt_timing.items())[:8]:
826
+ avg_v = v.get("avg", 0); p90_v = v.get("p90", 0); n_v = v.get("n", 0)
827
+ icon = "🟒" if avg_v < 3000 else "🟑" if avg_v < 10000 else "πŸ”΄"
828
+ det += f"{icon} {phase[:16]:<16} avg:{avg_v:>7}ms p90:{p90_v:>7}ms Γ—{n_v}\n"
829
+ det += "</code>\n"
830
+ if rt_repair:
831
+ det += "<b>πŸ”§ Quality counters:</b>\n<code>"
832
+ for k, v in list(rt_repair.items())[:6]:
833
+ det += f" {k[:20]:<20} {v}\n"
834
+ det += "</code>\n"
835
+ else:
836
+ det += "<i>ℹ️ Telemetria runtime non disponibile (Railway idle)</i>\n"
837
+
838
+ # Top 3 best + Top 3 worst
839
+ sorted_tasks = sorted([t for t in tasks if t.get("score") is not None], key=lambda t: -t["score"])
840
+ if sorted_tasks:
841
+ det += "\n<b>πŸ₯‡ Migliori task:</b>\n"
842
+ for t in sorted_tasks[:3]:
843
+ ref_r = (t.get("ref") or {}).get("replit", avg_rpl)
844
+ d_ = round(t["score"] - ref_r)
845
+ ds_ = ("+" if d_ >= 0 else "") + str(d_)
846
+ ms_ = f" {round(t['agentMs']/1000)}s" if t.get("agentMs") else ""
847
+ det += f" <code>{(t.get('id') or '?'):>3}</code> <b>{t['score']}%</b> ({ds_} vsRpl){ms_}\n"
848
+ det += f" <i>{(t.get('label') or '')[:60]}</i>\n"
849
+ det += "\n<b>⚠️ Task critici:</b>\n"
850
+ for t in sorted_tasks[-3:]:
851
+ ref_r = (t.get("ref") or {}).get("replit", avg_rpl)
852
+ ref_m = (t.get("ref") or {}).get("manus", avg_mns)
853
+ d_ = round(t["score"] - ref_r)
854
+ dm_ = round(t["score"] - ref_m)
855
+ ds_ = ("+" if d_ >= 0 else "") + str(d_)
856
+ dms_ = ("+" if dm_ >= 0 else "") + str(dm_)
857
+ det += f" <code>{(t.get('id') or '?'):>3}</code> <b>{t['score']}%</b> vsRpl {ds_} vsManus {dms_}\n"
858
+ det += f" <i>{(t.get('label') or '')[:60]}</i>\n"
859
+
860
+ # Gap cards top 3
861
+ if gaps:
862
+ det += "\n<b>πŸ”₯ Gap da correggere:</b>\n"
863
+ for g in gaps[:3]:
864
+ gicon = "πŸ”΄" if g.get("gravita") == "critical" else "🟑"
865
+ det += f"{gicon} <b>{(g.get('modulo_coinvolto') or '').replace('_',' ')}</b> β€” {(g.get('causa_radice') or '')[:55]}\n"
866
+ det += f" Fix: <i>{(g.get('fix_immediato') or '')[:65]}</i>\n"
867
+ det += f" Atteso: <b>{(g.get('beneficio_atteso') or '')[:40]}</b>\n"
868
+
869
+ det += f"\n<code>{replay}</code>"
870
+ det += f"\n🌐 <a href=\"https://agente-ai.pages.dev\">Dashboard β†’</a>"
871
+
872
+ _TG_MAX = 4000
873
+ if len(det) <= _TG_MAX:
874
+ await _tg_reply(chat_id, det, keyboard=_BENCH_ACTION_KB)
875
+ else:
876
+ await _tg_reply(chat_id, det[:_TG_MAX])
877
+ await _tg_reply(chat_id, det[_TG_MAX:_TG_MAX*2][:_TG_MAX], keyboard=_BENCH_ACTION_KB)
878
+
879
+
880
+ async def _cmd_bench(chat_id: int, mode: str = "default") -> None:
881
+ """πŸ“Š Benchmark via bench.yml (benchmark-extended.mjs) + quickchart.io.
882
+
883
+ GAP-TGB: workflow_dispatch su bench.yml β€” usa benchmark-extended.mjs
884
+ (20 categorie, seed canonico 1337, tutte le fix v5).
885
+ Risultati inviati via Telegram da ab-bench.mjs --notify al completamento.
886
+ """
887
+ gh_token = os.getenv("GITHUB_TOKEN", "").strip()
888
+
889
+ # ── Tenta fetch ultimo run completato da GitHub Actions artifact ─────────
890
+ last_report: dict | None = None
891
+ if gh_token:
892
+ try:
893
+ import httpx as _hx
894
+ async with _hx.AsyncClient(timeout=8.0) as _c:
895
+ _r = await _c.get(
896
+ "https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/runs"
897
+ "?status=completed&per_page=1",
898
+ headers={"Authorization": f"Bearer {gh_token}",
899
+ "Accept": "application/vnd.github.v3+json",
900
+ "User-Agent": "AgenteAI-Bot"},
901
+ )
902
+ if _r.status_code == 200:
903
+ _runs = _r.json().get("workflow_runs", [])
904
+ if _runs:
905
+ last_report = {
906
+ "run_id": _runs[0]["id"],
907
+ "run_url": _runs[0]["html_url"],
908
+ "conclusion":_runs[0].get("conclusion","?"),
909
+ "updated": _runs[0].get("updated_at",""),
910
+ }
911
+ except Exception as _exc:
912
+ _logger.debug("bench fetch last run: %s", _exc)
913
+
914
+ # ── Trigger nuovo run via workflow_dispatch ───────────────────────────────
915
+ run_url = "https://github.com/Baida98/AI/actions/workflows/bench.yml"
916
+ if gh_token:
917
+ try:
918
+ import httpx as _hx
919
+ async with _hx.AsyncClient(timeout=10.0) as _c:
920
+ _r = await _c.post(
921
+ "https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/dispatches",
922
+ json={"ref": "main", "inputs": {
923
+ "mode": mode,
924
+ "run_improve": "false",
925
+ "force_update_baseline": "false",
926
+ }},
927
+ headers={"Authorization": f"Bearer {gh_token}",
928
+ "Accept": "application/vnd.github.v3+json",
929
+ "User-Agent": "AgenteAI-Bot"},
930
+ )
931
+ if _r.status_code == 204:
932
+ _logger.info("[bench] workflow_dispatch OK (mode=%s)", mode)
933
+ # Attendi 2s e leggi il run ID appena creato
934
+ await asyncio.sleep(2.0)
935
+ async with _hx.AsyncClient(timeout=8.0) as _c2:
936
+ _r2 = await _c2.get(
937
+ "https://api.github.com/repos/Baida98/AI/actions/workflows/"
938
+ "bench.yml/runs?per_page=1",
939
+ headers={"Authorization": f"Bearer {gh_token}",
940
+ "Accept": "application/vnd.github.v3+json",
941
+ "User-Agent": "AgenteAI-Bot"},
942
+ )
943
+ if _r2.status_code == 200:
944
+ _rr = _r2.json().get("workflow_runs", [])
945
+ if _rr:
946
+ run_url = _rr[0]["html_url"]
947
+ else:
948
+ _logger.warning("[bench] workflow_dispatch status=%d", _r.status_code)
949
+ except Exception as _exc:
950
+ _logger.warning("[bench] workflow_dispatch error: %s", _exc)
951
+
952
+ # ── Costruisci messaggio con quickchart dell'ultimo run (se disponibile) ──
953
+ _BENCH_CACHE[chat_id] = {"mode": mode, "run_url": run_url}
954
+
955
+ # ── Fetch benchmark-report.json dal repo per quickchart reale ──────────────
956
+ bench_report: dict | None = None
957
+ if gh_token:
958
+ try:
959
+ import httpx as _hx, base64 as _b64, json as _json
960
+ async with _hx.AsyncClient(timeout=8.0) as _c:
961
+ _br = await _c.get(
962
+ "https://api.github.com/repos/Baida98/AI/contents/benchmark-report.json?ref=main",
963
+ headers={"Authorization": f"Bearer {gh_token}",
964
+ "Accept": "application/vnd.github.v3+json",
965
+ "User-Agent": "AgenteAI-Bot"},
966
+ )
967
+ if _br.status_code == 200:
968
+ _content = _b64.b64decode(_br.json()["content"]).decode()
969
+ bench_report = _json.loads(_content)
970
+ except Exception as _exc:
971
+ _logger.debug("bench fetch benchmark-report.json: %s", _exc)
972
+
973
+ chart_url: str | None = None
974
+
975
+ def _build_quickchart(report: dict) -> str:
976
+ """Costruisce URL quickchart.io da benchmark-report.json."""
977
+ import json as _j, urllib.parse as _ul
978
+ tasks = report.get("tasks", [])
979
+ summary = report.get("summary", {})
980
+ avg_ai = summary.get("avgScore", 0)
981
+ avg_rpl = summary.get("avgReplit", 57.9)
982
+ avg_mns = summary.get("avgManus", 71.2)
983
+ cat_map: dict[str, list[float]] = {}
984
+ for t in tasks:
985
+ cat = (t.get("cat") or "other").replace("_", " ")[:14]
986
+ cat_map.setdefault(cat, []).append(t.get("score", 0))
987
+ if not cat_map:
988
+ return ""
989
+ labels = list(cat_map.keys())
990
+ scores = [round(sum(v)/len(v)) for v in cat_map.values()]
991
+ colors = ["#4CAF50" if s >= avg_rpl else "#FF9800" if s >= 50 else "#F44336" for s in scores]
992
+ cfg = {
993
+ "type": "horizontalBar",
994
+ "data": {
995
+ "labels": labels,
996
+ "datasets": [
997
+ {"label": "Agente AI", "data": scores,
998
+ "backgroundColor": colors, "borderColor": colors, "borderWidth": 1},
999
+ {"label": f"Replit {avg_rpl}",
1000
+ "data": [avg_rpl]*len(labels),
1001
+ "type": "line", "borderColor": "#2196F3", "borderDash": [5,3],
1002
+ "pointRadius": 0, "fill": False, "borderWidth": 2},
1003
+ {"label": f"Manus {avg_mns}",
1004
+ "data": [avg_mns]*len(labels),
1005
+ "type": "line", "borderColor": "#9C27B0", "borderDash": [5,3],
1006
+ "pointRadius": 0, "fill": False, "borderWidth": 2},
1007
+ ],
1008
+ },
1009
+ "options": {
1010
+ "title": {"display": True,
1011
+ "text": f"Agente AI {avg_ai}% | Replit {avg_rpl}% | Manus {avg_mns}%"},
1012
+ "scales": {"xAxes": [{"ticks": {"min": 0, "max": 100, "stepSize": 20}}]},
1013
+ "legend": {"display": True, "position": "bottom"},
1014
+ "plugins": {"datalabels": {"display": False}},
1015
+ },
1016
+ }
1017
+ return ("https://quickchart.io/chart?c=" +
1018
+ _ul.quote(_j.dumps(cfg, separators=(",",":"))) +
1019
+ "&width=720&height=420&backgroundColor=white")
1020
+
1021
+ if bench_report:
1022
+ chart_url = _build_quickchart(bench_report)
1023
+
1024
+ summary = (bench_report or {}).get("summary", {})
1025
+ avg_ai = summary.get("avgScore")
1026
+ avg_rpl = summary.get("avgReplit")
1027
+ avg_mns = summary.get("avgManus")
1028
+ # ── Tabella ASCII con barre per caption Telegram ──────────────────────────
1029
+ def _text_table_bench(report: dict, rpl: float) -> str:
1030
+ tasks = report.get("tasks", [])
1031
+ cat_map: dict[str, list[float]] = {}
1032
+ for t in tasks:
1033
+ cat = (t.get("cat") or "other").replace("_", " ")[:12]
1034
+ cat_map.setdefault(cat, []).append(float(t.get("score", 0)))
1035
+ if not cat_map:
1036
+ return ""
1037
+ rows = []
1038
+ for cat, vals in sorted(cat_map.items(), key=lambda x: -sum(x[1]) / len(x[1])):
1039
+ sc = round(sum(vals) / len(vals))
1040
+ bar = "β–ˆ" * round(sc / 10) + "β–‘" * (10 - round(sc / 10))
1041
+ delta_rpl = sc - rpl
1042
+ vs = ("+" if delta_rpl >= 0 else "") + str(round(delta_rpl)) + "vsRpl"
1043
+ rows.append(f"{cat:<12} {bar} {sc:>3}% {vs}")
1044
+ return "\n".join(rows)
1045
+
1046
+ text_table = ""
1047
+ if bench_report and avg_rpl is not None:
1048
+ text_table = _text_table_bench(bench_report, float(avg_rpl))
1049
+
1050
+ score_line = ""
1051
+ if avg_ai is not None:
1052
+ score_line = (
1053
+ f"\nπŸ“ˆ <b>Score:</b> AI <b>{avg_ai}%</b>"
1054
+ + (f" | Replit {avg_rpl}%" if avg_rpl else "")
1055
+ + (f" | Manus {avg_mns}%" if avg_mns else "")
1056
+ + "\n"
1057
+ )
1058
+
1059
+ def _build_bench_caption(header: str) -> str:
1060
+ tbl = ("\n<code>" + text_table + "</code>") if text_table else ""
1061
+ link = f'\nπŸ”— <a href="{html.escape(run_url)}">GitHub Actions</a>'
1062
+ full = header + score_line + tbl + link
1063
+ if len(full) > 1020 and text_table:
1064
+ avail = max(0, 1020 - len(header) - len(score_line) - len(link) - 14)
1065
+ tbl = "\n<code>" + text_table[:avail] + "…</code>"
1066
+ full = header + score_line + tbl + link
1067
+ return full[:1024]
1068
+
1069
+ if last_report:
1070
+ _conclusion = last_report.get("conclusion", "?")
1071
+ _em = "βœ…" if _conclusion == "success" else ("❌" if _conclusion == "failure" else "⚠️")
1072
+ _upd = last_report.get("updated", "")[:16].replace("T", " ")
1073
+ header = f"πŸ“Š <b>Benchmark avviato</b> β€” {_em} {_conclusion}\nπŸ• {_upd} UTC"
1074
+ else:
1075
+ header = "πŸ“Š <b>Benchmark avviato</b> (benchmark-extended.mjs)"
1076
+
1077
+ caption = _build_bench_caption(header)
1078
+
1079
+ if chart_url:
1080
+ await _tg_photo(chat_id, chart_url, caption=caption, keyboard=_BENCH_ACTION_KB)
1081
+ else:
1082
+ await _tg_reply(chat_id, caption, keyboard=_BENCH_ACTION_KB)
1083
+
1084
+
1085
+
1086
+ # ── _cmd_improve β€” ciclo miglioramento: bench β†’ gap β†’ patch ─────────────────
1087
+
1088
+ async def _cmd_improve(chat_id: int) -> None:
1089
+ """βš™οΈ Ciclo di miglioramento: bench.yml con --improve β†’ gap β†’ patch automatica.
1090
+
1091
+ Triggera bench.yml con run_improve=true (benchmark-extended.mjs --improve).
1092
+ Il ciclo: few-shot retry sui task falliti β†’ identifica pattern di miglioramento
1093
+ β†’ genera regole β†’ propone patch via notify Telegram.
1094
+ """
1095
+ gh_token = os.getenv("GITHUB_TOKEN", "").strip()
1096
+
1097
+ await _tg_reply(chat_id,
1098
+ "βš™οΈ <b>Ciclo miglioramento avviato</b>\n\n"
1099
+ "β€’ Avvio <code>benchmark-extended.mjs --improve</code>\n"
1100
+ "β€’ Identifica gap Β· genera regole migliorate Β· propone patch\n"
1101
+ "<i>Riceverai notifica Telegram al completamento (~10-25 min).</i>",
1102
+ keyboard=_BACK_KB)
1103
+
1104
+ run_url = "https://github.com/Baida98/AI/actions/workflows/bench.yml"
1105
+ if not gh_token:
1106
+ await _tg_reply(chat_id,
1107
+ "⚠️ GITHUB_TOKEN non configurato β€” impossibile avviare workflow.",
1108
+ keyboard=_BACK_KB)
1109
+ return
1110
+
1111
+ try:
1112
+ import httpx as _hx
1113
+ async with _hx.AsyncClient(timeout=10.0) as _c:
1114
+ _r = await _c.post(
1115
+ "https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/dispatches",
1116
+ json={"ref": "main", "inputs": {
1117
+ "mode": "default",
1118
+ "run_improve": "true",
1119
+ "force_update_baseline": "false",
1120
+ }},
1121
+ headers={"Authorization": f"Bearer {gh_token}",
1122
+ "Accept": "application/vnd.github.v3+json",
1123
+ "User-Agent": "AgenteAI-Bot"},
1124
+ )
1125
+ if _r.status_code == 204:
1126
+ _logger.info("[improve] workflow_dispatch OK")
1127
+ await asyncio.sleep(2.0)
1128
+ async with _hx.AsyncClient(timeout=8.0) as _c2:
1129
+ _r2 = await _c2.get(
1130
+ "https://api.github.com/repos/Baida98/AI/actions/workflows/"
1131
+ "bench.yml/runs?per_page=1",
1132
+ headers={"Authorization": f"Bearer {gh_token}",
1133
+ "Accept": "application/vnd.github.v3+json",
1134
+ "User-Agent": "AgenteAI-Bot"},
1135
+ )
1136
+ if _r2.status_code == 200:
1137
+ _rr = _r2.json().get("workflow_runs", [])
1138
+ if _rr:
1139
+ run_url = _rr[0]["html_url"]
1140
+ await _tg_reply(chat_id,
1141
+ f"βœ… Ciclo miglioramento avviato\n"
1142
+ f'πŸ”— <a href="{html.escape(run_url)}">Segui su GitHub Actions</a>',
1143
+ keyboard=_BENCH_ACTION_KB)
1144
+ else:
1145
+ await _tg_reply(chat_id,
1146
+ f"⚠️ GitHub API status {_r.status_code} β€” riprova tra poco.",
1147
+ keyboard=_BACK_KB)
1148
+ except Exception as _exc:
1149
+ _logger.warning("[improve] error: %s", _exc)
1150
+ await _tg_reply(chat_id,
1151
+ f"❌ Errore: <code>{html.escape(str(_exc)[:150])}</code>",
1152
+ keyboard=_BACK_KB)
1153
+
1154
+
api/telegram_cmd_monitoring.py ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/telegram_cmd_monitoring.py β€” Comandi Telegram di monitoraggio e stato.
2
+
3
+ Comandi:
4
+ _cmd_help, _cmd_logs, _cmd_status, _cmd_commit_summary,
5
+ _cmd_check, _cmd_tasks, _cmd_git, _cmd_coord,
6
+ _cmd_scan_now, _cmd_telemetry
7
+ """
8
+ from __future__ import annotations
9
+ import asyncio, html, logging, os, re, time
10
+
11
+ from .telegram_tg_client import (
12
+ _get_bot_token, _tg_reply, _tg_send, _tg_edit,
13
+ _tg_typing, _tg_react, _tg_photo, _fmt_elapsed,
14
+ _BACK_KB,
15
+ )
16
+ from .telegram_keyboards import (
17
+ _MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL,
18
+ )
19
+
20
+ _logger = logging.getLogger("api.telegram_webhook")
21
+
22
+ # ── Command handlers ──────────────────────────────────────────────────────────
23
+
24
+ async def _cmd_help(chat_id: int) -> None:
25
+ """Menu principale: un messaggio pulito + inline keyboard essenziale."""
26
+ await _tg_typing(chat_id)
27
+ welcome = (
28
+ "πŸ€– <b>Agente AI</b>\n"
29
+ "<i>Assistente autonomo per lo sviluppo software</i>\n\n"
30
+ "πŸ“ <b>Come usarmi:</b>\n"
31
+ "Scrivi qualsiasi obiettivo β€” lo eseguo autonomamente:\n"
32
+ " β€’ <code>analizza i bug in providers.py</code>\n"
33
+ " β€’ <code>ottimizza le query Supabase piΓΉ lente</code>\n"
34
+ " β€’ <code>fai autofix degli errori nel log</code>\n\n"
35
+ "πŸ“Œ <b>Comandi rapidi:</b>\n"
36
+ " /avvia β€” lancia un task AI\n"
37
+ " /stato β€” vedi cosa sta facendo\n"
38
+ " /salute β€” controllo sistema\n"
39
+ " /chiedi β€” domanda veloce all\'AI\n\n"
40
+ "⬇️ <b>O scegli dal menu:</b>"
41
+ )
42
+ await _tg_reply(chat_id, welcome, keyboard=_MAIN_KB)
43
+
44
+
45
+ async def _cmd_logs(chat_id: int, level: str = "WARNING") -> None:
46
+ """Mostra ultimi log dal backend Railway filtrando per livello."""
47
+ import httpx as _hx
48
+ railway_url = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/")
49
+ await _tg_reply(chat_id,
50
+ f"πŸ“‹ <b>Log Railway</b> β€” <code>{level.upper()}</code>\n⏳ <i>Fetching…</i>")
51
+ try:
52
+ async with _hx.AsyncClient(timeout=10.0) as c:
53
+ r = await c.get(f"{railway_url}/api/telegram/logs",
54
+ params={"level": level.upper(), "n": 20})
55
+ data = r.json() if r.status_code == 200 else {}
56
+ except Exception as exc:
57
+ await _tg_reply(chat_id,
58
+ "❌ <b>Log non disponibili</b>\n<code>" + html.escape(str(exc)[:200]) + "</code>\n"
59
+ "<i>Controlla Railway dashboard.</i>", keyboard=_BACK_KB)
60
+ return
61
+ records = data.get("records", [])
62
+ if not records:
63
+ msg = ("βœ… <b>Nessun " + level.upper() + " nei log!</b>\n<i>Sistema stabile.</i>"
64
+ if level.upper() in ("WARNING","ERROR")
65
+ else "πŸ“‹ <b>Log vuoti</b> β€” nessun record disponibile")
66
+ await _tg_reply(chat_id, msg, keyboard=_BACK_KB)
67
+ return
68
+ import datetime as _dt
69
+ lines = [f"πŸ“‹ <b>Log</b> ({data.get('count',0)} rec β€” {level.upper()})\n"]
70
+ for rec in records[:15]:
71
+ ts = _dt.datetime.fromtimestamp(rec.get("ts",0), tz=_dt.timezone.utc).strftime("%H:%M:%S")
72
+ lvl = rec.get("level","?")
73
+ lgr = rec.get("logger","").split(".")[-1][:18]
74
+ msg = html.escape(str(rec.get("msg",""))[:100])
75
+ icon = "πŸ”΄" if lvl=="ERROR" else "🟑" if lvl=="WARNING" else "βšͺ"
76
+ lines.append(f"{icon} <code>{ts}</code> [{lgr}] {msg}")
77
+ await _tg_reply(chat_id, "\n".join(lines), keyboard=_BACK_KB)
78
+
79
+
80
+ async def _cmd_status(chat_id: int) -> None:
81
+ await _tg_typing(chat_id)
82
+ try:
83
+ from api.state import _agent_tasks, _loop_registry # noqa: F401
84
+ total = len(_agent_tasks)
85
+ running = sum(1 for t in _agent_tasks.values() if t.get("status") == "RUNNING")
86
+ success = sum(1 for t in _agent_tasks.values() if t.get("status") == "SUCCESS")
87
+ error = sum(1 for t in _agent_tasks.values() if t.get("status") == "ERROR")
88
+ queued = sum(1 for t in _agent_tasks.values() if t.get("status") == "QUEUED")
89
+
90
+ # Supabase fallback: se in-memory Γ¨ vuoto (restart backend) legge dal DB
91
+ sb_line = ""
92
+ if total == 0:
93
+ try:
94
+ from api.state import _sb
95
+ if _sb:
96
+ res = await asyncio.to_thread(
97
+ lambda: _sb.table("agent_tasks")
98
+ .select("status")
99
+ .order("created_at", desc=True)
100
+ .limit(50)
101
+ .execute()
102
+ )
103
+ rows = res.data or []
104
+ if rows:
105
+ db_run = sum(1 for r in rows if r.get("status") == "RUNNING")
106
+ db_done = sum(1 for r in rows if r.get("status") == "SUCCESS")
107
+ db_err = sum(1 for r in rows if r.get("status") == "ERROR")
108
+ sb_line = (
109
+ "\nπŸ“¦ <b>Supabase (ultimi 50):</b> "
110
+ + str(db_run) + " in corso / "
111
+ + str(db_done) + " ok / "
112
+ + str(db_err) + " err"
113
+ + " <i>(backend riavviato)</i>"
114
+ )
115
+ except Exception as _exc:
116
+ _logger.debug("[telegram_webhook] silenced %s", type(_exc).__name__) # noqa: BLE001
117
+
118
+ from api.scheduler import _tasks as sched_tasks, _loop_task
119
+ sched_ok = _loop_task is not None and not _loop_task.done()
120
+ sched_pending = sum(1 for t in sched_tasks.values() if t.get("status") == "pending")
121
+ sched_label = "βœ… attivo" if sched_ok else "❌ fermo"
122
+
123
+ ts_now = time.strftime("%Y-%m-%d %H:%M:%S")
124
+ railway_url = os.getenv("RAILWAY_URL","https://ai-production-4c06.up.railway.app")
125
+ ry_line = ""
126
+ try:
127
+ import httpx as _hx
128
+ async with _hx.AsyncClient(timeout=4.0) as c:
129
+ rv = await c.get(f"{railway_url}/api/info")
130
+ if rv.status_code == 200:
131
+ rj = rv.json()
132
+ ry_line = ("\nπŸš‚ <b>Railway:</b> v" + rj.get("version","?")
133
+ + " β€” " + rj.get("sprint",""))
134
+ except Exception:
135
+ pass
136
+
137
+ # ── NEW-1: HEAD git + ultimo commit ──────────────────────────────────
138
+ # Chiama GitHub API con GITHUB_TOKEN (Railway env) β€” timeout 4s, silent fail.
139
+ # Mostra: sha corto + prima riga commit message + etΓ  ("3h fa").
140
+ git_line = ""
141
+ try:
142
+ import httpx as _hx_g, datetime as _dt
143
+ _gh_token = os.getenv("GITHUB_TOKEN", "").strip()
144
+ _gh_repo = os.getenv("GITHUB_REPO", "Baida98/AI").strip()
145
+ if _gh_token and _gh_repo:
146
+ async with _hx_g.AsyncClient(timeout=4.0) as _gc:
147
+ _gr = await _gc.get(
148
+ f"https://api.github.com/repos/{_gh_repo}/commits/main",
149
+ headers={"Authorization": f"Bearer {_gh_token}", "User-Agent": "agente-ai"},
150
+ params={"per_page": 1},
151
+ )
152
+ if _gr.status_code == 200:
153
+ _cj = _gr.json()
154
+ _sha = (_cj.get("sha") or "")[:7]
155
+ _cmsg = ((_cj.get("commit") or {}).get("message") or "").split("\n")[0][:45]
156
+ _date = ((_cj.get("commit") or {}).get("committer") or {}).get("date", "")
157
+ _age = ""
158
+ if _date:
159
+ _ts = _dt.datetime.fromisoformat(_date.replace("Z", "+00:00"))
160
+ _secs = int((_dt.datetime.now(_dt.timezone.utc) - _ts).total_seconds())
161
+ if _secs < 3600: _age = f"{_secs // 60}m fa"
162
+ elif _secs < 86400: _age = f"{_secs // 3600}h fa"
163
+ else: _age = f"{_secs // 86400}g fa"
164
+ git_line = (
165
+ f"\nπŸ”€ <b>HEAD:</b> <code>{html.escape(_sha)}</code>"
166
+ f" {html.escape(_cmsg)} <i>({_age})</i>"
167
+ )
168
+ except Exception:
169
+ pass
170
+
171
+ # ── NEW-2: task live β€” goal + step corrente dal loop_registry ────────
172
+ # Per il primo task RUNNING: mostra goal + azione corrente (dal buffer SSE)
173
+ # + tempo trascorso. Zero overhead se non c'Γ¨ task in corso.
174
+ live_line = ""
175
+ try:
176
+ _running_list = [t for t in _agent_tasks.values() if t.get("status") == "RUNNING"]
177
+ if _running_list:
178
+ import json as _lj
179
+ _rt = _running_list[0]
180
+ _tid = _rt.get("id") or _rt.get("task_id") or ""
181
+ _goal_s = html.escape((_rt.get("goal") or "")[:38])
182
+ _elapsed = ""
183
+ _ca = _rt.get("created_at", 0)
184
+ if isinstance(_ca, int) and _ca > 0:
185
+ _es = int(time.time() * 1000 - _ca) // 1000
186
+ _elapsed = f" Β· {_es // 60}m{_es % 60:02d}s" if _es >= 60 else f" Β· {_es}s"
187
+ # Legge ultimo evento SSE dal buffer (action/type corrente)
188
+ _act = ""
189
+ _ebuf = (_loop_registry.get(_tid) or {}).get("event_buffer", [])
190
+ for _ev in reversed(_ebuf[-30:]):
191
+ try:
192
+ _raw = _ev[6:] if _ev.startswith("data: ") else _ev
193
+ _ed = _lj.loads(_raw)
194
+ _a = _ed.get("action") or _ed.get("type") or ""
195
+ if _a and _a not in ("ping", "connected", "keepalive"):
196
+ _act = f" β†’ <code>{html.escape(str(_a)[:20])}</code>"
197
+ break
198
+ except Exception:
199
+ pass
200
+ live_line = f"\nβš™οΈ <b>Live:</b> {_goal_s}{_act}{_elapsed}"
201
+ except Exception:
202
+ pass
203
+
204
+ # ── NEW-3: coord mini β€” sessioni agent-coord attive ──────────────────
205
+ # 1 riga: chi sta lavorando, su quale sprint, su quali file.
206
+ # Timeout aggressivo 3s β€” /status deve essere veloce.
207
+ coord_line = ""
208
+ try:
209
+ import httpx as _hx_c2, json as _jc2, time as _tc2
210
+ _SB_URL2 = os.getenv("SUPABASE_URL", "").rstrip("/")
211
+ _SB_KEY2 = os.getenv("SUPABASE_KEY", "")
212
+ if _SB_KEY2:
213
+ async with _hx_c2.AsyncClient(timeout=3.0) as _cc:
214
+ _cr = await _cc.get(
215
+ f"{_SB_URL2}/rest/v1/agent_tasks",
216
+ params={"goal": "ilike.*__session__*", "select": "context"},
217
+ headers={"apikey": _SB_KEY2, "Authorization": f"Bearer {_SB_KEY2}"},
218
+ )
219
+ if _cr.is_success:
220
+ _now_ms = int(_tc2.time() * 1000)
221
+ _active = []
222
+ for _row in (_cr.json() or []):
223
+ try:
224
+ _ctx = _jc2.loads(_row.get("context") or "{}")
225
+ if _now_ms - int(_ctx.get("lastHeartbeat", 0)) < 300_000:
226
+ _active.append(_ctx)
227
+ except Exception:
228
+ pass
229
+ if _active:
230
+ _s = _active[0]
231
+ _files = [f.split("/")[-1] for f in _s.get("claimedFiles", [])]
232
+ _fstr = ", ".join(_files[:3]) or "β€”"
233
+ _extra = f" (+{len(_active)-1})" if len(_active) > 1 else ""
234
+ coord_line = (
235
+ f"\nπŸ”— <b>Coord:</b> {html.escape(_s.get('sessionName','?'))}"
236
+ f" [{html.escape(_s.get('sprint','β€”'))}]"
237
+ f" Β· <code>{html.escape(_fstr)}</code>{_extra}"
238
+ )
239
+ else:
240
+ coord_line = "\nπŸ”— <b>Coord:</b> <i>nessuna sessione attiva</i>"
241
+ except Exception:
242
+ pass
243
+
244
+ # ── Icona salute sistema ──────────────────────────────────────────────
245
+ if running > 0:
246
+ _sys_icon, _sys_label = "βš™οΈ", f"{running} task in esecuzione"
247
+ elif error > 0 and success == 0 and total > 0:
248
+ _sys_icon, _sys_label = "πŸ”΄", "ultimi task terminati con errore"
249
+ elif total == 0:
250
+ _sys_icon, _sys_label = "πŸ’€", "nessun task recente"
251
+ else:
252
+ _sys_icon, _sys_label = "βœ…", "tutto operativo"
253
+
254
+ parts = [f"πŸ“Š <b>Sistema</b> {_sys_icon} β€” <i>{_sys_label}</i>", ""]
255
+ if running or queued:
256
+ parts.append(f"βš™οΈ <b>In esecuzione:</b> {running} Β· <b>In coda:</b> {queued}")
257
+ if success or error or total:
258
+ parts.append(f"βœ… Completati: {success} Β· ❌ Errori: {error} Β· Totale: {total}")
259
+ for _extra_line in [sb_line, ry_line, git_line, live_line, coord_line]:
260
+ if _extra_line:
261
+ parts.append(_extra_line)
262
+ parts += [
263
+ "",
264
+ f"πŸ—“ <b>Scheduler:</b> {sched_label}" + (f" Β· {sched_pending} in coda" if sched_pending else ""),
265
+ "",
266
+ f"<i>πŸ• {ts_now} UTC</i>",
267
+ ]
268
+ await _tg_reply(chat_id, "\n".join(p for p in parts if p is not None),
269
+ keyboard=_MAIN_KB)
270
+ except Exception as exc:
271
+ await _tg_reply(chat_id, "⚠️ Errore lettura stato: " + html.escape(str(exc)[:200]))
272
+
273
+
274
+ async def _cmd_commit_summary(chat_id: int) -> None:
275
+ """Riepilogo humanizzato degli ultimi commit via GitHub + AI leggera."""
276
+ import json as _json
277
+ GH_API = "https://api.github.com/repos/Baida98/AI/commits?per_page=8"
278
+ SKIP_RE = re.compile(r"^(πŸ”’|πŸ”“|acquire push lock|release push lock)", re.I)
279
+ try:
280
+ import httpx
281
+ async with httpx.AsyncClient(timeout=10) as cli:
282
+ r = await cli.get(GH_API, headers={"Authorization": f"Bearer {_gh_token}",
283
+ "Accept": "application/vnd.github+json"})
284
+ commits = r.json() if r.status_code == 200 else []
285
+ except Exception:
286
+ commits = []
287
+
288
+ # Filtra commit di lock/chore puro
289
+ commits = [c for c in commits
290
+ if not SKIP_RE.match((c.get("commit", {}).get("message") or "").split("\n")[0])][:6]
291
+
292
+ if not commits:
293
+ await _tg_reply(chat_id, "πŸ“­ Nessun commit recente trovato.", keyboard=_BACK_KB)
294
+ return
295
+
296
+ # Costruisci breve sintesi statica (no AI) con traduzione tipo
297
+ _TYPE_IT = {
298
+ "feat": "Nuova funzione", "fix": "Correzione bug", "docs": "Documentazione",
299
+ "refactor": "Refactor", "chore": "Manutenzione", "test": "Test",
300
+ "perf": "Performance", "ux": "Esperienza utente", "style": "Stile",
301
+ "ci": "CI/CD", "build": "Build",
302
+ }
303
+ _TYPE_ICON = {
304
+ "feat": "✨", "fix": "πŸ”§", "docs": "πŸ“„", "refactor": "♻️",
305
+ "chore": "πŸ”©", "test": "πŸ§ͺ", "perf": "⚑", "ux": "🎨",
306
+ "ci": "βš™οΈ", "build": "πŸ“¦",
307
+ }
308
+ lines = ["πŸ”€ <b>Ultimi commit</b>\n"]
309
+ for c in commits:
310
+ raw_msg = (c.get("commit", {}).get("message") or "").split("\n")[0]
311
+ sha = (c.get("sha") or "")[:7]
312
+ date = (c.get("commit", {}).get("author", {}).get("date") or "")
313
+ time_s = date[11:16] if len(date) >= 16 else "??:??"
314
+ # Estrai tipo e corpo
315
+ m = re.match(r"^(feat|fix|docs|refactor|chore|test|perf|ux|style|ci|build)(?:\([^)]+\))?:\s*(.+)$", raw_msg)
316
+ if m:
317
+ tipo, corpo = m.group(1), m.group(2)
318
+ icon = _TYPE_ICON.get(tipo, "πŸ“Œ")
319
+ tipo_it = _TYPE_IT.get(tipo, tipo)
320
+ # Humanizza la descrizione: rimuovi jargon tecnico comune
321
+ corpo_h = corpo.replace("_", " ").replace("-", " ")
322
+ corpo_h = re.sub(r"(impl|add|implement|update|refactor|fix|use|remove|clean)", "", corpo_h, flags=re.I).strip()
323
+ corpo_h = corpo_h[:60] or corpo[:60]
324
+ lines.append(f"{icon} <b>{tipo_it}</b> β€” {html.escape(corpo_h)}\n <i>{time_s} Β· <code>{sha}</code></i>")
325
+ else:
326
+ lines.append(f"πŸ“Œ {html.escape(raw_msg[:65])}\n <i>{time_s} Β· <code>{sha}</code></i>")
327
+
328
+ await _tg_reply(
329
+ chat_id,
330
+ "\n\n".join(lines),
331
+ keyboard={
332
+ "inline_keyboard": [
333
+ [{"text": "πŸ”„ Aggiorna", "callback_data": "qp_commits"},
334
+ {"text": "πŸš€ Nuovo Task", "callback_data": "agent"}],
335
+ [{"text": "🏠 Menu", "callback_data": "tgw_help"}],
336
+ ]
337
+ }
338
+ )
339
+
340
+
341
+ async def _cmd_check(chat_id: int) -> None:
342
+ """πŸ” Health check live: Railway, HF Space A+B, versione vs GH HEAD."""
343
+ import time as _time
344
+ await _tg_typing(chat_id)
345
+ await _tg_reply(chat_id, "πŸ” <b>Check infrastruttura...</b>", keyboard=None)
346
+
347
+ async def _probe(label: str, url: str, timeout: int = 7) -> str:
348
+ t0 = _time.monotonic()
349
+ try:
350
+ async with httpx.AsyncClient(timeout=timeout) as cli:
351
+ r = await cli.get(url)
352
+ ms = round((_time.monotonic() - t0) * 1000)
353
+ icon = "βœ…" if r.status_code == 200 else "⚠️"
354
+ return f"{icon} <b>{label}</b> β€” <code>{r.status_code}</code> <i>{ms}ms</i>"
355
+ except Exception as exc:
356
+ ms = round((_time.monotonic() - t0) * 1000)
357
+ return f"❌ <b>{label}</b> β€” <code>{str(exc)[:55]}</code> <i>{ms}ms</i>"
358
+
359
+ probes = await asyncio.gather(
360
+ _probe("Railway backend", "https://ai-production-4c06.up.railway.app/health"),
361
+ _probe("HF Space A", "https://arjanit98-terminal.hf.space/api/version"),
362
+ _probe("HF Space B", "https://baida00-ai-backend-collab.hf.space/api/version"),
363
+ )
364
+
365
+ # GH HEAD vs HF Space version
366
+ gh_sha, hf_build = "?", "?"
367
+ try:
368
+ async with httpx.AsyncClient(timeout=5) as cli:
369
+ ref_r = await cli.get(
370
+ "https://api.github.com/repos/Baida98/AI/git/refs/heads/main",
371
+ headers={"Authorization": f"Bearer {_gh_token}", "Accept": "application/vnd.github+json"},
372
+ )
373
+ if ref_r.status_code == 200:
374
+ gh_sha = (ref_r.json().get("object", {}).get("sha") or "?")[:10]
375
+ except Exception:
376
+ pass
377
+ try:
378
+ async with httpx.AsyncClient(timeout=5) as cli:
379
+ ver_r = await cli.get("https://arjanit98-terminal.hf.space/api/version")
380
+ if ver_r.status_code == 200:
381
+ d = ver_r.json()
382
+ hf_build = f"{d.get('version','?')} ({d.get('build_date','?')})"
383
+ except Exception:
384
+ pass
385
+
386
+ sync_icon = "βœ…" if gh_sha != "?" and gh_sha[:8] in hf_build else "⚠️"
387
+ lines_out = ["πŸ” <b>Infrastructure Check</b>"]
388
+ lines_out.extend(probes)
389
+ lines_out.append(f"<code>GH HEAD: {gh_sha}</code>")
390
+ lines_out.append(f"<code>HF build: {hf_build}</code>")
391
+ lines_out.append(f"{sync_icon} <i>GH↔HF {'in sync' if sync_icon=='βœ…' else 'OUT OF SYNC β€” usa πŸ”„ Sync HF'}</i>")
392
+ await _tg_reply(chat_id, "\n".join(lines_out), keyboard=_BACK_KB)
393
+
394
+
395
+ async def _cmd_tasks(chat_id: int) -> None:
396
+ await _tg_typing(chat_id)
397
+ STATUS_EMOJI = {
398
+ "SUCCESS": "βœ…", "ERROR": "❌", "RUNNING": "βš™οΈ",
399
+ "QUEUED": "⏳", "CANCELLED": "🚫",
400
+ }
401
+ try:
402
+ from api.state import _agent_tasks
403
+ mem_tasks = sorted(
404
+ _agent_tasks.values(),
405
+ key=lambda t: t.get("created_at", 0),
406
+ reverse=True,
407
+ )[:8]
408
+
409
+ # Supabase fallback se memoria vuota
410
+ if not mem_tasks:
411
+ try:
412
+ from api.state import _sb
413
+ if _sb:
414
+ res = await asyncio.to_thread(
415
+ lambda: _sb.table("agent_tasks")
416
+ .select("task_id,goal,status,created_at")
417
+ .order("created_at", desc=True)
418
+ .limit(8)
419
+ .execute()
420
+ )
421
+ rows = res.data or []
422
+ if rows:
423
+ lines = ["πŸ“‹ <b>Ultimi task (Supabase)</b> <i>⚠️ backend riavviato</i>\n"]
424
+ for r in rows:
425
+ em = STATUS_EMOJI.get(r.get("status", ""), "β€’")
426
+ gol = html.escape((r.get("goal") or "")[:55])
427
+ tid = html.escape(str(r.get("task_id") or "")[:8])
428
+ ts = r.get("created_at", 0)
429
+ age = _fmt_elapsed(ts) if isinstance(ts, int) and ts > 1_000_000 else "?"
430
+ lines.append(em + " <code>" + tid + "</code> " + gol + " <i>" + age + "</i>")
431
+ return await _tg_reply(chat_id, "\n".join(lines))
432
+ except Exception as _exc:
433
+ _logger.debug("[telegram_webhook] silenced %s", type(_exc).__name__) # noqa: BLE001
434
+ return await _tg_reply(chat_id, "πŸ“‹ Nessun task recente in memoria.")
435
+
436
+ _ST_LABEL = {
437
+ "SUCCESS": "Fatto", "ERROR": "Fallito ⚠️", "RUNNING": "In corso…",
438
+ "QUEUED": "In coda", "CANCELLED": "Annullato",
439
+ }
440
+ _ST_FLAIR = {
441
+ "SUCCESS": "βœ…", "ERROR": "❌", "RUNNING": "βš™οΈ",
442
+ "QUEUED": "⏳", "CANCELLED": "🚫",
443
+ }
444
+ lines = ["<b>Cosa ha fatto l'agente:</b>\n"]
445
+ for t in mem_tasks:
446
+ st = t.get("status", "?")
447
+ em = _ST_FLAIR.get(st, "β€’")
448
+ gol = html.escape((t.get("goal") or "β€”")[:70])
449
+ ca = t.get("created_at", 0)
450
+ age = _fmt_elapsed(ca) if isinstance(ca, int) and ca > 1_000_000 else "poco fa"
451
+ lbl = _ST_LABEL.get(st, st)
452
+ lines.append(f"{em} {gol}\n <i>{lbl} Β· {age}</i>")
453
+ await _tg_reply(chat_id, "\n\n".join(lines), keyboard=_BACK_KB)
454
+ except Exception as exc:
455
+ await _tg_reply(chat_id, "⚠️ " + html.escape(str(exc)[:200]))
456
+
457
+
api/telegram_keyboards.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/telegram_keyboards.py β€” Inline keyboards, menu e costanti UI Telegram.
2
+
3
+ Costanti:
4
+ _QUICK_PICK_KB β€” quick-pick task templates
5
+ _MAIN_KB β€” tastiera principale (reply keyboard)
6
+ _LAST_GOAL β€” dict chat_id β†’ ultimo goal (per retry)
7
+ _BENCH_CACHE β€” cache benchmark
8
+
9
+ Funzioni:
10
+ _after_task_kb(chat_id) β€” keyboard post-task con retry
11
+ """
12
+ from __future__ import annotations
13
+ # ── Quick-pick task templates (MX-QUICKPICK) ──────────────────────────────────
14
+ _QUICK_PICK_KB = {
15
+ "inline_keyboard": [
16
+ [{"text": "πŸ” Analizza bug", "callback_data": "qp_bug"},
17
+ {"text": "⚑ Ottimizza DB", "callback_data": "qp_db"}],
18
+ [{"text": "πŸ”§ AutoFix log", "callback_data": "qp_autofix"},
19
+ {"text": "πŸ”€ Riassumi commit", "callback_data": "qp_commits"}],
20
+ [{"text": "πŸ“ Genera docs", "callback_data": "qp_docs"},
21
+ {"text": "πŸ§ͺ Genera test", "callback_data": "qp_tests"}],
22
+ [{"text": "✍️ Scrivi obiettivo...", "callback_data": "qp_custom"}],
23
+ [{"text": "🏠 Menu", "callback_data": "tgw_help"}],
24
+ ]
25
+ }
26
+
27
+ # ── After-task keyboard (retry + navigazione) ─────────────────────────────────
28
+ # Ultimo goal per chat_id β€” usato da πŸ” Rifai
29
+ _LAST_GOAL: dict[int, str] = {}
30
+
31
+ def _after_task_kb(chat_id: int) -> dict:
32
+ """Keyboard mostrata dopo ogni task completato."""
33
+ return {
34
+ "inline_keyboard": [
35
+ [{"text": "πŸ” Rifai", "callback_data": "tgw_retry"},
36
+ {"text": "πŸ“‹ AttivitΓ ", "callback_data": "tgw_tasks"}],
37
+ [{"text": "πŸš€ Nuovo Task", "callback_data": "agent"},
38
+ {"text": "🏠 Menu", "callback_data": "tgw_help"}],
39
+ ]
40
+ }
41
+
42
+
43
+
44
+ # ── Main reply keyboard ─────────────────────────────────────────────────────
45
+ _MAIN_KB = {
46
+ "inline_keyboard": [
47
+ [{"text": "πŸš€ Nuovo Task", "callback_data": "agent"},
48
+ {"text": "πŸ“‹ AttivitΓ ", "callback_data": "tgw_tasks"}],
49
+ [{"text": "🩺 Salute", "callback_data": "tgw_health"},
50
+ {"text": "πŸ”§ AutoFix", "callback_data": "tgw_autofix"}],
51
+ [{"text": "πŸ’¬ Chiedi all'AI","callback_data": "tgw_ask"},
52
+ {"text": "πŸ“ˆ Stato", "callback_data": "tgw_status"}],
53
+ [{"text": "🌐 Dashboard β†’", "url": "https://agente-ai.pages.dev"}],
54
+ ]
55
+ }
56
+
57
+ # ── Bench cache + keyboard (GAP-TGB) ─────────────────────────────────────────
58
+ # Salva l'ultimo run bench per chat_id β†’ usato dai callback tgw_bench_fix/run
59
+ _BENCH_CACHE: dict[int, dict] = {}
60
+
61
+ _BENCH_ACTION_KB = {
62
+ "inline_keyboard": [
63
+ [{"text": "πŸ”§ Applica Fix", "callback_data": "tgw_bench_fix"},
64
+ {"text": "πŸ”„ Riesegui", "callback_data": "tgw_bench_run"}],
65
+ [{"text": "βš™οΈ Migliora", "callback_data": "tgw_improve"}],
66
+ [{"text": "🏠 Menu", "callback_data": "tgw_help"}],
67
+ ]
68
+ }
69
+
70
+ # ── ReplyKeyboardRemove β€” rimuove tastiera persistente da versioni precedenti ──
71
+ _REPLY_KB_REMOVE = {"remove_keyboard": True}
72
+
73
+ # ── Sub-menu inline keyboards ─────────────────────────────────────────────────
74
+ _TASK_MENU_KB = {
75
+ "inline_keyboard": [
76
+ [{"text": "πŸ€– Nuovo Task", "callback_data": "tgw_do"},
77
+ {"text": "πŸ”§ AutoFix", "callback_data": "tgw_autofix"}],
78
+ [{"text": "βš™οΈ Migliora AI", "callback_data": "tgw_improve"},
79
+ {"text": "πŸ“‹ Task recenti", "callback_data": "tgw_tasks"}],
80
+ [{"text": "🧠 Briefing", "callback_data": "tgw_briefing"},
81
+ {"text": "πŸ“ Salva Nota", "callback_data": "tgw_nota"}],
82
+ [{"text": "πŸ” Cerca web", "callback_data": "tgw_cerca"},
83
+ {"text": "🌀 Meteo", "callback_data": "tgw_meteo"}],
84
+ ]
85
+ }
86
+ _STATUS_MENU_KB = {
87
+ "inline_keyboard": [
88
+ [{"text": "πŸ“Š Daemon+Task", "callback_data": "tgw_status"},
89
+ {"text": "πŸ”Œ Provider AI", "callback_data": "tgw_providers"}],
90
+ [{"text": "πŸ”— Coord sessioni", "callback_data": "tgw_coord"},
91
+ {"text": "πŸ”€ Git log", "callback_data": "tgw_git"}],
92
+ [{"text": "🌐 Dashboard", "url": "https://agente-ai.pages.dev"}],
93
+ ]
94
+ }
95
+ _PERF_MENU_KB = {
96
+ "inline_keyboard": [
97
+ [{"text": "πŸ“Š Benchmark", "callback_data": "tgw_bench"},
98
+ {"text": "πŸ† Score", "callback_data": "tgw_score"}],
99
+ [{"text": "πŸ“‘ Telemetria", "callback_data": "tgw_telemetry"},
100
+ {"text": "βš™οΈ Migliora", "callback_data": "tgw_improve"}],
101
+ [{"text": "πŸ”§ Fix gap bench", "callback_data": "tgw_bench_fix"}],
102
+ ]
103
+ }
104
+ _HEALTH_MENU_KB = {
105
+ "inline_keyboard": [
106
+ [{"text": "πŸ” Scan completo", "callback_data": "tgw_health"},
107
+ {"text": "πŸ“ Log errori", "callback_data": "tgw_logs"}],
108
+ [{"text": "πŸ“Š Status", "callback_data": "tgw_status"},
109
+ {"text": "πŸ”Œ Provider AI", "callback_data": "tgw_providers"}],
110
+ ]
111
+ }
112
+ _DEV_MENU_KB = {
113
+ "inline_keyboard": [
114
+ [{"text": "πŸ“Έ Snapshot", "callback_data": "tgw_snap"},
115
+ {"text": "βœ… Verify", "callback_data": "tgw_verify"}],
116
+ [{"text": "πŸ’Š Heal", "callback_data": "tgw_heal"},
117
+ {"text": "πŸ“ Log", "callback_data": "tgw_logs"}],
118
+ [{"text": "πŸ”€ Git commits", "callback_data": "tgw_git"},
119
+ {"text": "πŸ“ Ping", "callback_data": "tgw_ping"}],
120
+ ]
121
+ }
122
+
api/telegram_tg_client.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/telegram_tg_client.py β€” Telegram Bot API helpers.
2
+
3
+ Livello piΓΉ basso dell'integrazione Telegram: funzioni pure per inviare
4
+ messaggi, modificarli, reazioni, typing indicator, foto.
5
+ Non ha dipendenze da altri moduli interni β€” importabile ovunque.
6
+
7
+ Esportati:
8
+ _get_bot_token, _log_tg_exc, _fmt_elapsed
9
+ _tg_reply, _tg_send, _tg_edit, _tg_photo, _tg_typing, _tg_react, _tg_answer_callback
10
+ """
11
+ from __future__ import annotations
12
+ import asyncio, html, logging, os, time
13
+ import httpx # top-level β€” era lazy in 20+ funzioni
14
+
15
+ _logger = logging.getLogger("api.telegram_webhook") # unico logger (duplicato rimosso)
16
+
17
+
18
+ def _log_tg_exc(task: "asyncio.Task[None]") -> None:
19
+ """Gap-2.6: log exceptions from fire-and-forget tasks."""
20
+ try:
21
+ exc = task.exception()
22
+ if exc:
23
+ _logger.warning("tg_webhook bg task error: %s: %s", type(exc).__name__, exc)
24
+ except (asyncio.CancelledError, asyncio.InvalidStateError):
25
+ pass
26
+
27
+
28
+
29
+ # ── Helpers ───────────────────────────────────────────────────────────────────
30
+
31
+ def _get_bot_token() -> str:
32
+ return os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
33
+
34
+
35
+ async def _tg_reply(chat_id: str | int, text: str, token: str | None = None,
36
+ keyboard: dict | None = None) -> None:
37
+ """Invia risposta al chat_id con HTML + opzionale inline keyboard."""
38
+ bot_token = token or _get_bot_token()
39
+ if not bot_token:
40
+ return
41
+ payload: dict = {
42
+ "chat_id": chat_id,
43
+ "text": text,
44
+ "parse_mode": "HTML",
45
+ "link_preview_options": {"is_disabled": True},
46
+ }
47
+ if keyboard:
48
+ payload["reply_markup"] = keyboard
49
+ try:
50
+ import httpx
51
+ async with httpx.AsyncClient(timeout=8.0) as c:
52
+ await c.post(
53
+ f"https://api.telegram.org/bot{bot_token}/sendMessage",
54
+ json=payload,
55
+ )
56
+ except Exception as exc:
57
+ _logger.warning("tg_reply error: %s", exc)
58
+
59
+
60
+ async def _tg_answer_callback(callback_query_id: str, text: str = "", token: str | None = None) -> None:
61
+ """Risponde a un callback_query (obbligatorio per chiudere il loading sui buttons)."""
62
+ bot_token = token or _get_bot_token()
63
+ if not bot_token:
64
+ return
65
+ try:
66
+ import httpx
67
+ async with httpx.AsyncClient(timeout=5.0) as c:
68
+ await c.post(
69
+ f"https://api.telegram.org/bot{bot_token}/answerCallbackQuery",
70
+ json={"callback_query_id": callback_query_id, "text": text, "show_alert": False},
71
+ )
72
+ except Exception as exc:
73
+ _logger.debug("answer_callback error: %s", exc)
74
+
75
+
76
+ async def _tg_send(chat_id: str | int, text: str, token: str | None = None,
77
+ keyboard: dict | None = None) -> str | None:
78
+ """Invia messaggio e ritorna il message_id (per editMessageText streaming)."""
79
+ bot_token = token or _get_bot_token()
80
+ if not bot_token:
81
+ return None
82
+ payload: dict = {
83
+ "chat_id": chat_id,
84
+ "text": text,
85
+ "parse_mode": "HTML",
86
+ "link_preview_options": {"is_disabled": True},
87
+ }
88
+ if keyboard:
89
+ payload["reply_markup"] = keyboard
90
+ try:
91
+ import httpx
92
+ async with httpx.AsyncClient(timeout=8.0) as c:
93
+ r = await c.post(
94
+ f"https://api.telegram.org/bot{bot_token}/sendMessage",
95
+ json=payload,
96
+ )
97
+ j = r.json()
98
+ return str(j.get("result", {}).get("message_id", "")) if j.get("ok") else None
99
+ except Exception as exc:
100
+ _logger.warning("tg_send error: %s", exc)
101
+ return None
102
+
103
+
104
+ async def _tg_edit(chat_id: str | int, message_id: str, text: str,
105
+ token: str | None = None, keyboard: dict | None = None) -> bool:
106
+ """Aggiorna messaggio esistente β€” streaming live via editMessageText.
107
+ Ritorna True se successo. Rate-limit: max 20 edit/min per chat Telegram."""
108
+ bot_token = token or _get_bot_token()
109
+ if not bot_token or not message_id:
110
+ return False
111
+ payload: dict = {
112
+ "chat_id": chat_id,
113
+ "message_id": int(message_id),
114
+ "text": text[:4000],
115
+ "parse_mode": "HTML",
116
+ "link_preview_options": {"is_disabled": True},
117
+ }
118
+ if keyboard:
119
+ payload["reply_markup"] = keyboard
120
+ try:
121
+ import httpx
122
+ async with httpx.AsyncClient(timeout=8.0) as c:
123
+ r = await c.post(
124
+ f"https://api.telegram.org/bot{bot_token}/editMessageText",
125
+ json=payload,
126
+ )
127
+ return r.json().get("ok", False)
128
+ except Exception as exc:
129
+ _logger.debug("tg_edit error: %s", exc)
130
+ return False
131
+
132
+
133
+ async def _tg_photo(
134
+ chat_id: str | int,
135
+ photo_url: str,
136
+ caption: str = "",
137
+ token: str | None = None,
138
+ keyboard: dict | None = None,
139
+ ) -> None:
140
+ """Invia foto/chart via sendPhoto Telegram.
141
+
142
+ Strategia anti URL-lungo:
143
+ 1. POST a quickchart.io β†’ scarica PNG bytes β†’ multipart sendPhoto (no limite URL).
144
+ 2. Fallback: invia URL direttamente (funziona se URL < ~2000 chars).
145
+ """
146
+ bot_token = token or _get_bot_token()
147
+ if not bot_token:
148
+ return
149
+ caption_safe = (caption or "")[:1024]
150
+
151
+ import httpx as _hx_p, json as _j_p, urllib.parse as _ul_p, re as _re_p
152
+
153
+ png_bytes: bytes | None = None
154
+ if "quickchart.io/chart" in photo_url:
155
+ try:
156
+ m = _re_p.search(r"[?&]c=([^&]+)", photo_url)
157
+ if m:
158
+ cfg_dict = _j_p.loads(_ul_p.unquote(m.group(1)))
159
+ async with _hx_p.AsyncClient(timeout=20.0) as c:
160
+ qr = await c.post(
161
+ "https://quickchart.io/chart",
162
+ json={"chart": cfg_dict, "width": 720, "height": 420,
163
+ "backgroundColor": "white", "format": "png"},
164
+ )
165
+ if qr.status_code == 200 and qr.headers.get("content-type", "").startswith("image/"):
166
+ png_bytes = qr.content
167
+ _logger.debug("tg_photo: quickchart POST ok, %d bytes", len(png_bytes))
168
+ except Exception as exc:
169
+ _logger.debug("tg_photo: quickchart POST fallback: %s", exc)
170
+
171
+ try:
172
+ import httpx as _hx_s
173
+ async with _hx_s.AsyncClient(timeout=15.0) as c:
174
+ if png_bytes:
175
+ import json as _j_s
176
+ data: dict = {"chat_id": str(chat_id), "parse_mode": "HTML"}
177
+ if caption_safe:
178
+ data["caption"] = caption_safe
179
+ if keyboard:
180
+ data["reply_markup"] = _j_s.dumps(keyboard)
181
+ files = {"photo": ("chart.png", png_bytes, "image/png")}
182
+ await c.post(f"https://api.telegram.org/bot{bot_token}/sendPhoto",
183
+ data=data, files=files)
184
+ else:
185
+ payload: dict = {"chat_id": chat_id, "photo": photo_url, "parse_mode": "HTML"}
186
+ if caption_safe:
187
+ payload["caption"] = caption_safe
188
+ if keyboard:
189
+ payload["reply_markup"] = keyboard
190
+ await c.post(f"https://api.telegram.org/bot{bot_token}/sendPhoto", json=payload)
191
+ except Exception as exc:
192
+ _logger.warning("tg_photo error: %s", exc)
193
+
194
+
195
+ async def _tg_typing(chat_id: str | int, action: str = "typing", token: str | None = None) -> None:
196
+ """Invia sendChatAction β€” mostra '⌨️ digitando…' prima di operazioni pesanti.
197
+
198
+ Dura 5 secondi o fino al prossimo messaggio del bot.
199
+ Azioni: typing, upload_photo, upload_document, find_location, record_video_note.
200
+ """
201
+ bot_token = token or _get_bot_token()
202
+ if not bot_token:
203
+ return
204
+ try:
205
+ async with httpx.AsyncClient(timeout=3.0) as c:
206
+ await c.post(
207
+ f"https://api.telegram.org/bot{bot_token}/sendChatAction",
208
+ json={"chat_id": chat_id, "action": action},
209
+ )
210
+ except Exception:
211
+ pass
212
+
213
+
214
+ async def _tg_react(
215
+ chat_id: str | int,
216
+ message_id: int | str,
217
+ emoji: str = "πŸ‘",
218
+ token: str | None = None,
219
+ ) -> None:
220
+ """Aggiunge reazione emoji a un messaggio (Bot API 7.1+, Feb 2024).
221
+
222
+ Emoji supportate: πŸ‘ πŸ‘Ž ❀ πŸ”₯ πŸ₯° πŸ‘ 😁 πŸ€” 🀯 😱 πŸŽ‰ 🀩 πŸ† βœ… πŸ’― ⚑ πŸš€ 🎯
223
+ """
224
+ bot_token = token or _get_bot_token()
225
+ if not bot_token or not message_id:
226
+ return
227
+ try:
228
+ async with httpx.AsyncClient(timeout=3.0) as c:
229
+ await c.post(
230
+ f"https://api.telegram.org/bot{bot_token}/setMessageReaction",
231
+ json={
232
+ "chat_id": chat_id,
233
+ "message_id": int(message_id),
234
+ "reaction": [{"type": "emoji", "emoji": emoji}],
235
+ "is_big": False,
236
+ },
237
+ )
238
+ except Exception:
239
+ pass
240
+
241
+
242
+ def _fmt_elapsed(created_at_ms: int) -> str:
243
+ """Formatta elapsed time da un timestamp ms β†’ stringa leggibile."""
244
+ diff = int(time.time() * 1000) - created_at_ms
245
+ s = diff // 1000
246
+ if s < 60:
247
+ return f"{s}s fa"
248
+ if s < 3600:
249
+ return f"{s // 60}m{s % 60:02d}s fa"
250
+ return f"{s // 3600}h{(s % 3600) // 60:02d}m fa"
251
+
252
+ _WEBAPP_KB = {
253
+ "inline_keyboard": [
254
+ [{"text": "πŸš€ Apri Dashboard", "web_app": {"url": "https://agente-ai.pages.dev"}}],
255
+ [{"text": "🏠 Menu", "callback_data": "tgw_help"}],
256
+ ]
257
+ }
258
+ _BACK_KB = {
259
+ "inline_keyboard": [
260
+ [{"text": "🏠 Menu", "callback_data": "tgw_help"},
261
+ {"text": "πŸ“Š Stato", "callback_data": "tgw_status"}],
262
+ ]
263
+ }
264
+
api/telegram_webhook.py CHANGED
The diff for this file is too large to render. See raw diff
 
memory/distiller.py CHANGED
@@ -1,68 +1,123 @@
1
  """
2
- distiller.py Ҁ” S-SESTO-SENSO-V2: Memory Cognitive Compressive.
3
  Sintetizza i log grezzi delle sessioni in lezioni apprese e concetti chiave.
4
- Riduce il rumore e ottimizza l'uso dei token per la memoria a lungo termine.
 
 
 
 
5
  """
6
  import logging
7
  import json
8
- from typing import List, Dict, Any
 
9
  from datetime import datetime, timezone
10
 
11
  _logger = logging.getLogger("agente_ai.memory.distiller")
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  class MemoryDistiller:
14
  def __init__(self, ai_client=None):
15
  self.ai_client = ai_client
16
 
17
  async def distill_session(self, messages: List[Dict[str, Any]], goal: str) -> Dict[str, Any]:
18
  """
19
- Analizza una sequenza di messaggi e ne estrae il succo cognitivo.
 
20
  """
21
  if not messages:
22
  return {}
23
 
24
- # 1. Pre-processing: Rimuove i contenuti troppo pesanti (es. dump di file)
25
  cleaned_msgs = []
26
  for msg in messages:
27
  content = msg.get("content", "")
28
  if len(content) > 2000:
29
- content = content[:1000] + "... [TRUNCATED FOR DISTILLATION] ..." + content[-500:]
30
- cleaned_msgs.append({"role": msg.get("role"), "content": content})
31
 
32
- # 2. Prompt per la distillazione (da usare con l'AI client)
33
- distill_prompt = f"""
34
- Sei un Memory Distiller per un agente AI avanzato.
35
- Analizza la seguente sessione di lavoro e sintetizzala in modo ultra-compresso.
36
-
37
- OBIETTIVO ORIGINALE: {goal}
38
-
39
- ESTRAI:
40
- 1. **Lezioni Imparate**: Errori fatti e come sono stati risolti.
41
- 2. **Pattern Rilevati**: Comportamenti ricorrenti o architetture fragili identificate.
42
- 3. **Fatti Chiave**: Informazioni tecniche stabili scoperte (es. percorsi file, API keys, limiti).
43
- 4. **Stato Finale**: Il task Γ¨ stato completato? Se no, perchΓ©?
44
-
45
- FORMATO: JSON compresso.
46
- """
47
 
48
- # Nota: Qui si chiamerebbe l'LLM per la distillazione reale.
49
- # Per ora implementiamo una logica di fallback euristica.
50
-
51
- summary = {
52
- "timestamp": datetime.now(timezone.utc).isoformat(),
53
- "goal": goal,
54
- "message_count": len(messages),
55
- "summary": f"Sessione con {len(messages)} messaggi distillata automaticamente.",
56
- "lessons": [],
57
- "patterns": [],
58
- "facts": []
59
- }
60
-
61
- _logger.info(f"[Distiller] Sessione distillata con successo: {goal[:50]}...")
62
- return summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  def compress_for_long_term(self, distilled_data: Dict[str, Any]) -> str:
65
- """
66
- Converte i dati distillati in una stringa ottimizzata per pgvector/semantic memory.
67
- """
68
- return json.dumps(distilled_data)
 
1
  """
2
+ distiller.py β€” S-SESTO-SENSO-V2: Memory Cognitive Compressive.
3
  Sintetizza i log grezzi delle sessioni in lezioni apprese e concetti chiave.
4
+
5
+ C1-FIX (2026-07-01): chiamata LLM reale implementata β€” il distillatore produceva
6
+ solo placeholder vuoto (lessons: [], patterns: [], facts: []) invece di estrarre
7
+ conoscenza reale. Ora chiama ai_client quando disponibile; fallback euristico
8
+ se il client Γ¨ None o se la chiamata fallisce.
9
  """
10
  import logging
11
  import json
12
+ import re
13
+ from typing import List, Dict, Any, Optional
14
  from datetime import datetime, timezone
15
 
16
  _logger = logging.getLogger("agente_ai.memory.distiller")
17
 
18
+ _DISTILL_SYSTEM = (
19
+ "Sei un Memory Distiller per un agente AI avanzato. "
20
+ "Analizza la sessione e restituisci SOLO un oggetto JSON valido, "
21
+ "senza markdown, senza spiegazioni. Formato esatto:\n"
22
+ '{"lessons":["..."],"patterns":["..."],"facts":["..."],'
23
+ '"completed":true|false,"completion_note":"..."}'
24
+ )
25
+
26
+ def _build_distill_prompt(goal: str, cleaned_msgs: List[Dict]) -> str:
27
+ transcript = "\n".join(
28
+ f"[{m['role'].upper()}] {m['content'][:300]}" for m in cleaned_msgs[-20:]
29
+ )
30
+ return (
31
+ f"OBIETTIVO: {goal}\n\n"
32
+ f"TRASCRIZIONE (ultimi {len(cleaned_msgs[-20:])} messaggi):\n{transcript}\n\n"
33
+ "Estrai lessons (errori + soluzioni), patterns (architetture fragili o ricorrenti), "
34
+ "facts (info tecniche stabili). Rispondi SOLO con il JSON."
35
+ )
36
+
37
+ def _heuristic_distill(messages: List[Dict], goal: str) -> Dict[str, Any]:
38
+ """Fallback euristico β€” estrae pattern semplici dal testo."""
39
+ errors_found = []
40
+ facts_found = []
41
+ combined = " ".join(m.get("content", "") for m in messages).lower()
42
+ if "errore" in combined or "error" in combined or "exception" in combined:
43
+ errors_found.append("Errori rilevati nella sessione β€” dettagli nel transcript.")
44
+ if "completato" in combined or "done" in combined or "success" in combined:
45
+ facts_found.append("Task marcato come completato nel transcript.")
46
+ return {
47
+ "timestamp": datetime.now(timezone.utc).isoformat(),
48
+ "goal": goal,
49
+ "message_count": len(messages),
50
+ "lessons": errors_found,
51
+ "patterns": [],
52
+ "facts": facts_found,
53
+ "completed": "completato" in combined or "done" in combined,
54
+ "completion_note": "Distillazione euristica (LLM non disponibile).",
55
+ "source": "heuristic",
56
+ }
57
+
58
  class MemoryDistiller:
59
  def __init__(self, ai_client=None):
60
  self.ai_client = ai_client
61
 
62
  async def distill_session(self, messages: List[Dict[str, Any]], goal: str) -> Dict[str, Any]:
63
  """
64
+ C1-FIX: chiama l'LLM reale per estrarre lezioni/pattern/fatti.
65
+ Fallback euristico se ai_client Γ¨ None o la chiamata fallisce.
66
  """
67
  if not messages:
68
  return {}
69
 
70
+ # 1. Pre-processing: tronca contenuti pesanti
71
  cleaned_msgs = []
72
  for msg in messages:
73
  content = msg.get("content", "")
74
  if len(content) > 2000:
75
+ content = content[:1000] + "... [TRUNCATED] ..." + content[-400:]
76
+ cleaned_msgs.append({"role": msg.get("role", "user"), "content": content})
77
 
78
+ # 2. Tentativo LLM reale
79
+ if self.ai_client is not None:
80
+ try:
81
+ result = await self._distill_with_llm(cleaned_msgs, goal)
82
+ if result:
83
+ return result
84
+ except Exception as e:
85
+ _logger.warning("[Distiller] LLM call failed (%s) β€” fallback euristico", e)
86
+
87
+ # 3. Fallback euristico
88
+ return _heuristic_distill(cleaned_msgs, goal)
 
 
 
 
89
 
90
+ async def _distill_with_llm(
91
+ self, cleaned_msgs: List[Dict], goal: str
92
+ ) -> Optional[Dict[str, Any]]:
93
+ """Chiamata LLM reale via ai_client (qualsiasi provider con interfaccia openai-compat)."""
94
+ prompt = _build_distill_prompt(goal, cleaned_msgs)
95
+ response = await self.ai_client.chat.completions.create(
96
+ model=getattr(self.ai_client, "_distill_model", "llama-3.1-8b-instant"),
97
+ messages=[
98
+ {"role": "system", "content": _DISTILL_SYSTEM},
99
+ {"role": "user", "content": prompt},
100
+ ],
101
+ max_tokens=600,
102
+ temperature=0.1,
103
+ )
104
+ raw = response.choices[0].message.content or ""
105
+ # Estrai JSON dalla risposta (puΓ² avere testo attorno)
106
+ match = re.search(r"\{[\s\S]*\}", raw)
107
+ if not match:
108
+ _logger.warning("[Distiller] risposta LLM non contiene JSON valido: %.100s", raw)
109
+ return None
110
+ parsed = json.loads(match.group(0))
111
+ parsed.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
112
+ parsed.setdefault("goal", goal)
113
+ parsed.setdefault("message_count", len(cleaned_msgs))
114
+ parsed["source"] = "llm"
115
+ _logger.info(
116
+ "[Distiller] distillazione LLM completata: %d lessons, %d facts | goal: %.50s",
117
+ len(parsed.get("lessons", [])), len(parsed.get("facts", [])), goal,
118
+ )
119
+ return parsed
120
 
121
  def compress_for_long_term(self, distilled_data: Dict[str, Any]) -> str:
122
+ """Converte i dati distillati in stringa ottimizzata per pgvector/semantic memory."""
123
+ return json.dumps(distilled_data, ensure_ascii=False)
 
 
memory/evolutionary.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/memory/evolutionary.py β€” Evolutionary Memory (S960)
3
+ Distilla preferenze utente, stili di codice e regole operative dalle sessioni.
4
+ Alimenta il layer 'Reflection' con conoscenze di alto livello (Long-term Evolution).
5
+ """
6
+ import json
7
+ import logging
8
+ import os
9
+ from pathlib import Path
10
+ from datetime import datetime
11
+ from typing import List, Dict, Any
12
+
13
+ _logger = logging.getLogger("memory.evolutionary")
14
+
15
+ # Directory per i dati evolutivi (Sync con reflection.py)
16
+ _DATA_DIR = os.getenv('CHROMA_DATA_DIR') or ('/data' if Path('/data').exists() else '.')
17
+ EVO_PATH = Path(_DATA_DIR) / 'evolutionary_rules.json'
18
+
19
+ class EvolutionaryMemory:
20
+ def __init__(self, ai_client=None):
21
+ self.ai_client = ai_client
22
+ self.rules: Dict[str, Any] = {
23
+ "user_preferences": {}, # es. "language": "python", "style": "functional"
24
+ "operational_rules": [], # es. "Usa sempre pnpm invece di npm"
25
+ "domain_knowledge": {}, # es. "path/to/project": "description"
26
+ "last_updated": None
27
+ }
28
+ self._load()
29
+
30
+ def _load(self):
31
+ if EVO_PATH.exists():
32
+ try:
33
+ self.rules = json.loads(EVO_PATH.read_text())
34
+ except Exception as e:
35
+ _logger.error(f"[S960] Load error: {e}")
36
+
37
+ def _save(self):
38
+ try:
39
+ EVO_PATH.write_text(json.dumps(self.rules, indent=2, ensure_ascii=False))
40
+ except Exception as e:
41
+ _logger.error(f"[S960] Save error: {e}")
42
+
43
+ async def distill_and_evolve(self, session_summary: Dict[str, Any]):
44
+ """
45
+ Prende un sommario distillato (dal MemoryDistiller) e aggiorna le regole evolutive.
46
+ """
47
+ # 1. Estrazione euristica (in attesa di LLM integration)
48
+ # Se il sommario contiene fatti chiave, li integriamo
49
+ facts = session_summary.get("facts", [])
50
+ for fact in facts:
51
+ if ":" in fact:
52
+ k, v = fact.split(":", 1)
53
+ self.rules["domain_knowledge"][k.strip()] = v.strip()
54
+
55
+ # 2. Rilevamento preferenze (es. linguaggi usati con successo)
56
+ lessons = session_summary.get("lessons", [])
57
+ for lesson in lessons:
58
+ if lesson.get("type") == "success":
59
+ # Esempio: "Usato FastAPI con successo" -> preferenza per FastAPI
60
+ pass
61
+
62
+ self.rules["last_updated"] = datetime.now().isoformat()
63
+ self._save()
64
+ _logger.info("[S960] Memoria evolutiva aggiornata.")
65
+
66
+ def get_evolutionary_context(self) -> str:
67
+ """
68
+ Ritorna una stringa formattata da iniettare nel System Prompt.
69
+ """
70
+ if not self.rules["user_preferences"] and not self.rules["operational_rules"] and not self.rules["domain_knowledge"]:
71
+ return ""
72
+
73
+ context = "\n[MEMORIA EVOLUTIVA - REGOLE APPRESE]\n"
74
+
75
+ if self.rules["user_preferences"]:
76
+ context += "Preferenze Utente:\n"
77
+ for k, v in self.rules["user_preferences"].items():
78
+ context += f"- {k}: {v}\n"
79
+
80
+ if self.rules["operational_rules"]:
81
+ context += "Regole Operative:\n"
82
+ for rule in self.rules["operational_rules"]:
83
+ context += f"- {rule}\n"
84
+
85
+ if self.rules["domain_knowledge"]:
86
+ context += "Conoscenza Dominio:\n"
87
+ for k, v in self.rules["domain_knowledge"].items():
88
+ context += f"- {k}: {v}\n"
89
+
90
+ return context
91
+
92
+ # Singleton
93
+ evo_memory = EvolutionaryMemory()
memory/manager.py CHANGED
@@ -8,6 +8,7 @@ from .working import WorkingMemory
8
  from .episodic import EpisodicMemory
9
  from .semantic import SemanticMemory
10
  from .reflection import ReflectionMemory
 
11
 
12
  import logging
13
  _logger = logging.getLogger("memory.manager")
@@ -89,6 +90,9 @@ class MemoryManager:
89
  # ── get_context β€” algoritmo Waterfall TAM ──────────────────────────────────
90
 
91
  async def get_context(self, query: str, code_length: int = 0) -> str:
 
 
 
92
  """Assembla il contesto dai 4 layer con Waterfall Token Budget.
93
 
94
  TAM β€” Token-Aware Memory:
@@ -284,3 +288,4 @@ class MemoryManager:
284
  if self.episodic._db:
285
  self.episodic._db.execute("DELETE FROM episodes")
286
  self.episodic._db.commit()
 
 
8
  from .episodic import EpisodicMemory
9
  from .semantic import SemanticMemory
10
  from .reflection import ReflectionMemory
11
+ from .evolutionary import evo_memory # S960
12
 
13
  import logging
14
  _logger = logging.getLogger("memory.manager")
 
90
  # ── get_context β€” algoritmo Waterfall TAM ──────────────────────────────────
91
 
92
  async def get_context(self, query: str, code_length: int = 0) -> str:
93
+ # S960: Recupero regole evolutive apprese
94
+ evo_context = evo_memory.get_evolutionary_context()
95
+
96
  """Assembla il contesto dai 4 layer con Waterfall Token Budget.
97
 
98
  TAM β€” Token-Aware Memory:
 
288
  if self.episodic._db:
289
  self.episodic._db.execute("DELETE FROM episodes")
290
  self.episodic._db.commit()
291
+
sql/cache_schema.sql ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- ============================================================================
2
+ -- cache_schema.sql β€” Schema Tabella Cache per Supabase A
3
+ --
4
+ -- Eseguire su Supabase A (nodo analytics/cache) per creare la tabella cache
5
+ -- ============================================================================
6
+
7
+ -- Tabella Cache Entries
8
+ CREATE TABLE IF NOT EXISTS cache_entries (
9
+ id BIGSERIAL PRIMARY KEY,
10
+ cache_key VARCHAR(32) NOT NULL UNIQUE,
11
+ strategy VARCHAR(50) NOT NULL,
12
+ identifier TEXT NOT NULL,
13
+ value TEXT NOT NULL,
14
+ ttl_seconds INTEGER DEFAULT 3600,
15
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
16
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
17
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
18
+
19
+ -- Indici per performance
20
+ CONSTRAINT cache_key_idx UNIQUE (cache_key),
21
+ CONSTRAINT strategy_idx ON strategy,
22
+ CONSTRAINT expires_at_idx ON expires_at
23
+ );
24
+
25
+ -- Indice su expires_at per pulizia automatica
26
+ CREATE INDEX IF NOT EXISTS idx_cache_expires_at ON cache_entries(expires_at);
27
+
28
+ -- Indice su strategy per query veloci per strategia
29
+ CREATE INDEX IF NOT EXISTS idx_cache_strategy ON cache_entries(strategy);
30
+
31
+ -- Indice composito per query veloci (strategy + identifier)
32
+ CREATE INDEX IF NOT EXISTS idx_cache_strategy_identifier ON cache_entries(strategy, identifier);
33
+
34
+ -- Tabella Statistiche Cache
35
+ CREATE TABLE IF NOT EXISTS cache_stats (
36
+ id BIGSERIAL PRIMARY KEY,
37
+ hits BIGINT DEFAULT 0,
38
+ misses BIGINT DEFAULT 0,
39
+ sets BIGINT DEFAULT 0,
40
+ deletes BIGINT DEFAULT 0,
41
+ evictions BIGINT DEFAULT 0,
42
+ recorded_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
43
+
44
+ CONSTRAINT cache_stats_pkey PRIMARY KEY (id)
45
+ );
46
+
47
+ -- Tabella Audit Cache (opzionale)
48
+ CREATE TABLE IF NOT EXISTS cache_audit (
49
+ id BIGSERIAL PRIMARY KEY,
50
+ action VARCHAR(50) NOT NULL, -- 'GET', 'SET', 'DELETE', 'EVICT'
51
+ strategy VARCHAR(50) NOT NULL,
52
+ identifier TEXT NOT NULL,
53
+ cache_key VARCHAR(32),
54
+ status VARCHAR(20) NOT NULL, -- 'HIT', 'MISS', 'SUCCESS', 'FAILURE'
55
+ duration_ms INTEGER,
56
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
57
+ );
58
+
59
+ -- Indice su created_at per query audit veloci
60
+ CREATE INDEX IF NOT EXISTS idx_cache_audit_created_at ON cache_audit(created_at DESC);
61
+
62
+ -- Indice su action per filtrare per tipo operazione
63
+ CREATE INDEX IF NOT EXISTS idx_cache_audit_action ON cache_audit(action);
64
+
65
+ -- ============================================================================
66
+ -- Funzione: Pulizia automatica entry scaduti
67
+ -- ============================================================================
68
+ CREATE OR REPLACE FUNCTION cleanup_expired_cache()
69
+ RETURNS TABLE(deleted_count INTEGER) AS $$
70
+ BEGIN
71
+ DELETE FROM cache_entries WHERE expires_at < NOW();
72
+ RETURN QUERY SELECT COUNT(*)::INTEGER FROM cache_entries WHERE expires_at < NOW();
73
+ END;
74
+ $$ LANGUAGE plpgsql;
75
+
76
+ -- ============================================================================
77
+ -- Trigger: Aggiorna updated_at su UPDATE
78
+ -- ============================================================================
79
+ CREATE OR REPLACE FUNCTION update_cache_updated_at()
80
+ RETURNS TRIGGER AS $$
81
+ BEGIN
82
+ NEW.updated_at = NOW();
83
+ RETURN NEW;
84
+ END;
85
+ $$ LANGUAGE plpgsql;
86
+
87
+ CREATE TRIGGER cache_entries_updated_at_trigger
88
+ BEFORE UPDATE ON cache_entries
89
+ FOR EACH ROW
90
+ EXECUTE FUNCTION update_cache_updated_at();
91
+
92
+ -- ============================================================================
93
+ -- View: Statistiche Cache Attuali
94
+ -- ============================================================================
95
+ CREATE OR REPLACE VIEW cache_stats_view AS
96
+ SELECT
97
+ COUNT(*) as total_entries,
98
+ COUNT(CASE WHEN expires_at > NOW() THEN 1 END) as valid_entries,
99
+ COUNT(CASE WHEN expires_at <= NOW() THEN 1 END) as expired_entries,
100
+ COUNT(DISTINCT strategy) as unique_strategies,
101
+ MIN(created_at) as oldest_entry,
102
+ MAX(created_at) as newest_entry,
103
+ AVG(ttl_seconds) as avg_ttl_seconds
104
+ FROM cache_entries;
105
+
106
+ -- ============================================================================
107
+ -- View: Hit Rate (ultimi 24 ore)
108
+ -- ============================================================================
109
+ CREATE OR REPLACE VIEW cache_hit_rate_24h AS
110
+ SELECT
111
+ COUNT(CASE WHEN status = 'HIT' THEN 1 END) as hits,
112
+ COUNT(CASE WHEN status = 'MISS' THEN 1 END) as misses,
113
+ COUNT(*) as total_requests,
114
+ ROUND(
115
+ COUNT(CASE WHEN status = 'HIT' THEN 1 END)::NUMERIC /
116
+ NULLIF(COUNT(*), 0) * 100,
117
+ 2
118
+ ) as hit_rate_percent
119
+ FROM cache_audit
120
+ WHERE created_at > NOW() - INTERVAL '24 hours';
121
+
122
+ -- ============================================================================
123
+ -- Policy RLS (Row Level Security) β€” Opzionale
124
+ -- ============================================================================
125
+ -- Abilita RLS sulla tabella cache_entries
126
+ ALTER TABLE cache_entries ENABLE ROW LEVEL SECURITY;
127
+
128
+ -- Policy: Tutti possono leggere cache (read-only per A)
129
+ CREATE POLICY "Allow read access to cache" ON cache_entries
130
+ FOR SELECT USING (true);
131
+
132
+ -- Policy: Solo service role puΓ² scrivere/modificare cache
133
+ CREATE POLICY "Allow write access to cache (service role only)" ON cache_entries
134
+ FOR INSERT WITH CHECK (current_user = 'postgres');
135
+
136
+ CREATE POLICY "Allow update access to cache (service role only)" ON cache_entries
137
+ FOR UPDATE USING (current_user = 'postgres');
138
+
139
+ CREATE POLICY "Allow delete access to cache (service role only)" ON cache_entries
140
+ FOR DELETE USING (current_user = 'postgres');
141
+
142
+ -- ============================================================================
143
+ -- Commenti Documentazione
144
+ -- ============================================================================
145
+ COMMENT ON TABLE cache_entries IS 'Archivio cache distribuito su Supabase A (read-heavy, non-critical)';
146
+ COMMENT ON COLUMN cache_entries.cache_key IS 'Chiave cache univoca (SHA256 hash di strategy:identifier)';
147
+ COMMENT ON COLUMN cache_entries.strategy IS 'Strategia cache: query, memory, embedding, conversation, analytics';
148
+ COMMENT ON COLUMN cache_entries.identifier IS 'Identificatore univoco per il valore (es. session_id, query_hash)';
149
+ COMMENT ON COLUMN cache_entries.value IS 'Valore memorizzato in cache (JSON serializzato)';
150
+ COMMENT ON COLUMN cache_entries.ttl_seconds IS 'Time-to-live in secondi (tempo di scadenza)';
151
+ COMMENT ON COLUMN cache_entries.expires_at IS 'Timestamp di scadenza (created_at + ttl_seconds)';
152
+
153
+ COMMENT ON TABLE cache_stats IS 'Statistiche aggregate del cache layer';
154
+ COMMENT ON TABLE cache_audit IS 'Audit log di tutte le operazioni cache (GET, SET, DELETE, EVICT)';
155
+
156
+ COMMENT ON FUNCTION cleanup_expired_cache() IS 'Rimuove entry scaduti dalla cache';
157
+ COMMENT ON VIEW cache_stats_view IS 'Statistiche attuali della cache (totale, valide, scadute, ecc.)';
158
+ COMMENT ON VIEW cache_hit_rate_24h IS 'Hit rate del cache nelle ultime 24 ore';
tests/test_telegram_commands.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """tests/test_telegram_commands.py β€” Test funzionale dei command handler Telegram.
3
+
4
+ Cosa testa
5
+ ----------
6
+ Ogni command handler (19 comandi + 2 callback dispatcher) viene eseguito
7
+ davvero, ma tutte le chiamate HTTP verso api.telegram.org sono intercettate.
8
+
9
+ Nessun messaggio viene inviato su Telegram.
10
+ Nessuna env var richiesta.
11
+ Nessuna connessione di rete necessaria.
12
+
13
+ Come funziona
14
+ -------------
15
+ 1. Le dipendenze esterne (fastapi, pydantic, httpx, api.*) vengono stubbate.
16
+ 2. Le funzioni _tg_reply / _tg_send / _tg_edit / _tg_typing / _tg_react
17
+ vengono rimpiazzate da AsyncMock PRIMA di ogni test, nel namespace del
18
+ modulo importato (import_module caching garantisce coerenza).
19
+ 3. Il test verifica che ogni handler abbia chiamato almeno una funzione TG.
20
+
21
+ Uso
22
+ ---
23
+ cd backend && python -m pytest tests/test_telegram_commands.py -v
24
+ cd backend && python tests/test_telegram_commands.py # standalone
25
+ """
26
+ from __future__ import annotations
27
+ import asyncio, importlib, sys, types, unittest
28
+ from unittest.mock import AsyncMock, MagicMock, patch
29
+
30
+ # ─────────────────────────────────────────────────────────────────────────────
31
+ # 0. Stub dipendenze esterne (identico a test_telegram_imports.py)
32
+ # ─────────────────────────────────────────────────────────────────────────────
33
+ def _stub(name: str, **attrs) -> types.ModuleType:
34
+ m = types.ModuleType(name)
35
+ for k, v in attrs.items():
36
+ setattr(m, k, v)
37
+ sys.modules[name] = m
38
+ return m
39
+
40
+ _fa = _stub("fastapi")
41
+ _fa.APIRouter = type("APIRouter", (), {
42
+ "__init__": lambda self, **kw: None,
43
+ "post": lambda self, *a, **kw: (lambda f: f),
44
+ "get": lambda self, *a, **kw: (lambda f: f),
45
+ })
46
+ _fa.Request = type("Request", (), {})
47
+ _fa.HTTPException = type("HTTPException", (Exception,), {})
48
+ _fa.BackgroundTasks = type("BackgroundTasks", (), {})
49
+ _stub("fastapi.responses", JSONResponse=type("JSONResponse", (), {}))
50
+ _stub("fastapi.routing")
51
+ _stub("starlette.requests", Request=_fa.Request)
52
+ _stub("pydantic", BaseModel=type("BaseModel", (), {}))
53
+
54
+ # httpx β€” il client viene moccato per intercettare tutte le chiamate TG
55
+ class _FakeResponse:
56
+ status_code = 200
57
+ def json(self): return {"ok": True, "result": {"message_id": 999}}
58
+ async def aread(self): return b'{"ok":true,"result":{"message_id":999}}'
59
+
60
+ class _FakeAsyncClient:
61
+ def __init__(self, *a, **kw): pass
62
+ async def __aenter__(self): return self
63
+ async def __aexit__(self, *a): pass
64
+ async def post(self, url, **kw): return _FakeResponse()
65
+ async def get(self, url, **kw): return _FakeResponse()
66
+
67
+ _httpx = _stub("httpx", AsyncClient=_FakeAsyncClient, Response=_FakeResponse)
68
+ _stub("httpcore")
69
+ _stub("anyio")
70
+
71
+ # Backend interno β€” stub per evitare import circolari
72
+ for _m in [
73
+ "api.agent", "api.providers", "api.state", "api.unified_api",
74
+ "api.structured_log", "api.supabase_client", "api.exec",
75
+ "agents.unified_loop_fallback", "agents.goal_verifier",
76
+ "agents.memory_manager", "agents.audit_semantic_l2",
77
+ ]:
78
+ _stub(_m)
79
+
80
+ # ─────────────────────────────────────────────────────────────────────────────
81
+ # 1. Import moduli (dopo aver stubbato le dipendenze)
82
+ # ─────────────────────────────────────────────────────────────────────────────
83
+ def _imp(name: str):
84
+ full = f"api.{name}"
85
+ return sys.modules.get(full) or importlib.import_module(full)
86
+
87
+ # Import nell'ordine delle dipendenze
88
+ _tg_client = _imp("telegram_tg_client")
89
+ _keyboards = _imp("telegram_keyboards")
90
+ _mon = _imp("telegram_cmd_monitoring")
91
+ _ai = _imp("telegram_cmd_ai")
92
+ _cb = _imp("telegram_callbacks")
93
+
94
+ CHAT_ID = 123456789 # chat_id fittizio β€” non viene mai usato realmente
95
+
96
+ # ─────────────────────────────────────────────────────────────────────────────
97
+ # 2. Helper β€” patch di tutte le _tg_* nel modulo target
98
+ # ─────────────────────────────────────────────────────────────────────────────
99
+ TG_FUNS = ["_tg_reply", "_tg_send", "_tg_edit", "_tg_typing",
100
+ "_tg_react", "_tg_photo", "_tg_answer_callback"]
101
+
102
+ class TGCapture:
103
+ """Context manager: patcha le funzioni TG in 'mod', registra le chiamate."""
104
+ def __init__(self, *mods):
105
+ self.mods = mods
106
+ self.mocks = {} # "mod.fun" β†’ AsyncMock
107
+ self._patches = []
108
+
109
+ def __enter__(self):
110
+ for mod in self.mods:
111
+ for fun in TG_FUNS:
112
+ if hasattr(mod, fun):
113
+ m = AsyncMock(return_value=999)
114
+ p = patch.object(mod, fun, m)
115
+ p.start()
116
+ self._patches.append(p)
117
+ self.mocks[f"{mod.__name__}.{fun}"] = m
118
+ return self
119
+
120
+ def __exit__(self, *a):
121
+ for p in self._patches:
122
+ p.stop()
123
+
124
+ def called_any(self) -> bool:
125
+ return any(m.called for m in self.mocks.values())
126
+
127
+ def calls(self) -> list[str]:
128
+ return [k.split(".")[-1] for k,m in self.mocks.items() if m.called]
129
+
130
+ def run(coro):
131
+ return asyncio.get_event_loop().run_until_complete(coro)
132
+
133
+ # ─────────────────────────────────────────────────────────────────────────────
134
+ # 3. Test suite
135
+ # ─────────────────────────────────────────────────────────────────────────────
136
+ class TestTelegramCommands(unittest.TestCase):
137
+
138
+ # ── Monitoring ─────────────────────────────────────────────────────────
139
+ def test_cmd_help(self):
140
+ with TGCapture(_mon) as cap:
141
+ run(_mon._cmd_help(CHAT_ID))
142
+ self.assertTrue(cap.called_any(), f"_cmd_help non ha chiamato nulla. Mocks: {cap.calls()}")
143
+
144
+ def test_cmd_logs(self):
145
+ with TGCapture(_mon) as cap:
146
+ run(_mon._cmd_logs(CHAT_ID))
147
+ self.assertTrue(cap.called_any(), f"_cmd_logs: {cap.calls()}")
148
+
149
+ def test_cmd_logs_level(self):
150
+ """_cmd_logs accetta livello custom."""
151
+ with TGCapture(_mon) as cap:
152
+ run(_mon._cmd_logs(CHAT_ID, level="ERROR"))
153
+ self.assertTrue(cap.called_any())
154
+
155
+ def test_cmd_status(self):
156
+ with TGCapture(_mon) as cap:
157
+ run(_mon._cmd_status(CHAT_ID))
158
+ self.assertTrue(cap.called_any(), f"_cmd_status: {cap.calls()}")
159
+
160
+ def test_cmd_commit_summary(self):
161
+ with TGCapture(_mon) as cap:
162
+ run(_mon._cmd_commit_summary(CHAT_ID))
163
+ self.assertTrue(cap.called_any(), f"_cmd_commit_summary: {cap.calls()}")
164
+
165
+ def test_cmd_check(self):
166
+ with TGCapture(_mon) as cap:
167
+ run(_mon._cmd_check(CHAT_ID))
168
+ self.assertTrue(cap.called_any(), f"_cmd_check: {cap.calls()}")
169
+
170
+ def test_cmd_tasks(self):
171
+ with TGCapture(_mon) as cap:
172
+ run(_mon._cmd_tasks(CHAT_ID))
173
+ self.assertTrue(cap.called_any(), f"_cmd_tasks: {cap.calls()}")
174
+
175
+ # ── AI commands ────────────────────────────────────────────────────────
176
+ def test_cmd_do_empty_goal(self):
177
+ """_cmd_do con goal vuoto deve rispondere con la keyboard quick-pick."""
178
+ with TGCapture(_ai) as cap:
179
+ run(_ai._cmd_do(CHAT_ID, goal=""))
180
+ self.assertTrue(cap.called_any(), f"_cmd_do(goal=''): {cap.calls()}")
181
+
182
+ def test_cmd_do_with_goal(self):
183
+ """_cmd_do con goal reale avvia lo streaming β€” la prima chiamata TG Γ¨ _tg_send."""
184
+ with TGCapture(_ai) as cap:
185
+ run(_ai._cmd_do(CHAT_ID, goal="analizza i log"))
186
+ self.assertTrue(cap.called_any(), f"_cmd_do(goal): {cap.calls()}")
187
+
188
+ def test_cmd_do_saves_last_goal(self):
189
+ """_cmd_do salva il goal in _LAST_GOAL per il tasto Rifai."""
190
+ with TGCapture(_ai):
191
+ run(_ai._cmd_do(CHAT_ID, goal="test retry goal"))
192
+ from api.telegram_keyboards import _LAST_GOAL
193
+ self.assertEqual(_LAST_GOAL.get(CHAT_ID), "test retry goal")
194
+
195
+ def test_cmd_autofix(self):
196
+ with TGCapture(_ai) as cap:
197
+ run(_ai._cmd_autofix(CHAT_ID))
198
+ self.assertTrue(cap.called_any(), f"_cmd_autofix: {cap.calls()}")
199
+
200
+ def test_cmd_autofix_with_hint(self):
201
+ with TGCapture(_ai) as cap:
202
+ run(_ai._cmd_autofix(CHAT_ID, hint="TypeError in providers.py"))
203
+ self.assertTrue(cap.called_any())
204
+
205
+ def test_cmd_nota(self):
206
+ with TGCapture(_ai) as cap:
207
+ run(_ai._cmd_nota(CHAT_ID, text="ricordati di aggiornare il token"))
208
+ self.assertTrue(cap.called_any(), f"_cmd_nota: {cap.calls()}")
209
+
210
+ def test_cmd_cerca(self):
211
+ with TGCapture(_ai) as cap:
212
+ run(_ai._cmd_cerca(CHAT_ID, query="FastAPI async streaming"))
213
+ self.assertTrue(cap.called_any(), f"_cmd_cerca: {cap.calls()}")
214
+
215
+ def test_cmd_meteo(self):
216
+ with TGCapture(_ai) as cap:
217
+ run(_ai._cmd_meteo(CHAT_ID, city="Milano"))
218
+ self.assertTrue(cap.called_any(), f"_cmd_meteo: {cap.calls()}")
219
+
220
+ def test_cmd_riepilogo(self):
221
+ with TGCapture(_ai) as cap:
222
+ run(_ai._cmd_riepilogo(CHAT_ID))
223
+ self.assertTrue(cap.called_any(), f"_cmd_riepilogo: {cap.calls()}")
224
+
225
+ def test_cmd_scan_now(self):
226
+ with TGCapture(_ai) as cap:
227
+ run(_ai._cmd_scan_now(CHAT_ID))
228
+ self.assertTrue(cap.called_any(), f"_cmd_scan_now: {cap.calls()}")
229
+
230
+ def test_cmd_coord(self):
231
+ with TGCapture(_ai) as cap:
232
+ run(_ai._cmd_coord(CHAT_ID))
233
+ self.assertTrue(cap.called_any(), f"_cmd_coord: {cap.calls()}")
234
+
235
+ def test_cmd_git(self):
236
+ with TGCapture(_ai) as cap:
237
+ run(_ai._cmd_git(CHAT_ID))
238
+ self.assertTrue(cap.called_any(), f"_cmd_git: {cap.calls()}")
239
+
240
+ def test_cmd_git_custom_n(self):
241
+ with TGCapture(_ai) as cap:
242
+ run(_ai._cmd_git(CHAT_ID, n=10))
243
+ self.assertTrue(cap.called_any())
244
+
245
+ def test_cmd_telemetry(self):
246
+ with TGCapture(_ai) as cap:
247
+ run(_ai._cmd_telemetry(CHAT_ID))
248
+ self.assertTrue(cap.called_any(), f"_cmd_telemetry: {cap.calls()}")
249
+
250
+ def test_cmd_score(self):
251
+ with TGCapture(_ai) as cap:
252
+ run(_ai._cmd_score(CHAT_ID))
253
+ self.assertTrue(cap.called_any(), f"_cmd_score: {cap.calls()}")
254
+
255
+ def test_cmd_bench(self):
256
+ with TGCapture(_ai) as cap:
257
+ run(_ai._cmd_bench(CHAT_ID))
258
+ self.assertTrue(cap.called_any(), f"_cmd_bench: {cap.calls()}")
259
+
260
+ def test_cmd_improve(self):
261
+ with TGCapture(_ai) as cap:
262
+ run(_ai._cmd_improve(CHAT_ID))
263
+ self.assertTrue(cap.called_any(), f"_cmd_improve: {cap.calls()}")
264
+
265
+ # ── Callback dispatcher ────────────────────────────────────────────────
266
+ def _make_cb(self, data: str) -> dict:
267
+ return {
268
+ "id": "cb_001",
269
+ "from": {"id": CHAT_ID, "first_name": "Test"},
270
+ "message": {"message_id": 42, "chat": {"id": CHAT_ID}},
271
+ "chat_instance": "ci",
272
+ "data": data,
273
+ }
274
+
275
+ def test_handle_callback_help(self):
276
+ with TGCapture(_mon, _ai, _cb) as cap:
277
+ run(_cb._handle_callback(self._make_cb("tgw_help"), token="fake"))
278
+ self.assertTrue(cap.called_any(), "callback tgw_help: nessuna chiamata TG")
279
+
280
+ def test_handle_callback_status(self):
281
+ with TGCapture(_mon, _ai, _cb) as cap:
282
+ run(_cb._handle_callback(self._make_cb("tgw_status"), token="fake"))
283
+ self.assertTrue(cap.called_any(), "callback tgw_status")
284
+
285
+ def test_handle_callback_tasks(self):
286
+ with TGCapture(_mon, _ai, _cb) as cap:
287
+ run(_cb._handle_callback(self._make_cb("tgw_tasks"), token="fake"))
288
+ self.assertTrue(cap.called_any(), "callback tgw_tasks")
289
+
290
+ def test_handle_callback_do(self):
291
+ with TGCapture(_mon, _ai, _cb) as cap:
292
+ run(_cb._handle_callback(self._make_cb("agent"), token="fake"))
293
+ self.assertTrue(cap.called_any(), "callback agent (β†’ _cmd_do)")
294
+
295
+ def test_handle_callback_autofix(self):
296
+ with TGCapture(_mon, _ai, _cb) as cap:
297
+ run(_cb._handle_callback(self._make_cb("tgw_autofix"), token="fake"))
298
+ self.assertTrue(cap.called_any(), "callback tgw_autofix")
299
+
300
+ def test_handle_callback_bench(self):
301
+ with TGCapture(_mon, _ai, _cb) as cap:
302
+ run(_cb._handle_callback(self._make_cb("tgw_bench"), token="fake"))
303
+ self.assertTrue(cap.called_any(), "callback tgw_bench")
304
+
305
+ def test_handle_callback_score(self):
306
+ with TGCapture(_mon, _ai, _cb) as cap:
307
+ run(_cb._handle_callback(self._make_cb("tgw_score"), token="fake"))
308
+ self.assertTrue(cap.called_any(), "callback tgw_score")
309
+
310
+ def test_handle_callback_improve(self):
311
+ with TGCapture(_mon, _ai, _cb) as cap:
312
+ run(_cb._handle_callback(self._make_cb("tgw_improve"), token="fake"))
313
+ self.assertTrue(cap.called_any(), "callback tgw_improve")
314
+
315
+ def test_handle_callback_retry(self):
316
+ """tgw_retry rilegge _LAST_GOAL β€” deve funzionare anche se vuoto."""
317
+ with TGCapture(_mon, _ai, _cb) as cap:
318
+ run(_cb._handle_callback(self._make_cb("tgw_retry"), token="fake"))
319
+ self.assertTrue(cap.called_any(), "callback tgw_retry")
320
+
321
+ def test_handle_callback_qp_bug(self):
322
+ """quick-pick qp_bug lancia _cmd_do con goal preimpostato."""
323
+ with TGCapture(_mon, _ai, _cb) as cap:
324
+ run(_cb._handle_callback(self._make_cb("qp_bug"), token="fake"))
325
+ self.assertTrue(cap.called_any(), "callback qp_bug")
326
+
327
+ def test_handle_inline_empty(self):
328
+ """Inline query vuota β†’ risponde con lista comandi."""
329
+ iq = {"id": "iq_001", "from": {"id": CHAT_ID}, "query": ""}
330
+ with TGCapture(_cb) as cap:
331
+ run(_cb._handle_inline(iq, token="fake"))
332
+ # La risposta va a answerInlineQuery β€” httpx Γ¨ stubbato, non fallisce
333
+
334
+ def test_handle_inline_with_query(self):
335
+ iq = {"id": "iq_002", "from": {"id": CHAT_ID}, "query": "analizza bug login"}
336
+ with TGCapture(_cb):
337
+ run(_cb._handle_inline(iq, token="fake"))
338
+
339
+ # ── Guardrail: nessun messaggio reale inviato ──────────────────────────
340
+ def test_no_real_http_calls(self):
341
+ """Verifica che httpx.AsyncClient.post non chiami api.telegram.org reale."""
342
+ real_calls: list[str] = []
343
+ original_post = _FakeAsyncClient.post
344
+ async def spy_post(self, url, **kw):
345
+ real_calls.append(url)
346
+ return _FakeResponse()
347
+ _FakeAsyncClient.post = spy_post
348
+ try:
349
+ with TGCapture(_mon):
350
+ run(_mon._cmd_help(CHAT_ID))
351
+ # Se arriva qui senza errori di rete β†’ ok (le _tg_* erano moccate,
352
+ # quindi httpx.post non Γ¨ stato chiamato direttamente)
353
+ for url in real_calls:
354
+ self.assertNotIn("api.telegram.org", url,
355
+ f"Chiamata reale a api.telegram.org intercettata: {url}")
356
+ finally:
357
+ _FakeAsyncClient.post = original_post
358
+
359
+
360
+ if __name__ == "__main__":
361
+ import pathlib
362
+ backend = pathlib.Path(__file__).parent.parent
363
+ if str(backend) not in sys.path:
364
+ sys.path.insert(0, str(backend))
365
+ runner = unittest.TextTestRunner(verbosity=2)
366
+ result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestTelegramCommands))
367
+ sys.exit(0 if result.wasSuccessful() else 1)
tests/test_telegram_imports.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """tests/test_telegram_imports.py β€” Smoke test import chain moduli M2 split.
3
+
4
+ Verifica che tutti i 6 moduli Telegram si importino correttamente senza
5
+ avviare FastAPI nΓ© connettersi a Telegram. Eseguibile in CI senza env vars.
6
+
7
+ Uso:
8
+ cd backend && python -m pytest tests/test_telegram_imports.py -v
9
+ # oppure direttamente:
10
+ cd backend && python tests/test_telegram_imports.py
11
+ """
12
+ from __future__ import annotations
13
+ import importlib, sys, types, unittest
14
+
15
+ # ── Stub dipendenze esterne non disponibili in CI ─────────────────────────────
16
+ def _stub(name: str, **attrs) -> types.ModuleType:
17
+ m = types.ModuleType(name)
18
+ for k, v in attrs.items():
19
+ setattr(m, k, v)
20
+ sys.modules[name] = m
21
+ return m
22
+
23
+ # fastapi
24
+ _fa = _stub("fastapi")
25
+ _fa.APIRouter = type("APIRouter", (), {"__init__": lambda self, **kw: None,
26
+ "post": lambda self, *a, **kw: (lambda f: f),
27
+ "get": lambda self, *a, **kw: (lambda f: f)})
28
+ _fa.Request = type("Request", (), {})
29
+ _fa.HTTPException = type("HTTPException", (Exception,), {})
30
+ _fa.BackgroundTasks = type("BackgroundTasks", (), {})
31
+ _stub("fastapi.responses", JSONResponse=type("JSONResponse", (), {}))
32
+ _stub("fastapi.routing")
33
+ _stub("starlette.requests", Request=_fa.Request)
34
+
35
+ # pydantic
36
+ _stub("pydantic", BaseModel=type("BaseModel", (), {}))
37
+
38
+ # httpx
39
+ _httpx = _stub("httpx")
40
+ _httpx.AsyncClient = type("AsyncClient", (), {
41
+ "__aenter__": lambda self: self, "__aexit__": lambda *a: None,
42
+ "post": lambda self, *a, **kw: None,
43
+ })
44
+ _stub("httpx._models")
45
+
46
+ # httpcore, anyio (transitive deps di httpx)
47
+ _stub("httpcore")
48
+ _stub("anyio")
49
+
50
+ # Altre dipendenze del backend usate dalle funzioni TG
51
+ for _mod in [
52
+ "api.agent", "api.providers", "api.state", "api.unified_api",
53
+ "api.structured_log", "api.supabase_client", "api.exec",
54
+ "agents.unified_loop_fallback", "agents.goal_verifier",
55
+ "agents.memory_manager", "agents.audit_semantic_l2",
56
+ ]:
57
+ _stub(_mod)
58
+
59
+
60
+ # ── Test ──────────────────────────────────────────────────────────────────────
61
+ class TestTelegramImportChain(unittest.TestCase):
62
+
63
+ def _import(self, modname: str) -> types.ModuleType:
64
+ """Importa (o re-importa) un modulo dal package api."""
65
+ full = f"api.{modname}"
66
+ if full in sys.modules:
67
+ return sys.modules[full]
68
+ return importlib.import_module(full)
69
+
70
+ # ── Layer 0 ───────────────────────────────────────────────────────────────
71
+ def test_01_tg_client(self):
72
+ """telegram_tg_client β€” nessuna dipendenza interna."""
73
+ m = self._import("telegram_tg_client")
74
+ for sym in ("_get_bot_token", "_tg_reply", "_tg_send", "_tg_edit",
75
+ "_tg_photo", "_tg_typing", "_tg_react",
76
+ "_tg_answer_callback", "_fmt_elapsed", "_log_tg_exc"):
77
+ self.assertTrue(hasattr(m, sym), f"Manca {sym} in tg_client")
78
+
79
+ # ── Layer 1 ───────────────────────────────────────────────────────────────
80
+ def test_02_keyboards(self):
81
+ """telegram_keyboards β€” solo costanti/dict."""
82
+ m = self._import("telegram_keyboards")
83
+ for sym in ("_MAIN_KB", "_QUICK_PICK_KB", "_after_task_kb",
84
+ "_LAST_GOAL", "_BENCH_CACHE"):
85
+ self.assertTrue(hasattr(m, sym), f"Manca {sym} in keyboards")
86
+
87
+ # ── Layer 2 ───────────────────────────────────────────────────────────────
88
+ def test_03_cmd_monitoring(self):
89
+ """telegram_cmd_monitoring β€” importa da tg_client + keyboards."""
90
+ m = self._import("telegram_cmd_monitoring")
91
+ for sym in ("_cmd_help", "_cmd_logs", "_cmd_status",
92
+ "_cmd_commit_summary", "_cmd_check", "_cmd_tasks"):
93
+ self.assertTrue(hasattr(m, sym), f"Manca {sym} in cmd_monitoring")
94
+
95
+ def test_04_cmd_ai(self):
96
+ """telegram_cmd_ai β€” importa da tg_client + keyboards."""
97
+ m = self._import("telegram_cmd_ai")
98
+ for sym in ("_cmd_do", "_cmd_autofix", "_cmd_nota", "_cmd_cerca",
99
+ "_cmd_meteo", "_cmd_riepilogo", "_cmd_score", "_cmd_bench",
100
+ "_cmd_improve", "_cmd_git", "_cmd_coord",
101
+ "_cmd_scan_now", "_cmd_telemetry"):
102
+ self.assertTrue(hasattr(m, sym), f"Manca {sym} in cmd_ai")
103
+
104
+ # ── Layer 3 ──────────────────────��────────────────────────────────────────
105
+ def test_05_callbacks(self):
106
+ """telegram_callbacks β€” importa da tg_client + keyboards + cmd_ai + cmd_monitoring."""
107
+ m = self._import("telegram_callbacks")
108
+ for sym in ("_handle_inline", "_handle_callback"):
109
+ self.assertTrue(hasattr(m, sym), f"Manca {sym} in callbacks")
110
+
111
+ # ── Layer 4 (router) ──────────────────────────────────────────────────────
112
+ def test_06_webhook(self):
113
+ """telegram_webhook β€” router puro, importa da tutti i moduli."""
114
+ m = self._import("telegram_webhook")
115
+ self.assertTrue(hasattr(m, "router"),
116
+ "Manca 'router' in telegram_webhook")
117
+
118
+ # ── Import chain completo ─────────────────────────────────────────────────
119
+ def test_07_no_circular_imports(self):
120
+ """Nessun import circolare: webhook importa callbacks che NON importa webhook."""
121
+ cb = sys.modules.get("api.telegram_callbacks")
122
+ wh = sys.modules.get("api.telegram_webhook")
123
+ if cb and wh:
124
+ # callbacks non deve avere 'telegram_webhook' come dipendenza diretta
125
+ cb_src = getattr(cb, "__file__", "")
126
+ import ast, pathlib
127
+ if cb_src and pathlib.Path(cb_src).exists():
128
+ tree = ast.parse(pathlib.Path(cb_src).read_text())
129
+ for node in ast.walk(tree):
130
+ if isinstance(node, ast.ImportFrom):
131
+ self.assertNotIn("telegram_webhook", (node.module or ""),
132
+ "Import circolare: callbacks β†’ webhook")
133
+
134
+ def test_08_main_kb_in_keyboards_not_tg_client(self):
135
+ """_MAIN_KB deve stare in keyboards, NON in tg_client."""
136
+ tc = self._import("telegram_tg_client")
137
+ kb = self._import("telegram_keyboards")
138
+ self.assertFalse(hasattr(tc, "_MAIN_KB"),
139
+ "_MAIN_KB non deve essere in tg_client")
140
+ self.assertTrue(hasattr(kb, "_MAIN_KB"),
141
+ "_MAIN_KB deve essere in keyboards")
142
+
143
+ def test_09_scan_now_in_cmd_ai(self):
144
+ """_cmd_scan_now/_cmd_git/_cmd_coord/_cmd_telemetry devono stare in cmd_ai."""
145
+ ai = self._import("telegram_cmd_ai")
146
+ mon = self._import("telegram_cmd_monitoring")
147
+ for sym in ("_cmd_scan_now", "_cmd_git", "_cmd_coord", "_cmd_telemetry"):
148
+ self.assertTrue(hasattr(ai, sym),
149
+ f"{sym} deve essere in cmd_ai")
150
+ self.assertFalse(hasattr(mon, sym),
151
+ f"{sym} NON deve essere in cmd_monitoring")
152
+
153
+
154
+ if __name__ == "__main__":
155
+ # Aggiungi backend/ al path se eseguito da root
156
+ import os, pathlib
157
+ backend = pathlib.Path(__file__).parent.parent
158
+ if str(backend) not in sys.path:
159
+ sys.path.insert(0, str(backend))
160
+ runner = unittest.TextTestRunner(verbosity=2)
161
+ result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestTelegramImportChain))
162
+ sys.exit(0 if result.wasSuccessful() else 1)
tools/registry.py CHANGED
@@ -1,3 +1,4 @@
 
1
  """
2
  registry.py β€” Tool Registry
3
  Ogni tool ha: goal, required_inputs, risk_level, fallbacks, _fn.
@@ -63,6 +64,7 @@ from tools.registry_web import (
63
  from tools.registry_fs import (
64
  _read_file, _write_file, _ts_syntax_check, _apply_patch, _execute_shell,
65
  _web_research, _send_email, _database_query, _call_api, _execute_sql, _create_pdf,
 
66
  )
67
  from tools.registry_dev import (
68
  _directory_tree, _file_search, _git_status, _git_clone, _git_diff,
@@ -689,7 +691,17 @@ TOOL_REGISTRY: dict[str, dict] = {
689
  "P30-B1: Analisi statica Python in-process β€” zero exec_engine, <5ms. "
690
  "code: stringa Python (obbligatorio). filename: nome file per errori (default '<string>'). "
691
  "Ritorna: syntax_ok (bool), errors[] (type/message/line/col), "
692
- "complexity{total_lines/functions/classes/imports/max_nesting},
 
 
 
 
 
 
 
 
 
 
693
  "read_file_range": {
694
  "name": "read_file_range",
695
  "goal": "Legge un range specifico di righe da un file (ottimizzato per file grandi)",
@@ -731,15 +743,4 @@ TOOL_REGISTRY: dict[str, dict] = {
731
  "fallbacks": [],
732
  "_fn": _file_metadata,
733
  },
734
- , "
735
- "suggestions[] (max 5 actionable), summary (stringa leggibile). "
736
- "Usare per: verificare sintassi prima di exec, analizzare qualitΓ  codice, "
737
- "ottenere metriche strutturali, suggerire refactoring."
738
- ),
739
- "required_inputs": ["code"],
740
- "optional_inputs": {"filename": "<string>", "content": ""}, # GAP-1: alias per 'code' (frontend compat)
741
- "risk_level": "low",
742
- "fallbacks": [],
743
- "_fn": _python_analyze,
744
- },
745
  }
 
1
+ # sync: 2026-07-02T21:03
2
  """
3
  registry.py β€” Tool Registry
4
  Ogni tool ha: goal, required_inputs, risk_level, fallbacks, _fn.
 
64
  from tools.registry_fs import (
65
  _read_file, _write_file, _ts_syntax_check, _apply_patch, _execute_shell,
66
  _web_research, _send_email, _database_query, _call_api, _execute_sql, _create_pdf,
67
+ _read_file_range, _grep_file, _file_metadata,
68
  )
69
  from tools.registry_dev import (
70
  _directory_tree, _file_search, _git_status, _git_clone, _git_diff,
 
691
  "P30-B1: Analisi statica Python in-process β€” zero exec_engine, <5ms. "
692
  "code: stringa Python (obbligatorio). filename: nome file per errori (default '<string>'). "
693
  "Ritorna: syntax_ok (bool), errors[] (type/message/line/col), "
694
+ "complexity{total_lines/functions/classes/imports/max_nesting}, "
695
+ "suggestions[] (max 5 actionable), summary (stringa leggibile). "
696
+ "Usare per: verificare sintassi prima di exec, analizzare qualitΓ  codice, "
697
+ "ottenere metriche strutturali, suggerire refactoring."
698
+ ),
699
+ "required_inputs": ["code"],
700
+ "optional_inputs": {"filename": "<string>", "content": ""}, # GAP-1: alias per 'code' (frontend compat)
701
+ "risk_level": "low",
702
+ "fallbacks": [],
703
+ "_fn": _python_analyze,
704
+ },
705
  "read_file_range": {
706
  "name": "read_file_range",
707
  "goal": "Legge un range specifico di righe da un file (ottimizzato per file grandi)",
 
743
  "fallbacks": [],
744
  "_fn": _file_metadata,
745
  },
 
 
 
 
 
 
 
 
 
 
 
746
  }
tools/registry_fs.py CHANGED
@@ -30,7 +30,7 @@ import pathlib
30
  import itertools
31
 
32
  async def _read_file_range(path: str, start_line: int = 1, end_line: int = 100, encoding: str = "utf-8") -> dict:
33
- \"\"\"P42: Legge un range specifico di righe da un file (ottimizzato per file grandi).\"\"\"
34
  try:
35
  _p = pathlib.Path(path)
36
  if not _p.exists():
@@ -53,7 +53,7 @@ async def _read_file_range(path: str, start_line: int = 1, end_line: int = 100,
53
  return {"ok": False, "error": str(exc)}
54
 
55
  async def _grep_file(path: str, pattern: str, max_results: int = 20, encoding: str = "utf-8") -> dict:
56
- \"\"\"P42: Cerca un pattern in un file senza caricarlo interamente in RAM.\"\"\"
57
  try:
58
  _p = pathlib.Path(path)
59
  if not _p.exists():
@@ -79,7 +79,7 @@ async def _grep_file(path: str, pattern: str, max_results: int = 20, encoding: s
79
  return {"ok": False, "error": str(exc)}
80
 
81
  async def _file_metadata(path: str) -> dict:
82
- \"\"\"P42: Ottiene metadati del file (dimensione, numero righe) senza leggere il contenuto.\"\"\"
83
  try:
84
  _p = pathlib.Path(path)
85
  if not _p.exists():
 
30
  import itertools
31
 
32
  async def _read_file_range(path: str, start_line: int = 1, end_line: int = 100, encoding: str = "utf-8") -> dict:
33
+ """P42: Legge un range specifico di righe da un file (ottimizzato per file grandi)."""
34
  try:
35
  _p = pathlib.Path(path)
36
  if not _p.exists():
 
53
  return {"ok": False, "error": str(exc)}
54
 
55
  async def _grep_file(path: str, pattern: str, max_results: int = 20, encoding: str = "utf-8") -> dict:
56
+ """P42: Cerca un pattern in un file senza caricarlo interamente in RAM."""
57
  try:
58
  _p = pathlib.Path(path)
59
  if not _p.exists():
 
79
  return {"ok": False, "error": str(exc)}
80
 
81
  async def _file_metadata(path: str) -> dict:
82
+ """P42: Ottiene metadati del file (dimensione, numero righe) senza leggere il contenuto."""
83
  try:
84
  _p = pathlib.Path(path)
85
  if not _p.exists():