Pulka commited on
Commit
7cc12d8
·
verified ·
1 Parent(s): 637dccb

deploy: backend build-1781037398298 — 2026-06-09 20:36 UTC

Browse files
agents/context_manager.py CHANGED
@@ -12,6 +12,7 @@ Design: stateless per request, tutto I/O fire-and-forget, mai blocca il loop
12
  """
13
  from __future__ import annotations
14
  import asyncio
 
15
  import re
16
  from typing import Any
17
 
@@ -83,7 +84,10 @@ async def compress_cold_file(path: str, content: str, language: str,
83
  Usa Groq-8b via CONTEXT role per velocità. Fallback a skeleton.
84
  Max 8s. Mai rilancia eccezioni.
85
  """
86
- cache_key = f'{path}:{hash(content[:500])}'
 
 
 
87
  if cache_key in _SUMMARY_CACHE:
88
  return _SUMMARY_CACHE[cache_key]
89
 
@@ -99,11 +103,12 @@ async def compress_cold_file(path: str, content: str, language: str,
99
  f"File: {path}\n```{language}\n{content[:2000]}\n```"},
100
  ]
101
  summary = await asyncio.wait_for(
102
- tester_llm.chat(msgs, temperature=0.0, max_tokens=120),
 
103
  timeout=7.0,
104
  )
105
  if summary and not summary.startswith('[LLM'):
106
- result = f' {path}: {summary[:180]}'
107
  if len(_SUMMARY_CACHE) >= _MAX_SUMMARY_CACHE:
108
  oldest = next(iter(_SUMMARY_CACHE))
109
  del _SUMMARY_CACHE[oldest]
 
12
  """
13
  from __future__ import annotations
14
  import asyncio
15
+ import hashlib
16
  import re
17
  from typing import Any
18
 
 
84
  Usa Groq-8b via CONTEXT role per velocità. Fallback a skeleton.
85
  Max 8s. Mai rilancia eccezioni.
86
  """
87
+ # S573: hash() è PYTHONHASHSEED-salted → chiave diversa a ogni restart HF Space
88
+ # → nessun riuso della cache tra restart. hashlib.sha256 è stabile e deterministica.
89
+ _h = hashlib.sha256(content[:500].encode("utf-8", errors="replace")).hexdigest()[:16]
90
+ cache_key = f'{path}:{_h}'
91
  if cache_key in _SUMMARY_CACHE:
92
  return _SUMMARY_CACHE[cache_key]
93
 
 
103
  f"File: {path}\n```{language}\n{content[:2000]}\n```"},
104
  ]
105
  summary = await asyncio.wait_for(
106
+ # S587: 120→200 — formato [SCOPO]|[SYMBOLS]|[DEPS] può superare 120 tok
107
+ tester_llm.chat(msgs, temperature=0.0, max_tokens=200),
108
  timeout=7.0,
109
  )
110
  if summary and not summary.startswith('[LLM'):
111
+ result = f' {path}: {summary[:300]}' # S604: 180→300 — summary file LLM spesso 2-3 righe
112
  if len(_SUMMARY_CACHE) >= _MAX_SUMMARY_CACHE:
113
  oldest = next(iter(_SUMMARY_CACHE))
114
  del _SUMMARY_CACHE[oldest]
agents/error_classifier.py CHANGED
@@ -180,7 +180,7 @@ def classify_error(errors: list[str]) -> ErrorResult:
180
  Non usa LLM — è sincrono e a zero latency.
181
  Chiamato da _reflective_debug prima di invocare ARCHITECT.
182
  """
183
- combined = " | ".join(str(e)[:400] for e in errors[-4:])[:1500]
184
 
185
  for category, pattern in _PATTERNS:
186
  m = pattern.search(combined)
 
180
  Non usa LLM — è sincrono e a zero latency.
181
  Chiamato da _reflective_debug prima di invocare ARCHITECT.
182
  """
183
+ combined = " | ".join(str(e)[:500] for e in errors[-4:])[:1500] # S604: 400→500 per error string completa
184
 
185
  for category, pattern in _PATTERNS:
186
  m = pattern.search(combined)
agents/executor.py CHANGED
@@ -2,48 +2,50 @@
2
  executor.py — Tool Executor con retry e timeout.
3
  Usa AIClient (multi-provider) al posto di OllamaClient (localhost).
4
  """
5
- import asyncio
6
- from models.ai_client import AIClient
7
- from memory.manager import MemoryManager
8
- from tools.registry import TOOL_REGISTRY
9
 
10
- class Executor:
11
- def __init__(self, llm_client: AIClient | None = None, memory: MemoryManager | None = None, max_retries: int = 2):
12
- self.llm = llm_client or AIClient()
13
- self.memory = memory
14
- self.max_retries = max_retries
15
 
16
- # Backward-compat: vecchia firma aveva ollama=OllamaClient, memory=MemoryManager
17
- @classmethod
18
- def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
19
- return cls(memory=memory, max_retries=max_retries)
20
 
21
- async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
22
- tool = TOOL_REGISTRY.get(tool_name)
23
- if not tool:
24
- return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
25
 
26
- missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
27
- if missing:
28
- return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
29
 
30
- for attempt in range(self.max_retries + 1):
31
- try:
32
- fn = tool.get("_fn")
33
- if fn is None:
34
- return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
35
- result = await asyncio.wait_for(fn(**inputs), timeout=timeout)
36
- if self.memory:
37
- await self.memory.save_episode("tool", f"{tool_name}: {str(inputs)[:100]}", str(result)[:500], True)
38
- return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
39
- except asyncio.TimeoutError:
40
- if attempt == self.max_retries:
41
- return {"success": False, "error": f"Timeout dopo {timeout}s (tentativo {attempt+1})", "output": None}
42
- await asyncio.sleep(0.5)
43
- except Exception as e:
44
- if attempt == self.max_retries:
45
- return {"success": False, "error": str(e), "output": None}
46
- await asyncio.sleep(0.5)
 
 
 
47
 
48
- return {"success": False, "error": "Max retries raggiunti", "output": None}
49
-
 
2
  executor.py — Tool Executor con retry e timeout.
3
  Usa AIClient (multi-provider) al posto di OllamaClient (localhost).
4
  """
5
+ import asyncio
6
+ from models.ai_client import AIClient
7
+ from memory.manager import MemoryManager
8
+ from tools.registry import TOOL_REGISTRY
9
 
10
+ class Executor:
11
+ def __init__(self, llm_client: AIClient | None = None, memory: MemoryManager | None = None, max_retries: int = 2):
12
+ self.llm = llm_client or AIClient()
13
+ self.memory = memory
14
+ self.max_retries = max_retries
15
 
16
+ # Backward-compat: vecchia firma aveva ollama=OllamaClient, memory=MemoryManager
17
+ @classmethod
18
+ def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
19
+ return cls(memory=memory, max_retries=max_retries)
20
 
21
+ async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
22
+ tool = TOOL_REGISTRY.get(tool_name)
23
+ if not tool:
24
+ return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
25
 
26
+ missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
27
+ if missing:
28
+ return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
29
 
30
+ for attempt in range(self.max_retries + 1):
31
+ try:
32
+ fn = tool.get("_fn")
33
+ if fn is None:
34
+ return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
35
+ result = await asyncio.wait_for(fn(**inputs), timeout=timeout)
36
+ if self.memory:
37
+ # S577: inputs 100→200 100 chars spesso taglia il parametro principale
38
+ # S589: inputs 200→300 più contesto tool in memoria episodica
39
+ # S600: inputs 300→500 — parity con str(result) e altri handler
40
+ await self.memory.save_episode("tool", f"{tool_name}: {str(inputs)[:500]}", str(result)[:500], True)
41
+ return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
42
+ except asyncio.TimeoutError:
43
+ if attempt == self.max_retries:
44
+ return {"success": False, "error": f"Timeout dopo {timeout}s (tentativo {attempt+1})", "output": None}
45
+ await asyncio.sleep(0.5)
46
+ except Exception as e:
47
+ if attempt == self.max_retries:
48
+ return {"success": False, "error": str(e), "output": None}
49
+ await asyncio.sleep(0.5)
50
 
51
+ return {"success": False, "error": "Max retries raggiunti", "output": None}
 
agents/goal_verifier.py CHANGED
@@ -154,7 +154,8 @@ class GoalVerifier:
154
  @classmethod
155
  def is_code_goal(cls, goal: str) -> bool:
156
  """True se il goal implica generazione/modifica di codice o artefatti."""
157
- return bool(cls._CODE_RE.search(goal[:300]))
 
158
 
159
  @classmethod
160
  def adaptive_threshold(cls, goal: str) -> float:
@@ -180,15 +181,16 @@ class GoalVerifier:
180
  # S427 FIX: goal misti "spiega E implementa" → NON usare soglia bassa.
181
  # Se il goal contiene ANCHE un pattern di codice, la soglia deve rimanere
182
  # quella del codice (0.42/0.55) — non quella dell'explanation (0.25).
183
- if cls._EXPLANATION_RE.search(g[:300]) and not cls._CODE_RE.search(g[:300]):
 
184
  return 0.25
185
 
186
  # 3. Codice complesso — alta soglia: app, API, dashboard, schema DB
187
- if _COMPLEX_CODE_RE.search(g[:300]):
188
  return 0.55
189
 
190
  # 4. Codice semplice — funzione singola, script breve
191
- if cls._CODE_RE.search(g[:300]):
192
  return 0.42
193
 
194
  # 5. Default: query generiche, domande factual, traduzioni
@@ -211,7 +213,8 @@ class GoalVerifier:
211
  {"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"},
212
  ]
213
  try:
214
- raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=120)
 
215
  if not raw or raw.startswith("[LLM"):
216
  return self._default_ok()
217
  return self._parse(raw)
@@ -261,7 +264,8 @@ class GoalVerifier:
261
  continue
262
 
263
  # Verifica: almeno metà dei criteri presenti nella risposta (euristica veloce)
264
- criteria_text = "\n".join(f"- {c}" for c in criteria[:3])
 
265
  check_prompt = (
266
  f"Requisito: {req_name}\n"
267
  f"Criteri:\n{criteria_text}\n\n"
@@ -285,15 +289,17 @@ class GoalVerifier:
285
  if criteria:
286
  failed_hints.append(f"{req_name}: {criteria[0]}")
287
 
288
- # Calcola score aggregato
289
- n_total = len(per_req)
 
290
  n_pass = sum(1 for v in per_req.values() if v == "PASS")
291
- score = (n_pass / n_total) if n_total > 0 else 0.5
292
 
293
  overall_pass = score >= threshold and not failed_reqs
294
- hint = "; ".join(failed_hints[:2]) if failed_hints else ""
 
295
  if failed_reqs:
296
- hint = f"Requisiti FAIL: {', '.join(failed_reqs[:3])}. {hint}"
297
 
298
  status = (
299
  GoalVerificationStatus.PASS if overall_pass
@@ -304,7 +310,7 @@ class GoalVerifier:
304
  return GoalVerifyResult(
305
  goal_met = overall_pass,
306
  coverage_score = round(score, 3),
307
- missing_items = failed_reqs[:3],
308
  repair_hint = hint[:MAX_HINT_CHARS],
309
  verification_status = status,
310
  )
@@ -321,7 +327,7 @@ class GoalVerifier:
321
  d = json.loads(m.group())
322
  score = float(d.get("score", 1.0))
323
  score = max(0.0, min(1.0, score))
324
- missing = [str(x)[:100] for x in (d.get("missing") or [])[:3]]
325
  hint = str(d.get("hint") or "")[:MAX_HINT_CHARS]
326
  is_pass = score >= RETRY_THRESHOLD
327
  return GoalVerifyResult(
 
154
  @classmethod
155
  def is_code_goal(cls, goal: str) -> bool:
156
  """True se il goal implica generazione/modifica di codice o artefatti."""
157
+ # S595: 300→500 — goal lunghi possono avere pattern di codice oltre i 300 chars
158
+ return bool(cls._CODE_RE.search(goal[:500]))
159
 
160
  @classmethod
161
  def adaptive_threshold(cls, goal: str) -> float:
 
181
  # S427 FIX: goal misti "spiega E implementa" → NON usare soglia bassa.
182
  # Se il goal contiene ANCHE un pattern di codice, la soglia deve rimanere
183
  # quella del codice (0.42/0.55) — non quella dell'explanation (0.25).
184
+ # S595: 300→500 goal lunghi possono avere pattern oltre i 300 chars
185
+ if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
186
  return 0.25
187
 
188
  # 3. Codice complesso — alta soglia: app, API, dashboard, schema DB
189
+ if _COMPLEX_CODE_RE.search(g[:500]): # S595: 300→500
190
  return 0.55
191
 
192
  # 4. Codice semplice — funzione singola, script breve
193
+ if cls._CODE_RE.search(g[:500]): # S595: 300→500
194
  return 0.42
195
 
196
  # 5. Default: query generiche, domande factual, traduzioni
 
213
  {"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"},
214
  ]
215
  try:
216
+ # S586: 120→200 JSON con score+missing[]+hint supera facilmente 120 tok
217
+ raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=200)
218
  if not raw or raw.startswith("[LLM"):
219
  return self._default_ok()
220
  return self._parse(raw)
 
264
  continue
265
 
266
  # Verifica: almeno metà dei criteri presenti nella risposta (euristica veloce)
267
+ # S591: criteria[:3]→[:5] — verifica più criteri per goal accurato
268
+ criteria_text = "\n".join(f"- {c}" for c in criteria[:5])
269
  check_prompt = (
270
  f"Requisito: {req_name}\n"
271
  f"Criteri:\n{criteria_text}\n\n"
 
289
  if criteria:
290
  failed_hints.append(f"{req_name}: {criteria[0]}")
291
 
292
+ # N-4-FIX: UNKNOWN non penalizza lo score — esclude dal denominatore.
293
+ # UNKNOWN = timeout LLM su 1/5 requisiti NON deve causare overall FAIL.
294
+ n_known = sum(1 for v in per_req.values() if v != "UNKNOWN")
295
  n_pass = sum(1 for v in per_req.values() if v == "PASS")
296
+ score = (n_pass / n_known) if n_known > 0 else 0.5
297
 
298
  overall_pass = score >= threshold and not failed_reqs
299
+ # S595: failed_hints 2→4, failed_reqs 3→5 riporta più requisiti falliti nel hint
300
+ hint = "; ".join(failed_hints[:4]) if failed_hints else ""
301
  if failed_reqs:
302
+ hint = f"Requisiti FAIL: {', '.join(failed_reqs[:5])}. {hint}"
303
 
304
  status = (
305
  GoalVerificationStatus.PASS if overall_pass
 
310
  return GoalVerifyResult(
311
  goal_met = overall_pass,
312
  coverage_score = round(score, 3),
313
+ missing_items = failed_reqs[:5], # S595: 3→5 — riporta più requisiti mancanti
314
  repair_hint = hint[:MAX_HINT_CHARS],
315
  verification_status = status,
316
  )
 
327
  d = json.loads(m.group())
328
  score = float(d.get("score", 1.0))
329
  score = max(0.0, min(1.0, score))
330
+ missing = [str(x)[:200] for x in (d.get("missing") or [])[:3]] # S577: 100→200
331
  hint = str(d.get("hint") or "")[:MAX_HINT_CHARS]
332
  is_pass = score >= RETRY_THRESHOLD
333
  return GoalVerifyResult(
agents/planner.py CHANGED
@@ -18,7 +18,7 @@ Rispondi SOLO con JSON valido nel formato:
18
  {
19
  "id": 1,
20
  "description": "cosa fare",
21
- "tool": "web_search|code|read_page|memory|direct_response",
22
  "requires": [],
23
  "risk": "low|medium|high"
24
  }
@@ -27,7 +27,26 @@ Rispondi SOLO con JSON valido nel formato:
27
  "impacted_files": []
28
  }
29
 
30
- impacted_files: lista di path file VFS che potrebbero essere impattati da questo task (vuota se non applicabile)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  class Planner:
33
  def __init__(self, llm_client: AIClient | None = None):
@@ -52,7 +71,10 @@ class Planner:
52
  {"role": "user", "content": f"Obiettivo: {goal}"}
53
  ]
54
  if context:
55
- ctx_str = "\n".join(m.get("content", "")[:100] for m in context[-3:])
 
 
 
56
  messages[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
57
 
58
  try:
@@ -60,7 +82,7 @@ class Planner:
60
  json_match = re.search(r'\{[\s\S]+\}', raw)
61
  if json_match:
62
  plan = json.loads(json_match.group())
63
- plan["_raw"] = raw[:200]
64
  return plan
65
  except Exception:
66
  pass
 
18
  {
19
  "id": 1,
20
  "description": "cosa fare",
21
+ "tool": "web_search|code|read_page|memory|direct_response|send_email|database_query|web_research|execute_sql|create_pdf|call_api|generate_image|run_python|apply_patch|write_file|read_file|execute_shell",
22
  "requires": [],
23
  "risk": "low|medium|high"
24
  }
 
27
  "impacted_files": []
28
  }
29
 
30
+ impacted_files: lista di path file VFS che potrebbero essere impattati da questo task (vuota se non applicabile).
31
+
32
+ Tool disponibili:
33
+ web_search — cerca informazioni online in tempo reale
34
+ code — genera/modifica codice (Python, TS, JS, etc.)
35
+ read_page — legge una pagina web per URL
36
+ memory — accede a dati precedentemente memorizzati
37
+ direct_response — risposta diretta senza tool esterni
38
+ send_email — invia email via Resend (richiede RESEND_API_KEY)
39
+ database_query — esegue query su database PostgreSQL/SQLite
40
+ web_research — ricerca approfondita multi-fonte con sintesi AI
41
+ execute_sql — esegue SQL su dati in-memory (sql.js, no DB esterno)
42
+ create_pdf — genera un documento PDF con contenuto strutturato
43
+ call_api — chiama un REST API esterno con metodo/headers/body
44
+ generate_image — genera un'immagine AI (FLUX.1-schnell + Pollinations fallback)
45
+ run_python — esegue codice Python in sandbox sicura
46
+ write_file — scrive o sovrascrive un file nel filesystem virtuale (risk: medium)
47
+ read_file — legge il contenuto di un file nel filesystem virtuale (risk: low)
48
+ apply_patch — applica una patch unificata a un file esistente nel VFS (risk: medium)
49
+ execute_shell — esegue un comando shell in sandbox (bash, npm, pip, etc.) — impostare risk: high"""
50
 
51
  class Planner:
52
  def __init__(self, llm_client: AIClient | None = None):
 
71
  {"role": "user", "content": f"Obiettivo: {goal}"}
72
  ]
73
  if context:
74
+ # S572: 100→300 al planner più contesto per decisioni multi-step corrette
75
+ # S590: context[-3:]→[-5:] — più history per task multi-step complessi
76
+ # S594: content 300→500 per messaggio — messaggi LLM spesso superano 300 chars
77
+ ctx_str = "\n".join(m.get("content", "")[:500] for m in context[-5:])
78
  messages[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
79
 
80
  try:
 
82
  json_match = re.search(r'\{[\s\S]+\}', raw)
83
  if json_match:
84
  plan = json.loads(json_match.group())
85
+ plan["_raw"] = raw[:400] # S577: 200→400 (debug log)
86
  return plan
87
  except Exception:
88
  pass
agents/reasoning_core.py CHANGED
@@ -59,7 +59,15 @@ Return:
59
  CONTEXT:
60
  {repo_context}
61
  """
62
- return await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2)
 
 
 
 
 
 
 
 
63
 
64
  # ── 2. Global Strategy (Devin Core) ─────────────────────────────────────────
65
  async def develop_strategy(self, state: ReasoningState) -> str:
@@ -76,7 +84,14 @@ Decide:
76
  - impact
77
  - risk level
78
  """
79
- return await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3)
 
 
 
 
 
 
 
80
 
81
  # ── 3. Error Intelligence ───────────────────────────────────────────────────
82
  async def analyze_error(self, error: str) -> str:
@@ -88,11 +103,19 @@ Return:
88
  - root cause
89
  - fix strategy
90
  """
91
- return await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1)
 
 
 
 
 
 
 
92
 
93
  # ── Prompt builder ──────────────────────────────────────────────────────────
94
  def _build_prompt(self, state: ReasoningState) -> str:
95
- errors_str = "\n".join(state.errors[-3:]) if state.errors else "nessuno"
 
96
  steps_str = "\n".join(f"- {s}" for s in state.completed_steps[-5:]) if state.completed_steps else "nessuno"
97
 
98
  return f"""Sei MobileMaxAgent, un sistema di ingegneria software autonoma.
@@ -102,7 +125,7 @@ STATO:
102
  - goal: {state.goal}
103
  - world_model: {'Presente' if state.world_model else 'Mancante'}
104
  - strategy: {'Definita' if state.strategy else 'Da definire'}
105
- - last_result: {state.last_result[:300] if state.last_result else 'vuoto'}
106
  - errors: {errors_str}
107
  - loop_count: {state.loop_count}/{self.MAX_LOOPS}
108
 
@@ -195,10 +218,21 @@ Regole:
195
  results.append({"action": "error_analysis", "output": error_analysis})
196
 
197
  elif decision.action == "continue":
198
- if self.executor and decision.steps:
199
- res = await self.executor.run_tool("direct_response", {"input": decision.steps[0]})
200
- state.last_result = str(res.get("output", ""))
201
- state.completed_steps.append(decision.steps[0])
 
 
 
 
 
 
 
 
 
 
 
202
  results.append({"action": "continue", "steps": decision.steps})
203
 
204
  # Auto-debug check con Critic
@@ -219,3 +253,82 @@ Regole:
219
  "has_strategy": state.strategy is not None
220
  }
221
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  CONTEXT:
60
  {repo_context}
61
  """
62
+ # S665: wrap con asyncio.wait_for — analyze_project usava await self.llm.chat() senza timeout
63
+ # → hang indefinito se il provider non risponde. Timeout 45s = STREAM_TIMEOUT (ai_client.py).
64
+ try:
65
+ return await asyncio.wait_for(
66
+ self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2),
67
+ timeout=45.0,
68
+ )
69
+ except asyncio.TimeoutError:
70
+ return "[reasoning_core] analyze_project: timeout 45s — contesto non disponibile"
71
 
72
  # ── 2. Global Strategy (Devin Core) ─────────────────────────────────────────
73
  async def develop_strategy(self, state: ReasoningState) -> str:
 
84
  - impact
85
  - risk level
86
  """
87
+ # S665: timeout anche per develop_strategy
88
+ try:
89
+ return await asyncio.wait_for(
90
+ self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3),
91
+ timeout=45.0,
92
+ )
93
+ except asyncio.TimeoutError:
94
+ return "[reasoning_core] develop_strategy: timeout 45s — strategia non disponibile"
95
 
96
  # ── 3. Error Intelligence ───────────────────────────────────────────────────
97
  async def analyze_error(self, error: str) -> str:
 
103
  - root cause
104
  - fix strategy
105
  """
106
+ # S665: timeout anche per analyze_error
107
+ try:
108
+ return await asyncio.wait_for(
109
+ self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1),
110
+ timeout=45.0,
111
+ )
112
+ except asyncio.TimeoutError:
113
+ return "[reasoning_core] analyze_error: timeout 45s — analisi non disponibile"
114
 
115
  # ── Prompt builder ──────────────────────────────────────────────────────────
116
  def _build_prompt(self, state: ReasoningState) -> str:
117
+ # S590: errors[-3:]→[-5:] più errori nel contesto per diagnosi più accurata
118
+ errors_str = "\n".join(state.errors[-5:]) if state.errors else "nessuno"
119
  steps_str = "\n".join(f"- {s}" for s in state.completed_steps[-5:]) if state.completed_steps else "nessuno"
120
 
121
  return f"""Sei MobileMaxAgent, un sistema di ingegneria software autonoma.
 
125
  - goal: {state.goal}
126
  - world_model: {'Presente' if state.world_model else 'Mancante'}
127
  - strategy: {'Definita' if state.strategy else 'Da definire'}
128
+ - last_result: {state.last_result[:500] if state.last_result else 'vuoto'} # S592: 300→500
129
  - errors: {errors_str}
130
  - loop_count: {state.loop_count}/{self.MAX_LOOPS}
131
 
 
218
  results.append({"action": "error_analysis", "output": error_analysis})
219
 
220
  elif decision.action == "continue":
221
+ # S575: direct_response non esiste nel TOOL_REGISTRY — usa LLM diretto
222
+ if decision.steps:
223
+ try:
224
+ _step_prompt = decision.steps[0]
225
+ _step_ans = await self.llm.chat(
226
+ [{"role": "system", "content":
227
+ "Sei un assistente tecnico. Esegui il passo richiesto in modo conciso."},
228
+ {"role": "user", "content":
229
+ f"Goal: {state.goal}\n\nPasso da eseguire: {_step_prompt}"}],
230
+ temperature=0.2, max_tokens=512,
231
+ )
232
+ state.last_result = _step_ans or ""
233
+ state.completed_steps.append(_step_prompt)
234
+ except Exception:
235
+ state.completed_steps.append(decision.steps[0])
236
  results.append({"action": "continue", "steps": decision.steps})
237
 
238
  # Auto-debug check con Critic
 
253
  "has_strategy": state.strategy is not None
254
  }
255
  }
256
+
257
+ async def run_loop_to_answer(self, goal: str, context: str = "",
258
+ on_step=None, max_loops: int = 8) -> str:
259
+ """S575: Versione di run_loop che ritorna una stringa risposta sintetizzata.
260
+
261
+ Usata dal gate in UnifiedAgentLoop quando tok_budget >= 6144 e subtask >= 3.
262
+ Limite max_loops=8 (S701: era 5) — più iterazioni per task profondi.
263
+ Output: stringa di risultati aggregati da passare come contesto extra al LLM finale.
264
+ Mai solleva eccezioni.
265
+ """
266
+ try:
267
+ state = ReasoningState(goal=goal, context=context)
268
+ parts: List[str] = []
269
+ loop_cap = min(max_loops, self.MAX_LOOPS)
270
+
271
+ while state.loop_count < loop_cap:
272
+ try:
273
+ decision = await self.decide(state)
274
+ except Exception:
275
+ break
276
+
277
+ if on_step:
278
+ try:
279
+ import asyncio as _aio
280
+ coro = on_step({
281
+ "loop": state.loop_count,
282
+ "action": f"reasoning:{decision.action}",
283
+ "reason": decision.reason[:200] if decision.reason else "", # S578: 120→200
284
+ "confidence": decision.confidence,
285
+ })
286
+ if _aio.iscoroutine(coro):
287
+ await coro
288
+ except Exception:
289
+ pass
290
+
291
+ if decision.action == "stop" or decision.confidence < self.MIN_CONFIDENCE:
292
+ break
293
+
294
+ elif decision.action == "analyze":
295
+ try:
296
+ state.world_model = await self.analyze_project(context or goal)
297
+ # S593: 400→600 — world_model spesso multi-paragrafo
298
+ parts.append(f"[ANALISI PROGETTO]: {(state.world_model or '')[:600]}")
299
+ except Exception:
300
+ pass
301
+
302
+ elif decision.action == "strategy":
303
+ try:
304
+ state.strategy = await self.develop_strategy(state)
305
+ # S593: 400→600 — strategy spesso multi-step
306
+ parts.append(f"[STRATEGIA]: {(state.strategy or '')[:600]}")
307
+ except Exception:
308
+ pass
309
+
310
+ elif decision.action in ("plan", "continue", "fix"):
311
+ # Esegui passo diretto via LLM
312
+ step_desc = (decision.steps[0] if decision.steps
313
+ else decision.reason or goal)
314
+ try:
315
+ _ans = await self.llm.chat(
316
+ [{"role": "system", "content":
317
+ "Sei un assistente tecnico esperto. "
318
+ "Svolgi il passo richiesto in modo preciso e conciso."},
319
+ {"role": "user", "content":
320
+ f"Goal complessivo: {goal}\n\nPasso: {step_desc}"}],
321
+ temperature=0.2, max_tokens=512,
322
+ )
323
+ if _ans and not _ans.startswith("[LLM"):
324
+ parts.append(f"[PASSO {state.loop_count+1}]: {_ans[:600]}")
325
+ state.last_result = _ans
326
+ state.completed_steps.append(step_desc)
327
+ except Exception:
328
+ pass
329
+
330
+ state.loop_count += 1
331
+
332
+ return "\n\n".join(parts) if parts else ""
333
+ except Exception:
334
+ return ""
agents/requirement_engine.py CHANGED
@@ -188,10 +188,12 @@ class RequirementEngine:
188
  )
189
  msgs = [
190
  {"role": "system", "content": system},
191
- {"role": "user", "content": f"Goal: {goal[:300]}"},
 
192
  ]
193
  try:
194
- raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=300)
 
195
  if not raw or raw.startswith("[LLM"):
196
  return []
197
  m = re.search(r'\[[\s\S]+\]', raw)
@@ -207,7 +209,7 @@ class RequirementEngine:
207
  result.append(Requirement(
208
  id=feat_id,
209
  feature=str(item.get("feature", feat_id)),
210
- description=str(item.get("description", ""))[:200],
211
  acceptance_criteria=criteria,
212
  source="llm",
213
  ))
 
188
  )
189
  msgs = [
190
  {"role": "system", "content": system},
191
+ # S596: goal 300→500 goal complessi superano 300 chars
192
+ {"role": "user", "content": f"Goal: {goal[:500]}"},
193
  ]
194
  try:
195
+ # S586: 300→512 JSON array di requisiti con 3-5 items supera 300 tok
196
+ raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=512)
197
  if not raw or raw.startswith("[LLM"):
198
  return []
199
  m = re.search(r'\[[\s\S]+\]', raw)
 
209
  result.append(Requirement(
210
  id=feat_id,
211
  feature=str(item.get("feature", feat_id)),
212
+ description=str(item.get("description", ""))[:400], # S576: 200→400
213
  acceptance_criteria=criteria,
214
  source="llm",
215
  ))
agents/response_verifier.py CHANGED
@@ -181,7 +181,10 @@ def check_coherence(goal: str, response: str) -> tuple[float, list[str], str]:
181
  issues.append("Risposta di resa senza contenuto utile")
182
  score -= 0.4
183
  if not hint:
184
- hint = f"Fornisci una risposta utile e completa all'obiettivo: {goal[:100]}"
 
 
 
185
  break
186
 
187
  # 4. Risposta in lingua sbagliata (controllo leggero)
@@ -249,7 +252,8 @@ def _check_html_structure(text: str) -> list[str]:
249
  open_count[tl] = open_count.get(tl, 0) - 1
250
  unbalanced = [t for t, c in open_count.items() if c != 0]
251
  if unbalanced:
252
- issues.append(f"Tag non bilanciati: {', '.join(unbalanced[:4])}")
 
253
 
254
  # Script/style non chiusi
255
  if block.count("<script") != block.count("</script>"):
@@ -327,7 +331,8 @@ class ResponseVerifier:
327
  return (
328
  f"La risposta precedente non era soddisfacente.\n"
329
  f"PROBLEMA: {hint}\n\n"
330
- f"Risposta precedente (non usare):\n{bad_output[:300]}...\n\n"
 
331
  f"Obiettivo originale: {goal}\n\n"
332
  f"Ora rispondi correttamente, in italiano, in modo diretto e completo."
333
  )
 
181
  issues.append("Risposta di resa senza contenuto utile")
182
  score -= 0.4
183
  if not hint:
184
+ # S577: 100→200 più contesto nell'hint di repair
185
+ # S589: goal 200→300 — hint repair più dettagliato
186
+ # S597: 300→500 — goal lunghi tagliati
187
+ hint = f"Fornisci una risposta utile e completa all'obiettivo: {goal[:500]}"
188
  break
189
 
190
  # 4. Risposta in lingua sbagliata (controllo leggero)
 
252
  open_count[tl] = open_count.get(tl, 0) - 1
253
  unbalanced = [t for t, c in open_count.items() if c != 0]
254
  if unbalanced:
255
+ # S597: unbalanced[:4]→[:6] — mostra più tag sbilanciati nel report
256
+ issues.append(f"Tag non bilanciati: {', '.join(unbalanced[:6])}")
257
 
258
  # Script/style non chiusi
259
  if block.count("<script") != block.count("</script>"):
 
331
  return (
332
  f"La risposta precedente non era soddisfacente.\n"
333
  f"PROBLEMA: {hint}\n\n"
334
+ # S592: bad_output 300→500 — più contesto della risposta precedente per retry
335
+ f"Risposta precedente (non usare):\n{bad_output[:500]}...\n\n"
336
  f"Obiettivo originale: {goal}\n\n"
337
  f"Ora rispondi correttamente, in italiano, in modo diretto e completo."
338
  )
agents/unified_loop.py CHANGED
The diff for this file is too large to render. See raw diff
 
agents/unified_loop_prompts.py ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unified_loop_prompts.py — PromptBuilderMixin: system prompt + message construction.
2
+
3
+ Estratto da unified_loop.py come secondo mixin (dopo DirectToolsMixin).
4
+
5
+ Contiene:
6
+ Block A — System prompt:
7
+ _SYSTEM_IDENTITY: system prompt principale (~120 righe)
8
+ _CONTEXT_RULES: lista (keyword[], regola) per injection contestuale (S200)
9
+ _pick_context_rules(goal): seleziona max 3 regole rilevanti per contesto
10
+
11
+ Block B — Message/prompt construction:
12
+ _classify_format_directive(goal): sceglie direttiva formato risposta
13
+ _build_messages(state, tool_results): costruisce lista messaggi per LLM
14
+ _build_prompt(state, tool_results): wrapper che chiama _build_messages
15
+ _guess_filename(lang, idx): nome file da linguaggio e indice (staticmethod)
16
+
17
+ Invariante B1: nessun corpo duplicato con unified_loop.py.
18
+ MRO Python garantisce self._SYSTEM_IDENTITY / self._pick_context_rules()
19
+ funzionino da qualsiasi metodo di UnifiedAgentLoop (es. _run_fallback).
20
+ """
21
+ from __future__ import annotations
22
+ import re
23
+
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ # Import solo a type-checking: evita circular import a runtime
27
+ if TYPE_CHECKING:
28
+ from agents.unified_loop import UnifiedLoopState
29
+
30
+
31
+ class PromptBuilderMixin:
32
+ # ── System prompt ─────────────────────────────────────────────────────────
33
+
34
+ _SYSTEM_IDENTITY = (
35
+ "Sei un agente AI autonomo, preciso e proattivo. Lavori come l'agente di Replit: "
36
+ "risolvi problemi concretamente, non li descrivi.\n\n"
37
+ "REGOLE FONDAMENTALI:\n"
38
+ "1. Rispondi SEMPRE nella lingua dell'utente (default italiano)\n"
39
+ "2. Lavora autonomamente — non chiedere conferma per ogni passo\n"
40
+ "3. Quando hai dati reali da tool, usali direttamente nella risposta\n"
41
+ "4. Non dire 'puoi fare X' — mostra X fatto, con codice completo se richiesto\n"
42
+ "5. Se incontri un errore, analizza e riprova con approccio diverso\n"
43
+ "6. Sii specifico e concreto — niente placeholder o risposte vaghe\n"
44
+ "7. Per codice: sempre blocchi markdown con sintassi corretta, tipizzati\n"
45
+ "8. Per matematica: mostra calcoli passo passo con numeri esatti\n"
46
+ "9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
47
+ "10. NON inventare mai informazioni su te stesso: token usati, context window, "
48
+ "versione, architettura, parametri interni. Se non lo sai con certezza, "
49
+ "di esplicitamente 'non ho accesso a questa informazione'.\n"
50
+ "11. Se un tool ha fallito o non hai dati reali aggiornati, dillo chiaramente. "
51
+ "Non rispondere mai con dati del training senza aggiungere: "
52
+ "'(informazione dal mio training — potrebbe non essere aggiornata)'. "
53
+ "Quando il limite del training si applica (notizie, eventi recenti, versioni software), "
54
+ "suggerisci SEMPRE fonti reali: Google News, BBC, Reuters, Corriere della Sera, "
55
+ "o il sito ufficiale della tecnologia (npmjs.com, github.com/releases).\n"
56
+ "12. Se nella sezione DATI REALI RECUPERATI ci sono errori tool, "
57
+ "dichiarali all'utente invece di ignorarli e inventare la risposta.\n"
58
+ "13. ANTI-HALLUCINATION VERSIONI (S225): MAI inventare versioni di AI, librerie o linguaggi. "
59
+ "Esempi VIETATI: 'GPT-5', 'Claude 4', 'TypeScript 6', 'React 20', 'Python 4', 'Next.js 20'. "
60
+ "Questi modelli/versioni NON ESISTONO — non citarli mai. "
61
+ "Se non sei certo della versione esatta → ometti il numero o scrivi 'versione corrente'. "
62
+ "Per la tua identità: di' solo 'Sono un agente AI' senza inventare versioni, parametri o architettura.\n"
63
+ "14. GENERAZIONE IMMAGINI (S276): Se ti viene chiesto di generare un'immagine AI, "
64
+ "usa il tool generate_image con il prompt desiderato. "
65
+ "Il tool usa FLUX.1-schnell (backend HF Space) → Pollinations fallback automatico. "
66
+ "Restituisce URL diretto visualizzabile nel browser come link cliccabile. "
67
+ "Se generate_image non è disponibile nel contesto, costruisci URL Pollinations: "
68
+ "https://image.pollinations.ai/prompt/{PROMPT_URL_ENCODED}?width=512&height=512&nologo=true\n"
69
+ "MAI inventare URL fake tipo example.com, placeholder.com, via.placeholder.com. "
70
+ "L'URL Pollinations funziona nel browser come fallback.\n\n"
71
+ "REGOLE SPECIALIZZATE:\n"
72
+ "• Probabilita/Bayes: usa sempre il Teorema di Bayes esplicitamente. "
73
+ "Scrivi P(A|B) = P(B|A)*P(A)/P(B). Usa la terminologia italiana del problema "
74
+ "(es. 'scatola', 'cassetto', 'porta', 'malato') nelle equazioni. "
75
+ "Conclude SEMPRE con la risposta finale come frazione (es. 2/3) "
76
+ "E come percentuale con punto decimale (es. 66.67%). "
77
+ "USA SEMPRE il punto come separatore decimale, mai la virgola.\n"
78
+ " BERTRAND BOX (S227): METODO OBBLIGATORIO — conta i CASSETTI ORO individualmente (non le scatole):\n"
79
+ " Scatola [Oro,Oro]: cassetto_1=Oro, cassetto_2=Oro → 2 cassetti oro\n"
80
+ " Scatola [Oro,Arg]: cassetto_3=Oro → 1 cassetto oro\n"
81
+ " Scatola [Arg,Arg]: nessun cassetto oro → 0\n"
82
+ " Totale cassetti oro: 3. Estratto cassetto oro → quale scatola?\n"
83
+ " Cassetto_1 o cassetto_2 → altra moneta è oro (2 casi su 3)\n"
84
+ " Cassetto_3 → altra moneta è argento (1 caso su 3)\n"
85
+ " RISPOSTA FINALE OBBLIGATORIA: 'P = 2/3 (66.67%)' — SEMPRE frazione E percentuale con punto.\n"
86
+ "• Bug Python MUTABLE DEFAULT ARGUMENT: nella risposta scrivi LETTERALMENTE "
87
+ "la frase 'mutable default argument' (in inglese, non tradurre). "
88
+ "Spiega che lo stesso oggetto mutabile e condiviso tra le chiamate. "
89
+ "Mostra SEMPRE il fix con None sentinel:\n"
90
+ " def f(lst=None):\n if lst is None: lst = []\n\n"
91
+ "• Git workflow: dai sempre i comandi esatti con le opzioni corrette "
92
+ "(es. `git pull --rebase origin main`, `git fetch && git rebase origin/main`)\n"
93
+ "• React useEffect: menziona SEMPRE useMemo/useCallback/useRef come possibili fix "
94
+ "per dipendenze instabili, con esempio di codice per ciascuno\n"
95
+ "• TypeScript debounce firma corretta (S195): <T extends (...args: Parameters<T>) => ReturnType<T>>\n"
96
+ " NON usare any[] — perde i tipi dei parametri\n"
97
+ "• JWT verifica token usa formato **Passo N:** (S195):\n"
98
+ " **Passo 1:** Decodifica header.payload.signature\n"
99
+ " **Passo 2:** Verifica firma con chiave pubblica/secret\n"
100
+ " **Passo 3:** Controlla exp iat aud iss\n"
101
+ " **Passo 4:** Estrai sub — mai decode() senza verify()\n"
102
+ "• Rate limiting benchmark: NON inventare numeri ms. Se non hai dati reali dilo esplicitamente.\n"
103
+ "\n"
104
+ "=== PROBLEM SOLVING ASSOLUTO ===\n"
105
+ "MAI dire 'non posso', 'non sono in grado', 'mi dispiace ma', 'impossibile', "
106
+ "'non ho accesso a', 'non riesco'. Ogni problema ha una soluzione — se l'approccio "
107
+ "A non funziona, prova B, poi C. Sei un problem solver, non un filtro di rifiuti.\n"
108
+ "Se il codice e troppo lungo per analizzarlo tutto in una volta, analizzalo pezzo per "
109
+ "pezzo: prima la struttura, poi i dettagli, poi i bug. Non fermarti mai.\n"
110
+ "Quando trovi codice con bug multipli, elencali tutti numerati anche se sono tanti.\n"
111
+ "Su problemi multi-parte: rispondi a OGNI parte separatamente con header numerato.\n"
112
+ "\n"
113
+ "=== PERFORMANCE E CODE REVIEW (S198) ===\n"
114
+ "• N+1 QUERIES: quando trovi il pattern, scrivi sempre 'N+1 query problem'. \n"
115
+ " Conta le query: 1 principale + N per ogni record. Con 100 post = 101 query. \n"
116
+ " Mostra sempre 2 fix: (1) include/JOIN eager load, (2) batch con IN clause.\n"
117
+ "• PERFORMANCE AUDIT checklist (5 punti obbligatori da verificare sempre): \n"
118
+ " (1) N+1 queries, (2) mancanza di limit/paginazione, (3) assenza di caching/Redis, \n"
119
+ " (4) operazioni sequenziali da parallelizzare con Promise.all, \n"
120
+ " (5) indici DB mancanti su colonne di filtro/sort. Menzionali tutti.\n"
121
+ "• TYPESCRIPT OVERLOADS: la funzione di implementazione usa union type, non any[]. \n"
122
+ " SBAGLIATO: function f<T>(v:T,...args:any[]). \n"
123
+ " CORRETTO: function f(v: string|number|Date|boolean, ...args: unknown[]).\n"
124
+ "• CODE REVIEW SEVERITY FORMAT: **[SEVERITY]** NomeVuln (riga): desc. Fix: `codice`. \n"
125
+ " Severity: CRITICAL > HIGH > MEDIUM > LOW > INFO.\n"
126
+ "• PRISMA DESIGN: Float per money → usa Decimal. id senza @default → Prisma Migrate fallisce. \n"
127
+ " Relazioni senza onDelete → FK constraint error in produzione. \n"
128
+ " Segnala SEMPRE questi 3 problemi in qualsiasi schema Prisma.\n"
129
+ "\n"
130
+ "Le regole specializzate per security, React, Node.js, TypeScript e database "
131
+ "vengono iniettate contestualmente in base al tipo di task.\n\n"
132
+ "=== AUTONOMIA E QUALITÀ (S385) ===\n"
133
+ "15. PATCH PREFERENCE: Per modifiche a file esistenti, usa apply_patch (diff minimale) "
134
+ "invece di riscrivere l'intero file — più veloce, meno errori di troncamento.\n"
135
+ " (S675: il tool si chiama apply_patch, non patch_tool)\n"
136
+ "16. LINTER AUTO-RETRY: Se dopo una write_file il linter segnala errori (ok: false), "
137
+ "correggi immediatamente il file prima di restituire la risposta finale.\n"
138
+ "17. README AUTO-GEN: Al termine di ogni task che crea o modifica file di progetto multipli, "
139
+ "genera SEMPRE un README.md con: (1) descrizione progetto, (2) struttura file, "
140
+ "(3) istruzioni per eseguire localmente (es. npm install && npm start, python main.py).\n"
141
+ )
142
+
143
+ # ── S200: Context-aware rule injection ──────────────────────────────────���───
144
+ # Seleziona solo le regole rilevanti per il task corrente.
145
+ # Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt
146
+ # causa troncamento silenzioso — le regole non vengono mai lette.
147
+ # Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message
148
+ # (posizione con massima attenzione del modello = "recency bias").
149
+
150
+ _CONTEXT_RULES: list[tuple[list[str], str]] = [
151
+ # (pattern keywords, regola da iniettare)
152
+ (
153
+ ["useeffect", "fetch", "hook", "react", "usedati", "userdata", "usequery", "cleanup"],
154
+ "REGOLA CRITICA React fetch: usa AbortController per cleanup. "
155
+ "CORRETTO: const ctrl=new AbortController(); fetch(url,{signal:ctrl.signal}).then(...).catch(e=>{if(e.name!=='AbortError')setError(e);}); return ()=>ctrl.abort(); "
156
+ "SBAGLIATO: fetch senza cleanup (memory leak). NON usare axios.cancelToken."
157
+ ),
158
+ (
159
+ ["regex", "regexp", "pattern", "test(", ".test(", "validate", "validat"],
160
+ "REGOLA CRITICA Regex: cerca ReDoS (catastrophic backtracking). "
161
+ "Pattern pericolosi: (.+)+ (\\w+\\s*)+ ([a-zA-Z]+)* "
162
+ "Test: 'aaaaab' su /^(a+)+$/ blocca il processo. Segnala **[CRITICAL]**."
163
+ ),
164
+ (
165
+ ["security", "sicurezza", "vulnerabilit", "audit", "review", "pentest", "hack"],
166
+ "REGOLE SICUREZZA obbligatorie — segnala TUTTE come [CRITICAL]:\n"
167
+ "1. SSRF: fetch(req.body.url) senza whitelist → attaccante punta a 169.254.169.254\n"
168
+ "2. Path traversal: input utente in path.join/fs.read senza sanitizzazione\n"
169
+ "3. SQL injection: template string in query raw o ORM.raw() non parametrizzato\n"
170
+ "4. Prototype pollution: Object.assign({},userInput) o _.merge senza validazione\n"
171
+ "5. ReDoS: regex con quantificatori annidati (.+)+ ([a-z]+)*"
172
+ ),
173
+ (
174
+ ["promise", "async", "parallel", "parallelo", "notific", "allsettled", "all(", "promise.all"],
175
+ "REGOLA Promise: scegli in base al comportamento voluto:\n"
176
+ "Promise.all → FAIL-FAST: se UNA promise fallisce, rigetta immediatamente (short-circuit). Usa per: dipendenze obbligatorie.\n"
177
+ "Promise.allSettled → aspetta TUTTE, ritorna [{status,value/reason}]. Usa per: notifiche/operazioni indipendenti.\n"
178
+ "Promise.any → primo fulfilled vince. Usa per: fonti ridondanti.\n"
179
+ "Promise.race → primo settled (ok o errore) vince. Usa per: timeout pattern."
180
+ ),
181
+ (
182
+ ["stream", "pipe", "readable", "writable", "backpressure", "drain", "pipeline"],
183
+ "REGOLA Node.js stream: writable.write() ritorna false quando buffer pieno. "
184
+ "Senza gestire 'drain' → OOM. "
185
+ "Fix: if(!dest.write(chunk)){src.pause();dest.once('drain',()=>src.resume())} "
186
+ "Meglio: pipeline() da 'node:stream/promises' gestisce backpressure automaticamente."
187
+ ),
188
+ (
189
+ ["job", "queue", "worker", "task", "lock", "deadlock", "postgres", "pg ", "sql", "select"],
190
+ "REGOLA job queue Postgres senza deadlock: "
191
+ "SELECT * FROM jobs WHERE status='pending' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED; "
192
+ "Senza SKIP LOCKED i worker si bloccano → deadlock. "
193
+ "Con SKIP LOCKED ogni worker prende un job diverso atomicamente."
194
+ ),
195
+ (
196
+ ["infer", "conditional type", "mapped type", "unwrap", "flatten", "returntype", "extends"],
197
+ "REGOLA TypeScript infer: "
198
+ "type Unwrap<T> = T extends Promise<infer U> ? U : T; "
199
+ "type Flatten<T> = T extends Array<infer U> ? U : T; "
200
+ "Distributive: T extends string → si applica a ogni membro della union. "
201
+ "Non-distributive: [T] extends [string] → tratta la union come insieme."
202
+ ),
203
+ (
204
+ ["next.js", "nextjs", "next 15", "app router", "cache", "fetch cache", "migr"],
205
+ "REGOLA Next.js 15 breaking change: "
206
+ "Next ≤14: fetch() cached di default (force-cache). "
207
+ "Next 15+: fetch() NON cached di default → aggiungi cache:'force-cache' esplicitamente. "
208
+ "Funzioni custom: usa unstable_cache() non getStaticProps."
209
+ ),
210
+ (
211
+ ["error boundary", "errorboundary", "errore app", "crash app", "fallback"],
212
+ "REGOLA ErrorBoundary: NON solo root level (un errore abbatte tutta l'app). "
213
+ "Metti ErrorBoundary PER ROUTE/FEATURE: "
214
+ "<ErrorBoundary fallback={<DashboardError/>}><Dashboard/></ErrorBoundary> "
215
+ "Next.js App Router: file error.tsx per route-level boundary automatico."
216
+ ),
217
+ ]
218
+
219
+ def _pick_context_rules(self, goal: str) -> str:
220
+ """Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto."""
221
+ goal_lower = goal.lower()
222
+ matched: list[str] = []
223
+ for patterns, rule in self._CONTEXT_RULES:
224
+ if any(p in goal_lower for p in patterns):
225
+ matched.append(rule)
226
+ if len(matched) >= 3:
227
+ break
228
+ if not matched:
229
+ return ""
230
+ return "\n\n⚡ REGOLE SPECIFICHE PER QUESTO TASK:\n" + "\n".join(f"• {r}" for r in matched)
231
+
232
+ def _classify_format_directive(self, goal: str) -> str:
233
+ """S375: classifica il formato output ottimale per il backend.
234
+ Speculare al frontend formatClassifier.ts — mantiene coerenza di formato
235
+ tra path frontend (assembleSystemPrompt) e path backend (_build_messages).
236
+ """
237
+ # S598: goal 400→600 — format classifier deve vedere più del goal per goal lunghi
238
+ g = goal[:600]
239
+ if self._MATH_GOAL_RE.search(g):
240
+ return self._FORMAT_DIRECTIVE_MATH
241
+ if self._CODE_GOAL_RE.search(g):
242
+ return self._FORMAT_DIRECTIVE_CODE
243
+ if self._MARKDOWN_GOAL_RE.search(g):
244
+ return self._FORMAT_DIRECTIVE_MARKDOWN
245
+ return self._FORMAT_DIRECTIVE_CONVERSATIONAL
246
+
247
+ def _build_messages(self, state: UnifiedLoopState, tool_results: str = "",
248
+ tool_exec_successes: int = 0, tool_exec_errors: int = 0,
249
+ session_files: "dict[str, str] | None" = None) -> list[dict]:
250
+ # S197: estrai code-block grandi in file virtuali per evitare timeout provider
251
+ goal_display, files_ctx = self._compress_goal(state.goal)
252
+ mem_hint = "Tieni conto della memoria per preferenze e contesto utente.\n" if self.memory else ""
253
+ if tool_results:
254
+ # S402: Tool Integrity Guard — distingue successi reali da errori completi.
255
+ # Quando TUTTI i tool hanno fallito, non iniettare dati come "reali" —
256
+ # obbliga l'LLM a dichiarare il fallimento invece di inventare risultati.
257
+ # S427-FixE: terzo caso — tool_results presente ma exec_success=0 e exec_errors=0
258
+ # significa che è il disclaimer S378 ("[NOTA: ricerca web non disponibile]")
259
+ # iniettato da _run_fallback. NON va wrappato come "DATI REALI RECUPERATI" —
260
+ # il LLM rispondeva con "Ho trovato che la ricerca non è disponibile..." (confuso).
261
+ _all_errors = (tool_exec_errors > 0 and tool_exec_successes == 0)
262
+ _no_exec = (tool_exec_successes == 0 and tool_exec_errors == 0)
263
+ if _all_errors:
264
+ tool_section = (
265
+ f"--- TENTATIVO TOOL FALLITO ---\n{tool_results}\n--- FINE DATI ---\n\n"
266
+ "⚠️ STATO TOOL: tutti i tool hanno restituito errori o timeout. "
267
+ "NON affermare di aver cercato, trovato o recuperato dati live. "
268
+ "Non iniziare con 'Ho cercato', 'Ho trovato', 'Ho recuperato', 'Ho ottenuto'. "
269
+ "Informa l'utente che i servizi live non sono disponibili al momento. "
270
+ "Rispondi dalla tua knowledge base e aggiungi esplicitamente che i dati "
271
+ "potrebbero non essere aggiornati."
272
+ )
273
+ elif _no_exec:
274
+ # S427-FixE: disclaimer S378 — nessun tool eseguito; non presentare come dati reali
275
+ tool_section = (
276
+ f"NOTA SISTEMA: {tool_results}\n\n"
277
+ "Rispondi in modo diretto, completo e concreto. "
278
+ "Se la domanda riguarda dati aggiornati, segnala esplicitamente il limite "
279
+ "del tuo training e suggerisci fonti reali (Google News, siti ufficiali)."
280
+ )
281
+ else:
282
+ tool_section = (
283
+ f"--- DATI REALI RECUPERATI ---\n{tool_results}\n--- FINE DATI ---\n\n"
284
+ "Usa QUESTI DATI REALI per rispondere. Non dire all'utente di controllare altri siti — "
285
+ "la risposta e gia qui. Se vedi errori tool nei dati, dichiarali all'utente. "
286
+ "Formula una risposta completa, diretta e utile."
287
+ )
288
+ else:
289
+ tool_section = (
290
+ "Rispondi in modo diretto, completo e concreto. Niente istruzioni generali — "
291
+ "dai la risposta specifica al problema. Se la domanda riguarda dati che potrebbero "
292
+ "essere cambiati dopo il tuo training, segnalalo esplicitamente."
293
+ )
294
+ # S385: context compression — tronca contesti >8000 chars per evitare overflow silenziosi
295
+ _raw_ctx = state.context or ""
296
+ # S458: ctxTrim multimodale — salta troncatura se content è lista (parti immagine/multimodal)
297
+ if isinstance(_raw_ctx, str) and len(_raw_ctx) > 8000:
298
+ _raw_ctx = _raw_ctx[:2000] + "\n\n[...contesto precedente compresso...]\n\n" + _raw_ctx[-5000:]
299
+ context_part = f"Contesto sessione: {_raw_ctx}\n\n" if _raw_ctx and _raw_ctx != "nessuno" else ""
300
+ files_part = f"{files_ctx}\n\n" if files_ctx else ""
301
+ # S200: regole contestuali iniettate ALLA FINE del user message (recency bias)
302
+ ctx_rules = self._pick_context_rules(goal_display)
303
+ # S375: format directive — garantisce coerenza di formato anche sul path backend
304
+ fmt_directive = self._classify_format_directive(state.goal)
305
+ user_content = f"{context_part}{files_part}{mem_hint}{tool_section}\n\nObiettivo/Domanda: {goal_display}{ctx_rules}"
306
+ # S416-Fix1: inietta contesto file sessione — evita che LLM inventi interfacce già scritte
307
+ # nei file precedenti (import rotti, tipi incompatibili, app che non compila)
308
+ if session_files:
309
+ # Fix 6 (S421): selezione per rilevanza contestuale invece degli ultimi 3
310
+ # Seleziona i 4 file più rilevanti per lo step corrente (tool_results hint)
311
+ # S576: 200→400 — più contesto per la selezione file rilevanti
312
+ # S599: 400→600 — hint da tool results può contenere più parole chiave utili
313
+ _step_hint = tool_results[:600].lower() if tool_results else ""
314
+ def _relevance_score(item: "tuple[str, str]") -> int:
315
+ path, _ = item
316
+ name = path.lower().replace("/", " ").replace(".", " ").replace("-", " ")
317
+ return sum(1 for word in name.split() if len(word) > 2 and word in _step_hint)
318
+ _sf_sorted = sorted(session_files.items(), key=_relevance_score, reverse=True)
319
+ _sf_items = _sf_sorted[:5] # top 5 per rilevanza (S432: era 4, era ultimi 3 fissi)
320
+ _sf_ctx = "\n\n".join(
321
+ f"[FILE GIÀ SCRITTO: {p}]\n```\n{c}\n```" for p, c in _sf_items
322
+ )
323
+ # S437: GAP7 fix — cold files (oltre top-5) come skeleton compatto.
324
+ # Previene LLM che inventa import da file che ha già scritto ma non vede più.
325
+ # build_file_skeleton è sync (estrae firme funzioni/classi) → zero latenza.
326
+ _sf_cold = _sf_sorted[5:]
327
+ if _sf_cold:
328
+ try:
329
+ from agents.context_manager import build_file_skeleton as _bfs
330
+ _cold_lines = []
331
+ for _p, _c in _sf_cold:
332
+ _ext = _p.rsplit(".", 1)[-1] if "." in _p else ""
333
+ _lang_map = {
334
+ "ts": "typescript", "tsx": "typescript",
335
+ "js": "javascript", "jsx": "javascript",
336
+ "py": "python",
337
+ }
338
+ _lang = _lang_map.get(_ext, "")
339
+ _sk = _bfs(_p, _c, _lang)
340
+ if _sk:
341
+ _cold_lines.append(_sk)
342
+ if _cold_lines:
343
+ _sf_ctx += (
344
+ "\n\n[SKELETON FILE SECONDARI — tipi e interfacce già scritti, "
345
+ "importa da questi senza reinventarli]\n"
346
+ + "\n".join(_cold_lines)
347
+ )
348
+ except Exception:
349
+ pass # non-blocking — fallback: cold files non mostrati (comportamento pre-S437)
350
+ user_content = (
351
+ "[CONTESTO FILE GIÀ SCRITTI — usa QUESTI come riferimento per tipi, "
352
+ "import e interfacce; non inventarli]\n"
353
+ f"{_sf_ctx}\n\n{user_content}"
354
+ )
355
+ # B10: usa flag separato — non inquinare il context string con '__HAS_FILES__'
356
+ if files_ctx:
357
+ state.has_files = True
358
+ # S375: format directive iniettata nel system prompt (non nel user msg)
359
+ # per evitare confusione con i dati tool nel user content
360
+ system_with_fmt = f"{self._SYSTEM_IDENTITY}\n\n{fmt_directive}"
361
+ return [
362
+ {"role": "system", "content": system_with_fmt},
363
+ {"role": "user", "content": user_content},
364
+ ]
365
+
366
+ def _build_prompt(self, state: UnifiedLoopState, tool_results: str = "") -> str:
367
+ msgs = self._build_messages(state, tool_results)
368
+ return f"{msgs[0]['content']}\n\n{msgs[1]['content']}"
369
+
370
+ # ── S197: Code-block extractor ────────────────────────────────────────────
371
+ # Quando il goal contiene blocchi di codice grandi (>1800 chars totali),
372
+ # li estrae come file virtuali [FILE:N], riduce il messaggio al LLM e
373
+ # li inietta come sezione CODICE_FORNITO nel contesto di sistema.
374
+ # Questo previene i timeout del provider su prompt lunghi.
375
+
376
+ _CODE_BLOCK_RE = re.compile(
377
+ r'```(?P<lang>[a-zA-Z0-9_+-]*)\n?(?P<body>.+?)```',
378
+ re.DOTALL,
379
+ )
380
+
381
+ @staticmethod
382
+ def _guess_filename(lang: str, idx: int) -> str:
383
+ ext_map = {
384
+ 'typescript': 'ts', 'ts': 'ts', 'tsx': 'tsx',
385
+ 'javascript': 'js', 'js': 'js', 'jsx': 'jsx',
386
+ 'python': 'py', 'py': 'py',
387
+ 'sql': 'sql', 'prisma': 'prisma',
388
+ 'bash': 'sh', 'sh': 'sh',
389
+ 'json': 'json', 'yaml': 'yaml', 'yml': 'yml',
390
+ 'rust': 'rs', 'go': 'go', 'java': 'java',
391
+ 'css': 'css', 'html': 'html',
392
+ }
393
+ ext = ext_map.get(lang.lower().strip(), 'txt')
394
+ return f'file_{idx + 1}.{ext}'
395
+
agents/unified_loop_tools.py ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unified_loop_tools.py — DirectToolsMixin: tool execution layer.
2
+
3
+ Estratto da unified_loop.py per ridurre il file principale da 2541 a ~2000 righe.
4
+
5
+ Contiene (nell'ordine originale del file):
6
+ - Regex class attrs: meteo, URL, ricerca, immagini, calcolo
7
+ - Helper: _extract_city / _extract_search_query / _extract_calc_expr
8
+ - _run_direct_tools: layer deterministico parallelo via TOOL_REGISTRY (S193/S419)
9
+ - _FALSE_CLAIM_RE / _REALTIME_GOAL_RE / _validate_claims: anti-hallucination (S428)
10
+ - _TOOL_NEEDED_RE / _needs_tools / _SIMPLE_CONV_RE / _is_simple_query: routing (S402)
11
+
12
+ Invariante B1: nessun corpo duplicato con unified_loop.py.
13
+ Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoop.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import os
19
+ import re
20
+ from typing import Any, Awaitable, Callable
21
+
22
+ # Alias speculare a unified_loop.py (nessun cross-import per evitare circolari)
23
+ StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
24
+
25
+
26
+ class DirectToolsMixin:
27
+ # ── Direct tool execution (S193) ─────────────────────────────────────────
28
+ # Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
29
+ # Deterministico, veloce, testabile. Restituisce i risultati come stringa
30
+ # pronta per essere iniettata nel prompt LLM.
31
+
32
+ _WEATHER_INTENT_RE = re.compile(
33
+ # S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather
34
+ # S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN
35
+ r"\b(meteo|temperatura|temperature|temp\s*a\b|clima|weather|previsioni|forecast|"
36
+ r"che\s+tempo\s+fa|quanto\s+fa\s+freddo|quanto\s+fa\s+caldo|gradi\s+a\b|"
37
+ r"piove|sta\s+piovendo|nevica|neve|pioggia|temporale|grandine|"
38
+ r"nebbia|umidità|vento|allerta\s+meteo|allerta\s+rossa|allerta\s+arancione|"
39
+ r"ondata\s+di\s+caldo|ondata\s+di\s+freddo|gelate|gelo|"
40
+ r"rain|raining|snow|snowing|fog|humid|wind|windy|storm|thunderstorm|hail|"
41
+ r"sunny|cloudy|overcast|uv\s+index|heat\s+wave|cold\s+snap|frost)\b",
42
+ re.IGNORECASE,
43
+ )
44
+ # S385: improved city extraction — catches bare patterns like "che tempo fa a Roma?"
45
+ # S390-B-O: aggiunti trigger 'temperature\s+in' e 'weather\s+(?:forecast\s+)?(?:in|at|for)'
46
+ _CITY_RE = re.compile(
47
+ r"(?:meteo|temperatura|temperature|temp(?:eratura)?\s+(?:a|in)|"
48
+ r"(?:che\s+)?tempo\s+(?:\w+\s+){0,2}(?:fa\s+)?(?:a|in)|"
49
+ r"com['\u2019]è\s+il\s+tempo\s+a|com['\u2019]è\s+il\s+meteo\s+a|"
50
+ r"clima\s+(?:a|in)|weather\s+(?:forecast\s+)?(?:in|at|for)|"
51
+ r"temperature\s+(?:in|at|for)|forecast\s+(?:for|in)|"
52
+ r"previsioni\s+(?:per|a|in)|gradi\s+(?:a|in))"
53
+ r"\s+(?:a\s+|in\s+|per\s+)?([A-Za-z\xc0-\xff][A-Za-z\xc0-\xff\s]{1,25}?)"
54
+ # S390-B-I: aggiunti terminatori inglesi (today/now/tomorrow/currently/right now)
55
+ r"(?:\?|$|\s*[,\.]|\s+adesso|\s+ora|\s+oggi|\s+domani|\s+attuale|\s+corrente"
56
+ r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)",
57
+ re.IGNORECASE,
58
+ )
59
+ _CITY_BARE_RE = re.compile(
60
+ r"\b(?:a|in)\s+([A-Za-z\xc0-\xff][a-zA-Z\xc0-\xff]{2,20})"
61
+ r"(?:\s*[\?,\.]|\s+(?:adesso|ora|oggi|attuale|domani)|\s*$)",
62
+ re.IGNORECASE,
63
+ )
64
+
65
+ _URL_RE = re.compile(r"https?://[^\s\)\"']+")
66
+
67
+ # NOTE: patterns ending in non-word chars (: \s) are placed OUTSIDE the \b…\b wrapper
68
+ # to avoid false-negative from word-boundary check after non-word char.
69
+ _SEARCH_INTENT_RE = re.compile(
70
+ r"(?:"
71
+ r"\b(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo|informazioni)|"
72
+ r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
73
+ r"notizie\s+(?:recenti|di\s+oggi|aggiornate|live|breaking|su|sull[ao']+|di|riguard[ao]|dal\s+mondo)|"
74
+ r"notizie\s+\w+|" # B2/S390-B-J: usa \w+ (non [a-zA-Z]) — \b finale falliva con singola lettera
75
+ r"ultime\s+notizie|news\s+su|news\s+\w+|breaking\s+news|" # B2/S390-B-J
76
+ r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
77
+ r"cosa\s+e\s+uscito|aggiornamenti\s+su|release|changelog|"
78
+ r"search\s+for\s+|find\s+online\s+)\b"
79
+ r"|\bcerca\s*:|\bsearch\s*:"
80
+ r")",
81
+ re.IGNORECASE,
82
+ )
83
+ _SEARCH_QUERY_RE = re.compile(
84
+ r"(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing)?|"
85
+ r"cerca\s*:\s*['\"]?|search\s*:\s*['\"]?|search\s+for\s+|find\s+online\s+|"
86
+ r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
87
+ r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|" # B1: notizie su/sull/di + bare 'notizie X'
88
+ r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
89
+ r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
90
+ r"web\s+search\s*:?\s*)"
91
+ r"(['\"]?.{2,180}?['\"]?)(?:\?|$|\s*\.)", # B1: soglia da 3 a 2 per topic brevi (AI, LLM)
92
+ re.IGNORECASE,
93
+ )
94
+
95
+ _IMAGE_GEN_INTENT_RE = re.compile(
96
+ # S390-B-F: rimosso \b prima di (immagine|...) nel primo branch
97
+ # perché "unimmagine" (typo mobile italiano per "un'immagine") non ha word boundary
98
+ r"\b(genera|crea|disegna|illustra|fai|mostra)\b.*(immagine|foto|illustrazione|sfondo|logo|banner|png|jpg)"
99
+ r"|\b(immagine|foto)\b.*\b(ai|artificiale|generata|gen)\b"
100
+ r"|pollinations|dall[- ]e|stable\s*diffusion|midjourney|image\s+gen",
101
+ re.IGNORECASE
102
+ )
103
+ # S427: aggiunti trigger di calcolo IT/EN comuni
104
+ _CALC_INTENT_RE = re.compile(
105
+ r"\b(calcola|computa|quanto\s+fa|risultato\s+di|evaluate|compute|"
106
+ r"quant[oei]\s+[eè]|qual\s+[eè]\s+il\s+risultato|"
107
+ r"risolvi|risolvimi|dammi\s+il\s+valore|quanto\s+vale|"
108
+ r"how\s+much\s+is|what\s+is\s+the\s+result\s+of|"
109
+ r"solve\s+this|calculate\s+this|what\s+does\s+.{0,20}\s+equal)\b",
110
+ re.IGNORECASE,
111
+ )
112
+
113
+ _WEB_RESEARCH_INTENT_RE = re.compile(
114
+ r"\b(ricerca\s+approfondita|analisi\s+(?:multi|multi-fonte|fonti)|"
115
+ r"web\s+research|deep\s+research|esplora\s+(?:il\s+web|online)|"
116
+ r"approfondisci\s+(?:il\s+tema|l[a']|lo\s+)"
117
+ r"|\b(studia|analizza)\s+(?:nel\s+dettaglio|approfonditamente|in\s+modo\s+approfondito)",
118
+ re.IGNORECASE,
119
+ )
120
+ _WEB_RESEARCH_TOPIC_RE = re.compile(
121
+ r"(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s+(?:su\s+|di\s+|sul\s+tema\s+)?(.{3,200}?)(?:\?|$|\s*\.)",
122
+ re.IGNORECASE,
123
+ )
124
+ _CALC_EXPR_RE = re.compile(
125
+ r"(?:calcola|computa|risultato\s+di|quanto\s+fa|evaluate\s*:?)[:\s]+"
126
+ # S390-B-M: aggiunto % (modulo) e // (floor division) al char class
127
+ r"([\d\(\)\+\-\*\/\^\s\.\,%]+)",
128
+ re.IGNORECASE,
129
+ )
130
+
131
+ def _extract_city(self, goal: str) -> str:
132
+ m = self._CITY_RE.search(goal)
133
+ if m:
134
+ return m.group(1).strip()
135
+ m2 = self._CITY_BARE_RE.search(goal)
136
+ if m2:
137
+ city = m2.group(1).strip()
138
+ _stop = {"me", "te", "lui", "lei", "noi", "voi", "loro", "casa", "fare",
139
+ "meno", "piu", "dire", "cui", "poi", "gia", "qui", "li", "la"}
140
+ if city.lower() not in _stop:
141
+ return city
142
+ return ""
143
+
144
+ def _extract_search_query(self, goal: str) -> str:
145
+ m = self._SEARCH_QUERY_RE.search(goal)
146
+ if m:
147
+ q = m.group(1).strip().rstrip(".,?!")
148
+ if len(q) > 1: # B1: soglia da >3 a >1 — topic brevi come 'AI', 'LLM', 'GPT'
149
+ return q
150
+ if self._SEARCH_INTENT_RE.search(goal):
151
+ clean = re.sub(
152
+ r"^\s*(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet)?|"
153
+ r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
154
+ r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|"
155
+ r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
156
+ r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
157
+ r"web\s+search\s*:?\s*)",
158
+ "", goal.strip(), flags=re.IGNORECASE
159
+ ).strip().rstrip(".,?!")
160
+ if len(clean) > 1: # B1: soglia abbassata da >3 a >1
161
+ return clean
162
+ # B1: ultimo fallback — usa il goal intero (es. 'ultime notizie AI' → 'ultime notizie AI')
163
+ if len(goal.strip()) > 1:
164
+ return goal.strip()[:200] # S579: 120→200 (fallback query usa il goal intero)
165
+ return ""
166
+
167
+ def _extract_calc_expr(self, goal: str) -> str:
168
+ m = self._CALC_EXPR_RE.search(goal)
169
+ if m:
170
+ expr = m.group(1).strip().rstrip(".?!, ").replace(",", ".").replace("^", "**")
171
+ if re.search(r"[\d]", expr) and re.search(r"[\+\-\*\/\(\)]|\*\*", expr):
172
+ return expr
173
+ return ""
174
+
175
+ async def _run_direct_tools(self, goal: str, on_step: StepCallback | None = None) -> tuple[str, int, int, int]:
176
+ """
177
+ S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
178
+ Returns: 4-tuple (results_str, n_called, n_success, n_errors).
179
+ results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
180
+ n_called: numero totale di tool chiamati
181
+ n_success: tool che hanno prodotto dati reali verificati (prefisso REAL_DATA_PREFIXES)
182
+ n_errors: tool che NON hanno prodotto dati reali (falliti, timeout, skip)
183
+ S376: Tool Governor — previene chiamate duplicate identiche (stesso tool + stessi arg chiave).
184
+ S390: Return type cambiato da str a tuple[str, int] per fix tools_fired metric.
185
+ """
186
+ try:
187
+ from tools.registry import TOOL_REGISTRY
188
+ except ImportError:
189
+ # S649: fix tipo ritorno — run() aspetta 4-tuple, non 2-tuple
190
+ return "", 0, 0, 0
191
+
192
+ results: list[str] = []
193
+
194
+ # S376/S393: Tool Governor — previene duplicate E supero budget globale per run
195
+ _gov_called: set[str] = set()
196
+ _gov_total: list[int] = [0] # S393: contatore totale chiamate tool nel run
197
+ # S650: budget adattivo — task complessi necessitano più tool calls
198
+ # _max_tokens_for_goal >= 6144 indica app multi-feature → 9 tool calls
199
+ # _max_tokens_for_goal >= 4096 indica task singolo complesso → 7 tool calls
200
+ # Default: 6 (query semplice, meteo, news, calcolo)
201
+ _tok_budget_gov = self._max_tokens_for_goal(goal)
202
+ _GOV_MAX_CALLS = 9 if _tok_budget_gov >= 6144 else 7 if _tok_budget_gov >= 4096 else 6
203
+
204
+ def _gov_check(tool_name: str, key_arg: str) -> bool:
205
+ """S393 Tool Governor: previene duplicate e supero budget.
206
+ Returns True solo se il tool NON è stato già chiamato con questi arg
207
+ E il budget totale del run non è esaurito."""
208
+ if _gov_total[0] >= _GOV_MAX_CALLS:
209
+ return False # budget esaurito — blocca TUTTE le chiamate successive
210
+ sig = f"{tool_name}:{key_arg[:150]}" # S608: 80→150
211
+ if sig in _gov_called:
212
+ return False # chiamata duplicata — skip silenzioso
213
+ _gov_called.add(sig)
214
+ _gov_total[0] += 1
215
+ return True
216
+
217
+ # Doc2-1a-FIX: helper cache speculativa (S361) — 0ms latency su cache hit.
218
+ # get_speculative_result() non era mai chiamata: la cache veniva riempita (quota Groq)
219
+ # ma mai letta. Ora ogni tool controlla la cache prima di eseguire la chiamata di rete.
220
+ def _spec_hit(tool_name: str, args: dict) -> "str | None":
221
+ try:
222
+ from api.speculative import get_speculative_result as _gsr
223
+ return _gsr(goal, tool_name, args)
224
+ except Exception:
225
+ return None
226
+
227
+ # S419: esegui i tool eligible in parallelo con asyncio.gather
228
+ # Pre-check intent (sincrono) → costruisce lista coroutine → gather
229
+ # Il governor usa stato locale; asyncio è single-threaded → nessuna race condition
230
+
231
+ url_m = self._URL_RE.search(goal)
232
+
233
+ async def _t_get_weather() -> str | None:
234
+ if not self._WEATHER_INTENT_RE.search(goal):
235
+ return None
236
+ city = self._extract_city(goal) or "Milano"
237
+ if not _gov_check("get_weather", city):
238
+ return None
239
+ try:
240
+ _sc = _spec_hit("get_weather", {"city": city})
241
+ if _sc is not None:
242
+ return _sc
243
+ if on_step:
244
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
245
+ "title": f"Meteo: {city}", "explanation": f"Recupero dati meteo reali per {city}…"}))
246
+ _t0 = asyncio.get_event_loop().time()
247
+ r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
248
+ try:
249
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
250
+ except Exception: pass
251
+ if "error" not in r:
252
+ _wdesc = {
253
+ 0: "sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso",
254
+ 3: "coperto", 45: "nebbia", 48: "nebbia ghiacciata",
255
+ 51: "pioggerella leggera", 53: "pioggerella", 55: "pioggerella intensa",
256
+ 61: "pioggia leggera", 63: "pioggia", 65: "pioggia intensa",
257
+ 71: "neve leggera", 73: "neve", 75: "neve intensa",
258
+ 80: "rovesci leggeri", 81: "rovesci", 82: "rovesci forti",
259
+ 95: "temporale", 96: "temporale con grandine",
260
+ }
261
+ wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
262
+ try:
263
+ desc = _wdesc.get(int(wcode), f"codice {wcode}") if wcode is not None else "N/D"
264
+ except (TypeError, ValueError):
265
+ desc = "N/D"
266
+ return (
267
+ f"[METEO REALE — {r['city']}, {r.get('country', '')}]\n"
268
+ f"Temperatura attuale: {f'{temp_c}°C' if temp_c is not None else 'N/D'}\n"
269
+ f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
270
+ f"Condizioni: {desc}"
271
+ )
272
+ return f"[get_weather: errore — {r['error'][:300]}]" # S605: 200→300
273
+ except asyncio.TimeoutError:
274
+ return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
275
+ except Exception as exc:
276
+ return f"[get_weather: errore — {str(exc)[:300]}]" # S605: 200→300
277
+
278
+ async def _t_read_page() -> str | None:
279
+ if not url_m:
280
+ return None
281
+ url = url_m.group(0)
282
+ if not _gov_check("read_page", url):
283
+ return None
284
+ try:
285
+ _sc = _spec_hit("read_page", {"url": url})
286
+ if _sc is not None:
287
+ return _sc
288
+ if on_step:
289
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
290
+ "title": "Lettura pagina", "explanation": f"Leggo {url[:60]}…"}))
291
+ _t0 = asyncio.get_event_loop().time()
292
+ r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
293
+ try:
294
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
295
+ except Exception: pass
296
+ if r.get("content"):
297
+ return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
298
+ return f"[read_page: errore — {r.get('error', 'nessun contenuto')[:300]}]" # S605: 200→300
299
+ except asyncio.TimeoutError:
300
+ return f"[read_page: timeout {TOOL_TIMEOUT}s]"
301
+ except Exception as exc:
302
+ return f"[read_page: errore — {str(exc)[:300]}]" # S605: 200→300
303
+
304
+ async def _t_calculate() -> str | None:
305
+ if url_m or not self._CALC_INTENT_RE.search(goal):
306
+ return None
307
+ expr = self._extract_calc_expr(goal)
308
+ if not expr or not _gov_check("calculate", expr):
309
+ return None
310
+ try:
311
+ if on_step:
312
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
313
+ "title": "Calcolo", "explanation": f"Calcolo: {expr[:60]}"}))
314
+ _sc = _spec_hit("calculate", {"expression": expr})
315
+ if _sc is not None:
316
+ return _sc
317
+ _t0 = asyncio.get_event_loop().time()
318
+ r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
319
+ try:
320
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
321
+ except Exception: pass
322
+ if "result" in r:
323
+ return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
324
+ return f"[calculate: errore — {r.get('error', '?')[:300]}]" # S605: 200→300
325
+ except asyncio.TimeoutError:
326
+ return "[calculate: timeout]"
327
+ except Exception as exc:
328
+ return f"[calculate: errore — {str(exc)[:300]}]" # S605: 200→300
329
+
330
+ async def _t_web_search() -> str | None:
331
+ if not self._SEARCH_INTENT_RE.search(goal):
332
+ return None
333
+ query = self._extract_search_query(goal)
334
+ if not query or not _gov_check("web_search", query):
335
+ return None
336
+ try:
337
+ if on_step:
338
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
339
+ "title": "Ricerca web", "explanation": f"Cerco: {query[:60]}…"}))
340
+ _sc = _spec_hit("web_search", {"query": query})
341
+ if _sc is not None:
342
+ return _sc
343
+ _t0 = asyncio.get_event_loop().time()
344
+ r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
345
+ try:
346
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
347
+ except Exception: pass
348
+ hits = r.get("results", [])
349
+ if hits:
350
+ snippets = "\n".join(
351
+ f"• [{item['title']}] {item['snippet']}"
352
+ + (f"\n URL: {item['url']}" if item.get("url") else "")
353
+ for item in hits[:6] # S591: 4→6 — più risultati web nel context
354
+ )
355
+ return f"[RICERCA WEB REALE: '{query}']\n{snippets}"
356
+ # S428 Sprint1-Fix2: rimosso "rispondo con dati del training" — invitava LLM
357
+ # ad allucinare training data come se fosse una ricerca reale riuscita.
358
+ # Ora è un errore esplicito → contato come _n_errors → _all_errors=True →
359
+ # _build_messages usa sezione "TENTATIVO TOOL FALLITO" che proibisce false claim.
360
+ return f"[web_search: NESSUN_RISULTATO — nessun dato trovato per '{query[:150]}']" # S608: 80→150
361
+ except asyncio.TimeoutError:
362
+ return f"[web_search: TIMEOUT_{TOOL_TIMEOUT}s — nessun dato disponibile]"
363
+ except Exception as exc:
364
+ return f"[web_search: errore — {str(exc)[:300]}]" # S605: 200→300
365
+
366
+ async def _t_generate_image() -> str | None:
367
+ if not self._IMAGE_GEN_INTENT_RE.search(goal):
368
+ return None
369
+ _img_prompt = re.sub(
370
+ r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
371
+ "", goal, flags=re.IGNORECASE
372
+ ).strip() or goal
373
+ if not _gov_check("generate_image", _img_prompt):
374
+ return None
375
+ try:
376
+ if on_step:
377
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
378
+ "title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"}))
379
+ _sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]}) # S607: 400→600
380
+ if _sc is not None:
381
+ return _sc
382
+ _t0 = asyncio.get_event_loop().time()
383
+ r = await asyncio.wait_for(TOOL_REGISTRY["generate_image"]["_fn"](prompt=_img_prompt[:600]), timeout=12) # S607: 400→600
384
+ try:
385
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
386
+ except Exception: pass
387
+ img_url = r.get("url", "")
388
+ if img_url:
389
+ return (
390
+ f"[IMMAGINE AI GENERATA]\n"
391
+ f"URL: {img_url}\n"
392
+ f"Prompt usato: {r.get('prompt', _img_prompt)[:200]}\n" # S579: 100→200
393
+ f"Dimensioni: {r.get('width')}x{r.get('height')} px"
394
+ )
395
+ return "[generate_image: nessun URL restituito]"
396
+ except asyncio.TimeoutError:
397
+ return "[generate_image: timeout — provider non raggiungibile]"
398
+ except Exception as exc:
399
+ return f"[generate_image: errore — {str(exc)[:300]}]" # S605: 200→300
400
+
401
+ async def _t_run_python() -> str | None:
402
+ _RUN_CODE_RE = re.compile(
403
+ r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
404
+ r"execute\s+(?:python\s+)?code|lancia\s+(?:il\s+)?codice|"
405
+ r"esegui\s+(?:questo\s+|il\s+)?(?:script|programma))\b",
406
+ re.IGNORECASE,
407
+ )
408
+ if not _RUN_CODE_RE.search(goal):
409
+ return None
410
+ _code_m = re.search(r"[::]\s*(.+)$", goal, re.DOTALL)
411
+ _code = _code_m.group(1).strip() if _code_m else goal
412
+ _code = re.sub(r"^```(?:python)?\s*|\s*```$", "", _code.strip(), flags=re.DOTALL).strip()
413
+ if not _code or len(_code) <= 3 or not _gov_check("run_python", _code[:80]):
414
+ return None
415
+ try:
416
+ if on_step:
417
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
418
+ "title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"}))
419
+ _sc = _spec_hit("run_python", {"code": _code[:400]}) # S608: 200→400
420
+ if _sc is not None:
421
+ return _sc
422
+ _t0 = asyncio.get_event_loop().time()
423
+ r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
424
+ try:
425
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
426
+ except Exception: pass
427
+ if r.get("returncode", -1) == 0 and r.get("stdout"):
428
+ return (
429
+ f"[CODICE PYTHON ESEGUITO]\n"
430
+ f"```python\n{_code[:500]}\n```\n"
431
+ f"Output:\n```\n{r['stdout'][:1500]}\n```"
432
+ )
433
+ if r.get("error"):
434
+ return f"[run_python: errore — {r['error'][:300]}]" # S605: 200→300
435
+ if r.get("stderr"):
436
+ # S573: 200→400 — stderr spesso contiene tracebacks multi-riga
437
+ # S593: 400→600 — tracebacks Python possono superare 400 chars
438
+ return f"[run_python: stderr — {r['stderr'][:600]}]"
439
+ return None
440
+ except asyncio.TimeoutError:
441
+ return "[run_python: timeout 18s]"
442
+ except Exception as exc:
443
+ # S593: 200→300 — exception str può includere path + msg
444
+ # S600: 300→500 — parity con altri exception handler
445
+ return f"[run_python: errore — {str(exc)[:500]}]"
446
+
447
+
448
+ async def _t_web_research() -> str | None:
449
+ if not self._WEB_RESEARCH_INTENT_RE.search(goal):
450
+ return None
451
+ _topic_m = self._WEB_RESEARCH_TOPIC_RE.search(goal)
452
+ _topic = _topic_m.group(1).strip() if _topic_m else re.sub(
453
+ r"^.*?(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s*(?:su\s+|di\s+)?",
454
+ "", goal, flags=re.IGNORECASE
455
+ ).strip()[:200] or goal[:200]
456
+ if not _topic or not _gov_check("web_research", _topic):
457
+ return None
458
+ try:
459
+ if on_step:
460
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
461
+ "title": "Ricerca approfondita", "explanation": f"Analizzo fonti multiple: {_topic[:60]}…"}))
462
+ _sc = _spec_hit("web_research", {"topic": _topic[:400]})
463
+ if _sc is not None:
464
+ return _sc
465
+ _t0 = asyncio.get_event_loop().time()
466
+ r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](topic=_topic[:400], depth=4, synthesize=True), timeout=55)
467
+ try:
468
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
469
+ except Exception: pass
470
+ if r.get("ok"):
471
+ _synthesis = r.get("synthesis", "")
472
+ _sources = r.get("sources", [])
473
+ out = f"[RICERCA APPROFONDITA: '{r.get('topic', _topic)}'\n{r.get('count', 0)} fonti analizzate]\n"
474
+ if _synthesis:
475
+ out += f"Sintesi:\n{_synthesis[:1500]}\n\n"
476
+ if _sources:
477
+ for s in _sources[:4]:
478
+ out += f"• {s.get('title', s.get('url','?'))}: {s.get('excerpt', '')[:200]}\n"
479
+ return out.strip()
480
+ return f"[web_research: {r.get('error', 'nessun risultato')[:200]}]"
481
+ except asyncio.TimeoutError:
482
+ return "[web_research: timeout 55s]"
483
+ except Exception as exc:
484
+ return f"[web_research: errore — {str(exc)[:300]}]"
485
+
486
+ # S419: gather parallelo — esegui tutti i tool idonei contemporaneamente
487
+ _parallel_results = await asyncio.gather(
488
+ _t_get_weather(),
489
+ _t_read_page(),
490
+ _t_calculate(),
491
+ _t_web_search(),
492
+ _t_generate_image(),
493
+ _t_run_python(),
494
+ _t_web_research(),
495
+ return_exceptions=True,
496
+ )
497
+ for _pr in _parallel_results:
498
+ if isinstance(_pr, str):
499
+ results.append(_pr)
500
+
501
+ # S428 Sprint1-Fix1: Tool Success Contract — conta successi per prefisso positivo.
502
+ # Il vecchio check ": errore —"/": timeout" NON catturava "NESSUN_RISULTATO" e
503
+ # "rispondo con dati del training" → contati come successi → _build_messages
504
+ # wrappava come "DATI REALI RECUPERATI" → LLM allucinava training data come reale.
505
+ # Soluzione: whitelist di prefissi che certificano dati REALI verificati.
506
+ _REAL_DATA_PREFIXES = (
507
+ "[RICERCA WEB REALE",
508
+ "[METEO",
509
+ "[CALCOLO REALE",
510
+ "[IMMAGINE AI GENERATA",
511
+ "[CODICE PYTHON ESEGUITO",
512
+ "[PAGINA WEB",
513
+ "[DATI REALI",
514
+ "[RICERCA APPROFONDITA",
515
+ )
516
+ _n_success = sum(1 for r in results if any(r.startswith(p) for p in _REAL_DATA_PREFIXES))
517
+ _n_errors = len(results) - _n_success
518
+ # Sprint 5 ITEM 13: tool_failure_count — mai incrementato prima
519
+ if _n_errors > 0:
520
+ try:
521
+ from api.state import increment_stat as _inc_tf
522
+ _inc_tf("tool_failure_count")
523
+ except Exception:
524
+ pass
525
+ return "\n\n".join(results), len(results), _n_success, _n_errors
526
+
527
+ # ── Claim Validation (S428 Sprint1-Fix3) ─────────────────────────────────
528
+ # Quando tutti i tool hanno fallito, il LLM può ancora affermare "Ho trovato / Ho recuperato"
529
+ # nonostante le istruzioni di _build_messages. Questo post-processing aggiunge un disclaimer
530
+ # esplicito SOLO se rileva false claim nella risposta — non riscrive il testo, lo estende.
531
+ _FALSE_CLAIM_RE = re.compile(
532
+ r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
533
+ r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
534
+ r"i\s+risultati\s+(?:mostrano|indicano|confermano)|"
535
+ r"la\s+ricerca\s+ha\s+(?:trovato|restituito)|"
536
+ r"secondo\s+i\s+risultati|dalle\s+mie\s+ricerche|"
537
+ r"I\s+found|the\s+results?\s+show|based\s+on\s+(?:the\s+)?results?|"
538
+ r"according\s+to\s+(?:the\s+)?(?:search\s+)?results?)\b",
539
+ re.IGNORECASE,
540
+ )
541
+ _REALTIME_GOAL_RE = re.compile(
542
+ r"\b(notizie|news|ultime\s+notizie|cerca|ricerca\s+web|"
543
+ r"weather|meteo|previsioni|temperatura|"
544
+ r"bitcoin|ethereum|cambio\s+valuta|tasso|crypto|"
545
+ r"versione\s+(?:attuale|corrente|recente)|aggiornamenti\s+su|release)\b",
546
+ re.IGNORECASE,
547
+ )
548
+
549
+ @staticmethod
550
+ def _validate_claims(
551
+ response: str,
552
+ n_success: int,
553
+ n_errors: int,
554
+ goal: str,
555
+ false_claim_re: "re.Pattern[str]",
556
+ realtime_goal_re: "re.Pattern[str]",
557
+ ) -> str:
558
+ """S428 Sprint1-Fix3: Claim Validation.
559
+ Se tutti i tool hanno fallito (n_success=0, n_errors>0) E la risposta
560
+ contiene false claim di dati reali, aggiunge un disclaimer di trasparenza.
561
+ Non riscrive la risposta — la estende con una nota visibile all'utente.
562
+ """
563
+ if n_success > 0 or n_errors == 0:
564
+ return response # dati reali presenti o nessun tool eseguito → ok
565
+ if not realtime_goal_re.search(goal):
566
+ return response # goal non richiede dati live → ok
567
+ if not false_claim_re.search(response):
568
+ return response # nessuna false claim → ok
569
+ # Rileva false claim + goal realtime + tutti tool falliti
570
+ disclaimer = (
571
+ "\n\n---\n"
572
+ "⚠️ **Nota tecnica**: i servizi di ricerca in tempo reale non erano "
573
+ "raggiungibili durante questa risposta. Le informazioni sopra provengono "
574
+ "dal mio training e potrebbero non essere aggiornate. "
575
+ "Per dati live consulta: Google News, Reuters, BBC, Corriere della Sera "
576
+ "o il sito ufficiale della tecnologia."
577
+ )
578
+ return response + disclaimer
579
+
580
+ # ── _needs_tools (S193) — regex ampliata ─────────────────────────────────
581
+
582
+ # S427: ampliato con fenomeni meteo, valute, knowledge lookup, calcoli
583
+ _TOOL_NEEDED_RE = re.compile(
584
+ r"\b(meteo|previsioni|tempo\s+(?:fa|a\b)|temperatura|clima|weather|"
585
+ r"che\s+tempo\s+fa|quanto\s+(?:fa\s+)?(?:freddo|caldo)|gradi\s+a\b|"
586
+ r"piove|nevica|neve|temporale|nebbia|umidità|vento|forecast|"
587
+ r"notizie|news|cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo)|"
588
+ r"cerca\s*:|search\s*:|search\s+for\s+|find\s+online\s+|"
589
+ r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
590
+ r"ultime\s+notizie|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
591
+ r"aggiornamenti\s+su|bitcoin|ethereum|cambio\s+valuta|crypto|tasso\s+di\s+cambio|"
592
+ r"euro|dollaro|yen|sterlina|libbra|release|changelog|"
593
+ r"https?://|leggi\s+(?:la\s+)?pagina|leggi\s+(?:il\s+)?sito|fetch|scarica\s+da|"
594
+ r"wikipedia|chi\s+[eè]\b|chi\s+era\b|cosa\s+[eè]\b|storia\s+di\b|"
595
+ r"visita\s+(?:il\s+)?sito|apri\s+(?:la\s+)?pagina|"
596
+ r"calcola\b|computa\b|quanto\s+fa\s+[\d]|risultato\s+di\s+[\d(]|"
597
+ r"quant[oei]\s+[eè]|risolvi\b|risolvimi\b|"
598
+ r"genera.*immagine|crea.*immagine|genera.*foto|disegna\b|illustra\b|pollinations|image.*gen|"
599
+ r"run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|execute\s+(?:python\s+)?code|"
600
+ r"lancia\s+(?:il\s+)?codice|esegui\s+(?:questo\s+|il\s+)?(?:script|programma)|"
601
+ r"installa|pip\s+install|shell|bash|terminal|api\s+pubblica|"
602
+ r"traduci|traduzione|translate|che\s+(?:ore\s+sono|giorno\s+[eè])|"
603
+ # S648: email/PDF keyword
604
+ r"invia\s+email|scrivi\s+email|manda\s+email|invia\s+mail|"
605
+ r"send\s+email|send\s+mail|crea\s+pdf|genera\s+pdf|"
606
+ r"crea\s+documento|crea\s+report|create\s+pdf|generate\s+pdf)\b",
607
+ re.IGNORECASE,
608
+ )
609
+
610
+ def _needs_tools(self, goal: str) -> bool:
611
+ return bool(self._TOOL_NEEDED_RE.search(goal))
612
+
613
+ # ── S402: Fast Path ───────────────────────────────────────────────────────
614
+ # Query conversazionali semplici: bypass memoria/planner/verifier/goal_verifier.
615
+ # Target: <3s vs 20-60s per il full pipeline.
616
+
617
+ # S427: aggiunti ack comuni IT/EN per fast path più ampio
618
+ _SIMPLE_CONV_RE = re.compile(
619
+ r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
620
+ r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
621
+ r"come stai\??|come va\??|stai bene\??|chi sei\??|cosa sei\??|"
622
+ r"sei (?:un[ao']?\s+)?(?:ai|bot|intelligenza artificiale|assistente)\??|"
623
+ r"cosa (?:puoi fare|sai fare)\??|dimmi qualcosa di te|"
624
+ r"bravo|benissimo|magnifico|fantastico|geniale|ottima risposta|"
625
+ r"giusto|corretto|esattamente|d['\u2019]accordo|"
626
+ r"capisco|ho capito|inteso|compreso|ricevuto|"
627
+ r"s[iì] grazie|no grazie|va bene|va benissimo|"
628
+ r"thanks|thank you|ty|thx|great|nice|perfect|exactly|understood|"
629
+ r"got it|sure|right|agreed|makes sense|correct|good|"
630
+ r"good morning|good evening|good night"
631
+ r")\.?\s*[!?]?$",
632
+ re.IGNORECASE,
633
+ )
634
+
635
+ def _is_simple_query(self, goal: str) -> bool:
636
+ """S402: True per greeting/ack/identità semplice (<70 chars, no tool/code intent).
637
+ Attiva il fast path che salta memoria, planner, verifier e self-healing."""
638
+ g = goal.strip()
639
+ if len(g) > 70 or self._needs_tools(g):
640
+ return False
641
+ if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
642
+ return False
643
+ return bool(self._SIMPLE_CONV_RE.match(g))
api/agent.py CHANGED
@@ -12,11 +12,12 @@ import os, asyncio, json, uuid, time
12
  from typing import Optional
13
  from fastapi import APIRouter, HTTPException, Request, Body
14
  from fastapi.responses import StreamingResponse
15
- from pydantic import BaseModel
 
16
  from .state import (
17
  _agent_tasks, _task_checkpoints, _loop_registry,
18
  _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
19
- _get_mem_manager, _get_executor, _get_planner, _get_ai_client,
20
  ReasonLoopIn, AgentTaskIn,
21
  )
22
  from .speculative import fire_speculative_tools
@@ -76,6 +77,14 @@ async def agent_run_stream(body: ReasonLoopIn):
76
  memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
77
  )
78
  context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
 
 
 
 
 
 
 
 
79
  result = await loop.run(
80
  goal=body.goal, context=context_str,
81
  max_steps=body.max_steps, on_step=step_cb,
@@ -143,6 +152,18 @@ async def agent_run_stream(body: ReasonLoopIn):
143
  'generate_image': 'Generazione immagine AI',
144
  'execution_validator_fix': 'Auto-correzione codice (S393)',
145
  'tool_governor_skip': 'Tool già eseguito — risultato riutilizzato',
 
 
 
 
 
 
 
 
 
 
 
 
146
  }
147
  _act_q = item.get('action', '')
148
  if 'explanation' not in item:
@@ -175,6 +196,18 @@ async def agent_run_stream(body: ReasonLoopIn):
175
  'generate_image': 'progress',
176
  'run_python': 'progress',
177
  'tool_governor_skip': 'progress',
 
 
 
 
 
 
 
 
 
 
 
 
178
  # Debug — shown only when devMode active
179
  'direct_tools': 'debug',
180
  }
@@ -214,7 +247,14 @@ async def reason_loop(body: ReasonLoopIn):
214
  memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
215
  )
216
  context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
217
- result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps)
 
 
 
 
 
 
 
218
  if isinstance(result, dict):
219
  output_text = result.get('output', '') or ''
220
  engine_used = result.get('engine', 'unknown')
@@ -229,6 +269,7 @@ async def reason_loop(body: ReasonLoopIn):
229
  'source': 'backend_loop',
230
  'engine': engine_used,
231
  'errors': errors_list,
 
232
  }
233
  except Exception as e:
234
  print(f'[reason/loop] Error: {e}', flush=True)
@@ -236,6 +277,7 @@ async def reason_loop(body: ReasonLoopIn):
236
  'ok': False,
237
  'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.',
238
  'source': 'fallback',
 
239
  }
240
 
241
 
@@ -259,18 +301,31 @@ async def agent_kernel_status():
259
  }
260
 
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  @router.post('/api/agent-kernel/dispatch')
263
- async def agent_kernel_dispatch(body: dict = Body(...)):
264
  gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
265
  if not gh_token:
266
  raise HTTPException(503, detail={
267
  'error': 'no_github_token',
268
  'message': 'GITHUB_TOKEN non configurato nel backend.',
269
  })
270
- goal = body.get('goal', '').strip()
271
- mode = body.get('mode', 'plan')
272
- if not goal:
273
- raise HTTPException(400, detail='goal è obbligatorio')
274
  import httpx as _httpx
275
  try:
276
  async with _httpx.AsyncClient(timeout=15) as _hc:
@@ -320,12 +375,14 @@ async def create_agent_task(body: AgentTaskIn):
320
  # Brand new task
321
  created_at = int(time.time() * 1000)
322
  _agent_tasks[task_id] = {
323
- 'id': task_id,
324
- 'status': 'QUEUED',
325
- 'goal': body.goal,
326
- 'context': body.context,
327
- 'max_steps': body.max_steps,
328
- 'created_at': created_at,
 
 
329
  }
330
  # Persist asynchronously — never block the response
331
  asyncio.create_task(
@@ -360,7 +417,7 @@ async def list_agent_tasks(limit: int = 50, status: str = ''):
360
  is_live = reg is not None and not reg.get('done', True)
361
  mem_tasks.append({
362
  'taskId': tid,
363
- 'goal': (t.get('goal') or '')[:120],
364
  'status': t.get('status', 'UNKNOWN'),
365
  'maxSteps': t.get('max_steps', 8),
366
  'createdAt': t.get('created_at', 0),
@@ -380,7 +437,7 @@ async def list_agent_tasks(limit: int = 50, status: str = ''):
380
  continue # already included from memory
381
  sb_tasks.append({
382
  'taskId': r['task_id'],
383
- 'goal': (r.get('goal') or '')[:120],
384
  'status': r.get('status', 'UNKNOWN'),
385
  'maxSteps': r.get('max_steps', 8),
386
  'createdAt': r.get('created_at', 0),
@@ -540,6 +597,9 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
540
  _ctr[0] += 1
541
  s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
542
  reg_entry['event_buffer'].append(s)
 
 
 
543
  for q in list(reg_entry['subscriber_queues']):
544
  try:
545
  q.put_nowait(s)
@@ -567,6 +627,15 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
567
  _verifier = None
568
 
569
  context_str = '\n'.join(m.get('content', '') for m in task['context']) if task['context'] else ''
 
 
 
 
 
 
 
 
 
570
  loop = UnifiedAgentLoop(
571
  llm_client=client, critic=_critic, verifier=_verifier,
572
  memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
@@ -641,16 +710,19 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
641
  # S362: emit vfs_update when a file operation is detected
642
  _VFS_ACTIONS = ('write_file', 'file_write', 'create_file', 'delete_file', 'file_delete')
643
  if _action in _VFS_ACTIONS or step_data.get('file_path'):
 
 
 
644
  _vfs_file = (step_data.get('file_path') or
645
- step_data.get('result', '')[:120] or
646
- step_data.get('output', '')[:120])
647
  _vfs_op = 'delete' if 'delete' in _action else 'write'
648
- _sse('vfs_update', {'taskId': task_id, 'file': str(_vfs_file)[:200], 'op': _vfs_op})
649
 
650
  # S363-UI: thought event — emitted when planner completes
651
  if _action == 'plan' and step_data.get('status') == 'done':
652
  _plan_obj = step_data.get('result', step_data.get('output', ''))
653
- _thought = (_plan_obj.get('goal', '') if isinstance(_plan_obj, dict) else str(_plan_obj))[:280]
654
  if _thought:
655
  _sse('thought', {'taskId': task_id, 'text': _thought,
656
  'complexity': _plan_obj.get('complexity') if isinstance(_plan_obj, dict) else None})
@@ -661,7 +733,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
661
  'subtasks': [
662
  {
663
  'id': s.get('id', _si + 1),
664
- 'description': s.get('description', '')[:80],
665
  'tool': s.get('tool', ''),
666
  'status': 'pending',
667
  }
@@ -853,7 +925,35 @@ async def list_checkpoints():
853
  return {
854
  'count': len(_task_checkpoints),
855
  'checkpoints': [
856
- {'taskId': k, 'step': v['step'], 'goal': v['goal'][:80], 'age_ms': now - v['savedAt']}
857
  for k, v in _task_checkpoints.items()
858
  ],
859
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  from typing import Optional
13
  from fastapi import APIRouter, HTTPException, Request, Body
14
  from fastapi.responses import StreamingResponse
15
+ from pydantic import BaseModel, field_validator
16
+ from typing import Literal
17
  from .state import (
18
  _agent_tasks, _task_checkpoints, _loop_registry,
19
  _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
20
+ _get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
21
  ReasonLoopIn, AgentTaskIn,
22
  )
23
  from .speculative import fire_speculative_tools
 
77
  memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
78
  )
79
  context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
80
+ # S456-X5: prepend project context (projectMemory.getContext() dal frontend)
81
+ if body.project_context:
82
+ context_str = f"[PROGETTO CORRENTE]\n{body.project_context}\n\n{context_str}".strip()
83
+ # S456-X4: inject top failure patterns appresi dal selfLearning frontend
84
+ if body.learning_hints:
85
+ # S591: learning_hints[:3]→[:5] — più pattern appresi nel context
86
+ hints_str = "\n".join(f"- {h}" for h in body.learning_hints[:5])
87
+ context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
88
  result = await loop.run(
89
  goal=body.goal, context=context_str,
90
  max_steps=body.max_steps, on_step=step_cb,
 
152
  'generate_image': 'Generazione immagine AI',
153
  'execution_validator_fix': 'Auto-correzione codice (S393)',
154
  'tool_governor_skip': 'Tool già eseguito — risultato riutilizzato',
155
+ # S661: label narrative per tool aggiunti in S648-S659 — prima usavano
156
+ # _act_q.replace('_',' ').capitalize() → "Apply patch", "Call api" (generico)
157
+ 'apply_patch': 'Applico patch al file…',
158
+ 'call_api': 'Chiamo API REST…',
159
+ 'send_email': 'Invio email…',
160
+ 'create_pdf': 'Genero documento PDF…',
161
+ 'web_research': 'Ricerca multi-fonte…',
162
+ 'write_file': 'Scrivo file…',
163
+ 'read_file': 'Leggo file…',
164
+ 'execute_shell': 'Eseguo comando shell…',
165
+ 'analyze_image': 'Analizzo immagine…',
166
+ 'run_python': 'Eseguo Python (Pyodide)…',
167
  }
168
  _act_q = item.get('action', '')
169
  if 'explanation' not in item:
 
196
  'generate_image': 'progress',
197
  'run_python': 'progress',
198
  'tool_governor_skip': 'progress',
199
+ # S660: tool aggiunti in S648-S659 mancanti da _STEP_VISIBILITY →
200
+ # fallback rule: _act_q.startswith('tool_') era False per questi →
201
+ # classificati 'debug' → nascosti all'utente durante esecuzione.
202
+ 'apply_patch': 'progress',
203
+ 'call_api': 'progress',
204
+ 'send_email': 'progress',
205
+ 'create_pdf': 'progress',
206
+ 'web_research': 'progress',
207
+ 'write_file': 'progress',
208
+ 'read_file': 'progress',
209
+ 'execute_shell': 'progress',
210
+ 'analyze_image': 'progress',
211
  # Debug — shown only when devMode active
212
  'direct_tools': 'debug',
213
  }
 
247
  memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
248
  )
249
  context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
250
+ # N-2-FIX: accumula step intermedi tramite on_step — inclusi nel response JSON per debug frontend
251
+ _steps_log: list[dict] = []
252
+ async def _on_step(step_data: dict) -> None:
253
+ _steps_log.append({
254
+ 'action': step_data.get('action', ''),
255
+ 'output': str(step_data.get('output', ''))[:400], # S577: 200→400
256
+ })
257
+ result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step)
258
  if isinstance(result, dict):
259
  output_text = result.get('output', '') or ''
260
  engine_used = result.get('engine', 'unknown')
 
269
  'source': 'backend_loop',
270
  'engine': engine_used,
271
  'errors': errors_list,
272
+ 'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
273
  }
274
  except Exception as e:
275
  print(f'[reason/loop] Error: {e}', flush=True)
 
277
  'ok': False,
278
  'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.',
279
  'source': 'fallback',
280
+ 'steps': [],
281
  }
282
 
283
 
 
301
  }
302
 
303
 
304
+ # S442-FIX3: modello Pydantic per agent_kernel_dispatch.
305
+ # Prima: body: dict grezzo → mode non validato, goal controllato solo dopo estrazione.
306
+ # Ora: validazione in ingresso → 422 chiaro invece di 500 a runtime.
307
+ class AgentKernelDispatchIn(BaseModel):
308
+ goal: str
309
+ mode: Literal["plan", "execute", "analyze"] = "plan"
310
+
311
+ @field_validator('goal', mode='before')
312
+ @classmethod
313
+ def validate_goal(cls, v: object) -> str:
314
+ if not isinstance(v, str) or not str(v).strip():
315
+ raise ValueError('goal must be a non-empty string')
316
+ return str(v).strip()
317
+
318
+
319
  @router.post('/api/agent-kernel/dispatch')
320
+ async def agent_kernel_dispatch(body: AgentKernelDispatchIn):
321
  gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
322
  if not gh_token:
323
  raise HTTPException(503, detail={
324
  'error': 'no_github_token',
325
  'message': 'GITHUB_TOKEN non configurato nel backend.',
326
  })
327
+ goal = body.goal
328
+ mode = body.mode
 
 
329
  import httpx as _httpx
330
  try:
331
  async with _httpx.AsyncClient(timeout=15) as _hc:
 
375
  # Brand new task
376
  created_at = int(time.time() * 1000)
377
  _agent_tasks[task_id] = {
378
+ 'id': task_id,
379
+ 'status': 'QUEUED',
380
+ 'goal': body.goal,
381
+ 'context': body.context,
382
+ 'max_steps': body.max_steps,
383
+ 'created_at': created_at,
384
+ 'project_context': body.project_context, # S456-X5
385
+ 'learning_hints': body.learning_hints, # S456-X4
386
  }
387
  # Persist asynchronously — never block the response
388
  asyncio.create_task(
 
417
  is_live = reg is not None and not reg.get('done', True)
418
  mem_tasks.append({
419
  'taskId': tid,
420
+ 'goal': (t.get('goal') or '')[:300], # S606: 200→300
421
  'status': t.get('status', 'UNKNOWN'),
422
  'maxSteps': t.get('max_steps', 8),
423
  'createdAt': t.get('created_at', 0),
 
437
  continue # already included from memory
438
  sb_tasks.append({
439
  'taskId': r['task_id'],
440
+ 'goal': (r.get('goal') or '')[:300], # S606: 200→300
441
  'status': r.get('status', 'UNKNOWN'),
442
  'maxSteps': r.get('max_steps', 8),
443
  'createdAt': r.get('created_at', 0),
 
597
  _ctr[0] += 1
598
  s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
599
  reg_entry['event_buffer'].append(s)
600
+ # N-5-FIX: cap buffer a 500 eventi — evita crescita illimitata su task lunghi
601
+ if len(reg_entry['event_buffer']) > 500:
602
+ reg_entry['event_buffer'] = reg_entry['event_buffer'][-500:]
603
  for q in list(reg_entry['subscriber_queues']):
604
  try:
605
  q.put_nowait(s)
 
627
  _verifier = None
628
 
629
  context_str = '\n'.join(m.get('content', '') for m in task['context']) if task['context'] else ''
630
+ # S456-X5/X4: inject project context + learning hints stored at task creation
631
+ _proj_ctx = task.get('project_context', '')
632
+ if _proj_ctx:
633
+ context_str = f"[PROGETTO CORRENTE]\n{_proj_ctx}\n\n{context_str}".strip()
634
+ _hints = task.get('learning_hints', [])
635
+ if _hints:
636
+ # S591: _hints[:3]→[:5] — più pattern appresi nel context (task replay)
637
+ hints_str = "\n".join(f"- {h}" for h in _hints[:5])
638
+ context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
639
  loop = UnifiedAgentLoop(
640
  llm_client=client, critic=_critic, verifier=_verifier,
641
  memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
 
710
  # S362: emit vfs_update when a file operation is detected
711
  _VFS_ACTIONS = ('write_file', 'file_write', 'create_file', 'delete_file', 'file_delete')
712
  if _action in _VFS_ACTIONS or step_data.get('file_path'):
713
+ # S581: 120→200 — path file spesso 120-200 chars
714
+ # S596: 200→400 — result/output può contenere path completo di progetto
715
+ # S604: 400→500 — parity con altri campi step
716
  _vfs_file = (step_data.get('file_path') or
717
+ step_data.get('result', '')[:500] or
718
+ step_data.get('output', '')[:500])
719
  _vfs_op = 'delete' if 'delete' in _action else 'write'
720
+ _sse('vfs_update', {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op})
721
 
722
  # S363-UI: thought event — emitted when planner completes
723
  if _action == 'plan' and step_data.get('status') == 'done':
724
  _plan_obj = step_data.get('result', step_data.get('output', ''))
725
+ _thought = (_plan_obj.get('goal', '') if isinstance(_plan_obj, dict) else str(_plan_obj))[:400] # S604: 280→400
726
  if _thought:
727
  _sse('thought', {'taskId': task_id, 'text': _thought,
728
  'complexity': _plan_obj.get('complexity') if isinstance(_plan_obj, dict) else None})
 
733
  'subtasks': [
734
  {
735
  'id': s.get('id', _si + 1),
736
+ 'description': s.get('description', '')[:200], # S581: 80→200
737
  'tool': s.get('tool', ''),
738
  'status': 'pending',
739
  }
 
925
  return {
926
  'count': len(_task_checkpoints),
927
  'checkpoints': [
928
+ {'taskId': k, 'step': v['step'], 'goal': v['goal'][:300], 'age_ms': now - v['savedAt']} # S606: 200→300
929
  for k, v in _task_checkpoints.items()
930
  ],
931
  }
932
+
933
+
934
+ # ─── Sprint 5 ITEM 15: /debug/timing — telemetria timing + qualità agente ────
935
+ # Usato da TelemetryDashboard.tsx (frontend) per la sezione "Qualità agente".
936
+ # Espone: timing_stats (avg/count per fase) + repair_stats (contatori qualità).
937
+ # Non richiede auth — dati aggregati, nessun dato sensibile.
938
+ @router.get('/debug/timing')
939
+ async def get_debug_timing():
940
+ """
941
+ Espone timing breakdown per fase (classify/plan/coder/verifier/browser)
942
+ e contatori qualità (goal_success, repair_success, tool_failure, req_engine).
943
+ Formato: { timing_stats: {label: {avg, count}}, repair_stats: {key: count} }
944
+ """
945
+ try:
946
+ from api.state import _TIMING_STORE, _REPAIR_STATS
947
+ timing_stats: dict = {}
948
+ for label, samples in _TIMING_STORE.items():
949
+ if samples:
950
+ avg_val = round(sum(samples) / len(samples), 1)
951
+ else:
952
+ avg_val = None
953
+ timing_stats[label] = {"avg": avg_val, "count": len(samples)}
954
+ return {
955
+ "timing_stats": timing_stats,
956
+ "repair_stats": dict(_REPAIR_STATS),
957
+ }
958
+ except Exception as exc:
959
+ return {"timing_stats": {}, "repair_stats": {}, "error": str(exc)}
api/browser.py CHANGED
@@ -5,10 +5,30 @@ S65 — /screenshot + /navigate stateless (mantenuti per retrocompat)
5
  S_NEW — /open + /act + /close con sessioni persistenti + DOM Intelligence
6
  S174 — GET /screenshot/{session_id} snapshot sessione attiva senza azioni
7
 
 
 
 
 
 
 
 
 
8
  Vincoli HF free tier:
9
  - Max SESSION_LIMIT sessioni vive (OOM guard: Chromium ~300 MB/sessione)
10
  - Timeout sessione: SESSION_TTL_S secondi di inattività
11
  - Un solo _browser_lock per aprire nuove sessioni (evita race condition)
 
 
 
 
 
 
 
 
 
 
 
 
12
  """
13
  import asyncio, base64, hashlib, os, time, uuid, logging
14
  from typing import Optional, Any
@@ -19,13 +39,13 @@ router = APIRouter(prefix="/api/browser", tags=["browser"])
19
  _logger = logging.getLogger("browser")
20
 
21
  # ─── Costanti ─────────────────────────────────────────────────────────────────
22
- SESSION_LIMIT = 2 # max sessioni vive (OOM guard HF free)
23
- SESSION_TTL_S = 480 # 8 min inattività → cleanup
24
- GOTO_TIMEOUT = 15_000 # ms
25
- ACTION_TIMEOUT = 5_000 # ms
26
  MAX_LINKS = 25
27
  MAX_INPUTS = 20
28
- MAX_TEXT = 3000
29
 
30
  # ─── Lock + registry sessioni ─────────────────────────────────────────────────
31
  _browser_lock = asyncio.Lock()
@@ -56,9 +76,80 @@ _BLOCKED = [
56
  "metadata.google", "169.254",
57
  ]
58
 
59
- # ─── DOM Intelligence script (potenziato: role/aria, form state, modal, heading) ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  _DOM_SCRIPT = """() => {
61
- // ── Link cliccabili ────────────────────────────────────────────────────────
62
  const links = [...document.querySelectorAll('a[href]')]
63
  .filter(a => a.href.startsWith('http') && a.innerText.trim())
64
  .slice(0, %d)
@@ -70,7 +161,6 @@ _DOM_SCRIPT = """() => {
70
  : (a.className ? '.' + a.className.split(' ').filter(c=>c&&!c.match(/^[a-z]{1,2}$/)).slice(0,2).join('.') : 'a'))
71
  }));
72
 
73
- // ── Input / form elements con stato ───────────────────────────────────────
74
  const inputs = [...document.querySelectorAll(
75
  'input:not([type=hidden]),textarea,select,button,[role=button],[role=checkbox],[role=radio],[role=switch],[role=combobox]'
76
  )]
@@ -80,7 +170,6 @@ _DOM_SCRIPT = """() => {
80
  })
81
  .slice(0, %d)
82
  .map(el => {
83
- // Selector strategy: id > name > aria-label > placeholder > role+text
84
  let selector = null;
85
  const role = el.getAttribute('role') || el.tagName.toLowerCase();
86
  if (el.id) selector = '#' + el.id;
@@ -88,32 +177,25 @@ _DOM_SCRIPT = """() => {
88
  else if (el.getAttribute('aria-label')) selector = '[aria-label="' + el.getAttribute('aria-label') + '"]';
89
  else if (el.placeholder) selector = '[placeholder="' + el.placeholder + '"]';
90
  else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
91
-
92
- // Form state
93
  const tag = el.tagName.toLowerCase();
94
  const isChecked = el.checked !== undefined ? el.checked : null;
95
  const currentVal = (tag === 'input' || tag === 'textarea') ? (el.value || '') : null;
96
  const isDisabled = el.disabled || el.getAttribute('aria-disabled') === 'true';
97
-
98
- // Label from DOM: label[for], aria-labelledby, closest label
99
  let label = el.getAttribute('aria-label') || el.placeholder || el.getAttribute('name') || el.id;
100
  if (!label) {
101
  const lbl = el.id ? document.querySelector('label[for="'+el.id+'"]') : el.closest('label');
102
  if (lbl) label = lbl.innerText.trim().slice(0, 40);
103
  }
104
  if (!label) label = el.innerText?.trim().slice(0, 40) || null;
105
-
106
  return { tag, role, type: el.type || null, label, selector,
107
  value: currentVal, checked: isChecked, disabled: isDisabled || false };
108
  })
109
  .filter(el => el.label || el.selector);
110
 
111
- // ── Titoli heading per navigazione semantica ──────────────────────────────
112
  const headings = [...document.querySelectorAll('h1,h2,h3')]
113
  .slice(0, 8)
114
  .map(h => ({ level: h.tagName.toLowerCase(), text: h.innerText.trim().slice(0, 80) }));
115
 
116
- // ── Rilevamento modal/dialog/popup ────────────────────────────────────────
117
  const modals = [...document.querySelectorAll(
118
  '[role=dialog],[role=alertdialog],[role=modal],.modal,.dialog,[aria-modal=true]'
119
  )]
@@ -128,7 +210,6 @@ _DOM_SCRIPT = """() => {
128
  selector: el.id ? '#' + el.id : (el.getAttribute('aria-label') ? '[aria-label="'+el.getAttribute('aria-label')+'"]' : '[role="'+(el.getAttribute('role')||'dialog')+'"]'),
129
  }));
130
 
131
- // ── Testo principale ──────────────────────────────────────────────────────
132
  const title = document.title;
133
  const desc = document.querySelector('meta[name=description]')?.content?.slice(0, 200) || null;
134
  const mainEl = document.querySelector('main,[role=main],article,.content,#content') || document.body;
@@ -144,6 +225,112 @@ def _safe_url(url: str) -> bool:
144
  return url.startswith(("http://", "https://")) and not any(b in low for b in _BLOCKED)
145
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  async def _try_persist_screenshot(url: str, png_b64: str, title: str) -> None:
148
  try:
149
  sb_url = os.getenv("SUPABASE_URL", "")
@@ -276,8 +463,8 @@ class DomSnapshot(BaseModel):
276
  text: Optional[str] = None
277
  links: list[dict] = []
278
  inputs: list[dict] = []
279
- headings: list[dict] = [] # h1/h2/h3 per navigazione semantica
280
- modals: list[dict] = [] # dialog/modal visibili
281
 
282
  class BrowserResult(BaseModel):
283
  ok: bool
@@ -288,33 +475,80 @@ class BrowserResult(BaseModel):
288
  text_content: Optional[str] = None
289
  dom: Optional[DomSnapshot] = None
290
  error: Optional[str] = None
291
- warnings: list[str] = [] # anti-loop, modal, ecc.
292
-
293
 
294
- # ─── Sprint 3b ITEM 8: verify_goal_browser ───────────────────────────────────
295
 
 
 
296
  async def verify_goal_browser(
297
  goal: str,
298
  url: str,
299
  requirements: "list | None" = None,
300
  timeout_s: float = 30.0,
301
  ) -> dict:
302
- """
303
- Verifica goal via Playwright headless — per ogni acceptance criterion
304
- esegue un check sulla pagina live.
305
-
306
- Limiti:
307
- - Max 5 criteri per run (performance guard)
308
- - Timeout 30s globale
309
- - Fallback silente se Playwright non disponibile
310
-
311
- Returns: {ok, overall: "PASS"|"FAIL"|"UNKNOWN", per_criterion: {desc: verdict}, error}
312
- """
313
- if not requirements:
314
- return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": None}
315
  if not _safe_url(url):
316
  return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "URL non consentita"}
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  per_criterion: dict[str, str] = {}
319
 
320
  try:
@@ -327,13 +561,13 @@ async def verify_goal_browser(
327
  )
328
  _page = await _ctx.new_page()
329
  try:
 
330
  await asyncio.wait_for(
331
- _page.goto(url, wait_until="domcontentloaded"),
332
  timeout=min(timeout_s, GOTO_TIMEOUT / 1000),
333
  )
334
  await _page.wait_for_timeout(1000)
335
 
336
- # Raccoglie acceptance criteria dai requisiti (max 5 totali)
337
  _criteria: list[str] = []
338
  for _req in (requirements or [])[:5]:
339
  _ac = getattr(_req, "acceptance_criteria", None) or []
@@ -357,7 +591,7 @@ async def verify_goal_browser(
357
  _el = await _page.query_selector("form, input, textarea, button[type=submit]")
358
  _verdict = "PASS" if _el else "FAIL"
359
  elif any(k in _low for k in ["errore", "error", "400", "401", "403", "fallisce"]):
360
- _verdict = "UNKNOWN" # non testabile senza credenziali errate
361
  else:
362
  _title = await _page.title()
363
  _verdict = "PASS" if _title else "FAIL"
@@ -382,10 +616,10 @@ async def verify_goal_browser(
382
  except ImportError:
383
  return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "Playwright non installato"}
384
  except Exception as _e:
385
- return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:200]}
386
 
387
 
388
- # ─── Endpoint stateless: /screenshot ─────────────────────────────────────────
389
 
390
  @router.post("/screenshot", response_model=BrowserResult)
391
  async def browser_screenshot(req: ScreenshotRequest):
@@ -400,7 +634,10 @@ async def browser_screenshot(req: ScreenshotRequest):
400
  ctx = await _make_context(browser, req.width, req.height, req.mobile)
401
  page = await ctx.new_page()
402
  try:
403
- await page.goto(req.url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT)
 
 
 
404
  await page.wait_for_timeout(req.wait_ms)
405
  png = await page.screenshot(type="png", full_page=False)
406
  title = await page.title()
@@ -408,19 +645,22 @@ async def browser_screenshot(req: ScreenshotRequest):
408
  asyncio.create_task(_try_persist_screenshot(req.url, png_b64, title))
409
  return BrowserResult(ok=True, screenshot_b64=png_b64, title=title, url=page.url)
410
  except Exception as e:
411
- return BrowserResult(ok=False, error=str(e)[:300])
412
  finally:
413
  await ctx.close()
414
  await browser.close()
415
  except Exception as e:
416
- return BrowserResult(ok=False, error=str(e)[:400])
417
 
418
 
419
- # ─── Endpoint stateless: /navigate ────────────────────────────────────────────
420
 
421
  @router.post("/navigate", response_model=BrowserResult)
422
  async def browser_navigate(req: NavigateRequest):
423
- """Naviga, esegui azioni, restituisce screenshot + testo (stateless)."""
 
 
 
424
  if not _safe_url(req.url):
425
  raise HTTPException(400, "URL non consentita")
426
  async with _browser_lock:
@@ -431,44 +671,45 @@ async def browser_navigate(req: NavigateRequest):
431
  ctx = await _make_context(browser, req.width, req.height, req.mobile)
432
  page = await ctx.new_page()
433
  try:
434
- await page.goto(req.url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT)
 
 
 
435
  await page.wait_for_timeout(500)
436
  await _execute_actions(page, req.actions)
437
  await page.wait_for_timeout(req.wait_ms)
 
438
  png = await page.screenshot(type="png", full_page=False)
439
  title = await page.title()
440
- text = await page.evaluate(
441
- "() => (document.querySelector('main,[role=main],article') || document.body)"
442
- ".innerText.slice(0,2000)"
443
- )
444
  png_b64 = base64.b64encode(png).decode()
445
  asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
446
  return BrowserResult(
447
  ok=True, screenshot_b64=png_b64, title=title,
448
- url=page.url, text_content=str(text)[:2000],
449
  )
450
  except Exception as e:
451
- return BrowserResult(ok=False, error=str(e)[:300])
452
  finally:
453
  await ctx.close()
454
  await browser.close()
455
  except Exception as e:
456
- return BrowserResult(ok=False, error=str(e)[:400])
457
 
458
 
459
- # ─── Endpoint sessioni persistenti: /open ─────────────────────────────────────
460
 
461
  @router.post("/open", response_model=BrowserResult)
462
  async def browser_open(req: BrowserOpenRequest):
463
  """
464
  Apre una sessione Playwright persistente, naviga all'URL, restituisce
465
- session_id + screenshot + mappa DOM (links, inputs, testo).
466
- Usa browser_act per azioni successive, browser_close per chiudere.
467
  """
468
  if not _safe_url(req.url):
469
  raise HTTPException(400, "URL non consentita")
470
 
471
- # OOM guard: chiudi sessione più vecchia se al limite
472
  if len(_sessions) >= SESSION_LIMIT:
473
  oldest = min(_sessions, key=lambda sid: _sessions[sid]["last_used"])
474
  await _close_session(oldest, "OOM guard")
@@ -478,10 +719,13 @@ async def browser_open(req: BrowserOpenRequest):
478
  from playwright.async_api import async_playwright
479
  pw = await async_playwright().start()
480
  browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
481
- ctx = await _make_context(browser, req.width, req.height, req.mobile)
482
  page = await ctx.new_page()
483
 
484
- await page.goto(req.url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT)
 
 
 
485
  await page.wait_for_timeout(500)
486
  await _execute_actions(page, req.actions)
487
  await page.wait_for_timeout(req.wait_ms)
@@ -490,16 +734,16 @@ async def browser_open(req: BrowserOpenRequest):
490
  title = await page.title()
491
  png_b64 = base64.b64encode(png).decode()
492
  dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
 
 
493
 
494
  sid = uuid.uuid4().hex[:16]
495
  _sessions[sid] = {
496
  "pw": pw, "browser": browser, "context": ctx, "page": page,
497
  "created_at": time.time(), "last_used": time.time(), "url": page.url,
498
- # Anti-loop: ultimi 20 selettori cliccati (con contatore)
499
- "click_history": [], # list of selector strings
500
- # Stato navigazione: URL visitati + log azioni
501
- "visited_urls": [page.url],
502
- "action_log": [], # list of {"type", "selector", "ok", "url_after"}
503
  }
504
  asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
505
 
@@ -508,18 +752,19 @@ async def browser_open(req: BrowserOpenRequest):
508
  ok=True, session_id=sid,
509
  screenshot_b64=png_b64, title=title, url=page.url,
510
  dom=dom,
 
511
  )
512
  except Exception as e:
513
- return BrowserResult(ok=False, error=str(e)[:400])
514
 
515
 
516
- # ─── Endpoint sessioni persistenti: /act ──────────────────────────────────────
517
 
518
  @router.post("/act", response_model=BrowserResult)
519
  async def browser_act(req: BrowserActRequest):
520
  """
521
- Esegue azioni (click/fill/scroll/ecc.) su una sessione aperta.
522
- Restituisce screenshot aggiornato + mappa DOM corrente + warnings anti-loop.
523
  """
524
  sess = _sessions.get(req.session_id)
525
  if not sess:
@@ -529,9 +774,8 @@ async def browser_act(req: BrowserActRequest):
529
  page = sess["page"]
530
  warnings: list[str] = []
531
 
532
- # ── Anti-loop guard ────────────────────────────────────────────────────────
533
  CLICK_HISTORY_MAX = 20
534
- LOOP_THRESHOLD = 3 # stesso selettore ≥ 3 volte = loop
535
  for action in req.actions:
536
  if action.type == "click" and action.selector:
537
  history: list[str] = sess.get("click_history", [])
@@ -552,13 +796,11 @@ async def browser_act(req: BrowserActRequest):
552
  url_after = page.url
553
  sess["url"] = url_after
554
 
555
- # Traccia URL visitati (dedup)
556
  visited: list[str] = sess.get("visited_urls", [])
557
  if url_after not in visited:
558
  visited.append(url_after)
559
  sess["visited_urls"] = visited[-30:]
560
 
561
- # Log azione
562
  action_log: list[dict] = sess.get("action_log", [])
563
  for a in req.actions:
564
  action_log.append({
@@ -571,10 +813,12 @@ async def browser_act(req: BrowserActRequest):
571
  dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
572
  dom = DomSnapshot(**dom_raw) if isinstance(dom_raw, dict) else None
573
 
574
- # Modal detection warning
575
  if dom and dom.modals:
576
- modal_titles = [m.get("title") or m.get("role","modal") for m in dom.modals]
577
- warnings.append(f"🔔 MODAL RILEVATO: {', '.join(str(t) for t in modal_titles)} — potrebbe bloccare le azioni. Chiudilo prima di continuare.")
 
 
 
578
 
579
  result = BrowserResult(
580
  ok=True, session_id=req.session_id,
@@ -589,10 +833,10 @@ async def browser_act(req: BrowserActRequest):
589
 
590
  return result
591
  except Exception as e:
592
- return BrowserResult(ok=False, session_id=req.session_id, error=str(e)[:400], warnings=warnings)
593
 
594
 
595
- # ─── Endpoint sessioni persistenti: /close ────────────────────────────────────
596
 
597
  @router.post("/close", response_model=BrowserResult)
598
  async def browser_close(req: BrowserCloseRequest):
@@ -603,21 +847,11 @@ async def browser_close(req: BrowserCloseRequest):
603
  return BrowserResult(ok=True, session_id=req.session_id)
604
 
605
 
606
- # ─── Endpoint sessioni persistenti: GET /screenshot/{session_id} ─────────────
607
 
608
  @router.get("/screenshot/{session_id}", response_model=BrowserResult)
609
  async def browser_session_screenshot(session_id: str, full_page: bool = False):
610
- """
611
- Cattura uno screenshot della pagina corrente di una sessione attiva
612
- senza eseguire alcuna azione. Aggiorna last_used per evitare TTL scaduto.
613
-
614
- Path param:
615
- session_id — ID sessione da browser_open
616
-
617
- Query param:
618
- full_page (bool, default False) — True = screenshot intera pagina,
619
- False = solo viewport visibile
620
- """
621
  sess = _sessions.get(session_id)
622
  if not sess:
623
  raise HTTPException(404, f"Sessione {session_id} non trovata o scaduta")
@@ -635,10 +869,10 @@ async def browser_session_screenshot(session_id: str, full_page: bool = False):
635
  screenshot_b64=png_b64, title=title, url=page.url,
636
  )
637
  except Exception as e:
638
- return BrowserResult(ok=False, session_id=session_id, error=str(e)[:400])
639
 
640
 
641
- # ─── Debug: lista sessioni attive ─────────────────────────────────────────────
642
 
643
  @router.get("/sessions")
644
  async def list_sessions():
@@ -653,5 +887,4 @@ async def list_sessions():
653
  }
654
 
655
 
656
- # Avvia il loop di cleanup background al boot del modulo
657
  _start_cleanup()
 
5
  S_NEW — /open + /act + /close con sessioni persistenti + DOM Intelligence
6
  S174 — GET /screenshot/{session_id} snapshot sessione attiva senza azioni
7
 
8
+ W-NAV aggiornamenti:
9
+ - MAX_TEXT 3000→6000: più contesto per l'LLM senza rischiare OOM
10
+ - _goto_with_networkidle(): networkidle per SPA/React/Vue, fallback domcontentloaded
11
+ - _dismiss_cookie_banner(): auto-dismiss CSS+JS prima dell'estrazione DOM (W-NAV2)
12
+ - _extract_text_trafilatura(): estrazione mainbody Readability-quality (W-NAV)
13
+ - /navigate: usa trafilatura per text_content (da 2000→5000 chars utili)
14
+ - /open: aggiunto text_content via trafilatura nella risposta
15
+
16
  Vincoli HF free tier:
17
  - Max SESSION_LIMIT sessioni vive (OOM guard: Chromium ~300 MB/sessione)
18
  - Timeout sessione: SESSION_TTL_S secondi di inattività
19
  - Un solo _browser_lock per aprire nuove sessioni (evita race condition)
20
+
21
+ Problematiche W-NAV anticipate:
22
+ - networkidle timeout: pagine con polling infinito (ads/analytics/WebSocket)
23
+ non raggiungono mai networkidle → timeout catturato, flusso continua
24
+ (domcontentloaded è già avvenuto come prerequisito → DOM accessibile)
25
+ - Cookie banner loop: _dismiss_cookie_banner() è idempotente e silenziosa —
26
+ se fallisce il flusso continua normalmente (banner nella DOM, LLM lo vede
27
+ e può istruire browser_act per gestirlo manualmente)
28
+ - trafilatura su pagine non-article (login, dashboard, SPA vuota): restituisce
29
+ None → fallback a DOM innerText evaluation (pre-esistente, sempre funziona)
30
+ - get_by_text Playwright API: usiamo page.evaluate() JS per text-matching invece
31
+ di Locator API (più stabile tra versioni playwright, zero versioning issues)
32
  """
33
  import asyncio, base64, hashlib, os, time, uuid, logging
34
  from typing import Optional, Any
 
39
  _logger = logging.getLogger("browser")
40
 
41
  # ─── Costanti ─────────────────────────────────────────────────────────────────
42
+ SESSION_LIMIT = 2 # max sessioni vive (OOM guard HF free)
43
+ SESSION_TTL_S = 480 # 8 min inattività → cleanup
44
+ GOTO_TIMEOUT = 15_000 # ms goto principale
45
+ ACTION_TIMEOUT = 5_000 # ms per singola azione
46
  MAX_LINKS = 25
47
  MAX_INPUTS = 20
48
+ MAX_TEXT = 6000 # W-NAV: alzato da 3000→6000 (più contesto per LLM)
49
 
50
  # ─── Lock + registry sessioni ─────────────────────────────────────────────────
51
  _browser_lock = asyncio.Lock()
 
76
  "metadata.google", "169.254",
77
  ]
78
 
79
+ # ─── W-NAV2: Cookie dismiss selettori CSS prioritizzati ────────────────────
80
+ # Ordine: vendor-specifici (più precisi) → pattern generici (più ampi)
81
+ # Problematica: selettori troppo generici (es. "button") catturano azioni non-cookie
82
+ # → usiamo pattern specifici per namespace (id/class con "cookie/consent/gdpr")
83
+ _COOKIE_DISMISS_SELECTORS = [
84
+ "#onetrust-accept-btn-handler", # OneTrust (ubiquo)
85
+ "#CybotCookiebotDialogBodyButtonAccept", # Cookiebot
86
+ "#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll",
87
+ "[data-cookiebanner='accept_button']",
88
+ ".cc-btn.cc-allow", # CookieConsent.js
89
+ ".cc-accept-all",
90
+ "#cookie_action_close_header",
91
+ "#accept-cookies",
92
+ "#acceptAllCookies",
93
+ "#acceptCookies",
94
+ ".cookie-accept-all",
95
+ ".cookie__btn--accept",
96
+ "#gdpr-cookie-accept",
97
+ ".gdpr-accept-all",
98
+ ".gdpr__btn",
99
+ "[aria-label='Accept all cookies']",
100
+ "[aria-label='Accetta tutti i cookie']",
101
+ "[aria-label='Consenti tutti i cookie']",
102
+ "[aria-label='Allow all cookies']",
103
+ "button[id*='cookie'][id*='accept']",
104
+ "button[id*='accept'][id*='cookie']",
105
+ "button[class*='cookie'][class*='accept']",
106
+ ".cookie-consent__accept",
107
+ ".cookie-notice__accept",
108
+ ".cookie-banner__accept",
109
+ "#cookie-accept",
110
+ ".js-accept-cookies",
111
+ ]
112
+
113
+ # JS fallback: testo-matching su pulsanti visibili
114
+ # Problematica: .innerText può essere "" su elementi visibili ma con solo icone
115
+ # → usiamo .textContent come fallback per innerText
116
+ # Problematica: false positive su "OK" generico (es. dialog di conferma)
117
+ # → "ok" solo se il genitore contiene "cookie/consent/gdpr" nel className/id
118
+ _COOKIE_DISMISS_JS = """() => {
119
+ const EXACT = [
120
+ 'accetta tutto', 'accetta tutti', 'accetta tutti i cookie',
121
+ 'accept all', 'accept all cookies', 'allow all', 'allow all cookies',
122
+ 'tout accepter', 'alle akzeptieren', 'aceitar tudo', 'aceptar todo',
123
+ 'i accept all', 'i agree to all',
124
+ ];
125
+ const PARTIAL = [
126
+ 'accetta', 'accept cookies', 'allow cookies',
127
+ 'consenti tutto', 'ho capito', 'i accept', 'i agree',
128
+ ];
129
+ const isCookieCtx = (el) => {
130
+ const ctx = (el.id + ' ' + el.className + ' ' +
131
+ (el.closest('[class*=cookie],[class*=consent],[class*=gdpr],[id*=cookie],[id*=consent],[id*=gdpr]')?.className || '')
132
+ ).toLowerCase();
133
+ return ctx.includes('cookie') || ctx.includes('consent') || ctx.includes('gdpr') || ctx.includes('privacy');
134
+ };
135
+ const btns = Array.from(document.querySelectorAll(
136
+ 'button,a,[role=button],[class*=cookie] *,[class*=consent] *,[id*=cookie] *,[id*=gdpr] *'
137
+ ));
138
+ for (const el of btns) {
139
+ const txt = (el.innerText || el.textContent || '').trim().toLowerCase().replace(/\\s+/g, ' ');
140
+ if (!txt || txt.length > 60) continue;
141
+ const s = window.getComputedStyle(el);
142
+ if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') continue;
143
+ if (EXACT.includes(txt) || (PARTIAL.some(p => txt.includes(p)) && isCookieCtx(el))) {
144
+ el.click();
145
+ return txt.slice(0, 40);
146
+ }
147
+ }
148
+ return null;
149
+ }"""
150
+
151
+ # ─── DOM Intelligence script ─────────────────────────────────────────────────
152
  _DOM_SCRIPT = """() => {
 
153
  const links = [...document.querySelectorAll('a[href]')]
154
  .filter(a => a.href.startsWith('http') && a.innerText.trim())
155
  .slice(0, %d)
 
161
  : (a.className ? '.' + a.className.split(' ').filter(c=>c&&!c.match(/^[a-z]{1,2}$/)).slice(0,2).join('.') : 'a'))
162
  }));
163
 
 
164
  const inputs = [...document.querySelectorAll(
165
  'input:not([type=hidden]),textarea,select,button,[role=button],[role=checkbox],[role=radio],[role=switch],[role=combobox]'
166
  )]
 
170
  })
171
  .slice(0, %d)
172
  .map(el => {
 
173
  let selector = null;
174
  const role = el.getAttribute('role') || el.tagName.toLowerCase();
175
  if (el.id) selector = '#' + el.id;
 
177
  else if (el.getAttribute('aria-label')) selector = '[aria-label="' + el.getAttribute('aria-label') + '"]';
178
  else if (el.placeholder) selector = '[placeholder="' + el.placeholder + '"]';
179
  else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
 
 
180
  const tag = el.tagName.toLowerCase();
181
  const isChecked = el.checked !== undefined ? el.checked : null;
182
  const currentVal = (tag === 'input' || tag === 'textarea') ? (el.value || '') : null;
183
  const isDisabled = el.disabled || el.getAttribute('aria-disabled') === 'true';
 
 
184
  let label = el.getAttribute('aria-label') || el.placeholder || el.getAttribute('name') || el.id;
185
  if (!label) {
186
  const lbl = el.id ? document.querySelector('label[for="'+el.id+'"]') : el.closest('label');
187
  if (lbl) label = lbl.innerText.trim().slice(0, 40);
188
  }
189
  if (!label) label = el.innerText?.trim().slice(0, 40) || null;
 
190
  return { tag, role, type: el.type || null, label, selector,
191
  value: currentVal, checked: isChecked, disabled: isDisabled || false };
192
  })
193
  .filter(el => el.label || el.selector);
194
 
 
195
  const headings = [...document.querySelectorAll('h1,h2,h3')]
196
  .slice(0, 8)
197
  .map(h => ({ level: h.tagName.toLowerCase(), text: h.innerText.trim().slice(0, 80) }));
198
 
 
199
  const modals = [...document.querySelectorAll(
200
  '[role=dialog],[role=alertdialog],[role=modal],.modal,.dialog,[aria-modal=true]'
201
  )]
 
210
  selector: el.id ? '#' + el.id : (el.getAttribute('aria-label') ? '[aria-label="'+el.getAttribute('aria-label')+'"]' : '[role="'+(el.getAttribute('role')||'dialog')+'"]'),
211
  }));
212
 
 
213
  const title = document.title;
214
  const desc = document.querySelector('meta[name=description]')?.content?.slice(0, 200) || null;
215
  const mainEl = document.querySelector('main,[role=main],article,.content,#content') || document.body;
 
225
  return url.startswith(("http://", "https://")) and not any(b in low for b in _BLOCKED)
226
 
227
 
228
+ async def _goto_with_networkidle(page: Any, url: str, timeout: int = GOTO_TIMEOUT) -> None:
229
+ """
230
+ Naviga all'URL con wait_until='networkidle' per SPA/React/Vue/Next.js.
231
+ W-NAV3: never use only domcontentloaded for SPAs — may miss JS-rendered content.
232
+
233
+ Comportamento:
234
+ - networkidle: aspetta 500ms senza richieste HTTP attive (standard per SPA)
235
+ - Timeout: se la pagina ha polling infinito (ads, analytics, WebSocket keepalive),
236
+ il timeout scatta DOPO che domcontentloaded è già avvenuto → DOM accessibile.
237
+ Catturiamo silenziosamente e continuiamo.
238
+ - wait_for_load_state fallback: su timeout, forza attesa domcontentloaded
239
+ (già avvenuto, ritorna subito — è solo un safety net)
240
+
241
+ Nota: timeout di goto con networkidle NON significa pagina non caricata.
242
+ Significa che ci sono richieste background attive dopo il caricamento visibile.
243
+ """
244
+ try:
245
+ await page.goto(url, wait_until="networkidle", timeout=timeout)
246
+ except Exception:
247
+ # Timeout o navigazione interrotta — il DOM è comunque disponibile
248
+ try:
249
+ await page.wait_for_load_state("domcontentloaded", timeout=3000)
250
+ except Exception:
251
+ pass # Anche domcontentloaded fallisce? Procediamo — page.content() funziona comunque
252
+
253
+
254
+ async def _dismiss_cookie_banner(page: Any) -> bool:
255
+ """
256
+ Auto-dismiss banner cookie prima dell'estrazione DOM.
257
+ W-NAV2: eseguita dopo ogni _goto_with_networkidle in /open, /navigate, /screenshot.
258
+
259
+ Strategia a 2 fasi:
260
+ Fase 1: CSS selectors vendor-specifici (precisi, zero false positive)
261
+ Fase 2: JS text-matching su pulsanti visibili (copre CMP custom e traduzioni)
262
+
263
+ Silenziosa: non lancia mai, timeout brevi (1.5s per selettore) per non
264
+ bloccare il flusso. Se fallisce, il banner rimane nel DOM e l'LLM lo vede
265
+ nei dom.modals → può istruire browser_act per gestirlo manualmente.
266
+ """
267
+ # Fase 1: CSS selectors diretti
268
+ for sel in _COOKIE_DISMISS_SELECTORS:
269
+ try:
270
+ el = await page.query_selector(sel)
271
+ if el:
272
+ visible = await el.is_visible()
273
+ if visible:
274
+ await el.click(timeout=1500)
275
+ await page.wait_for_timeout(300)
276
+ _logger.debug("Cookie banner dismissed via CSS: %s", sel)
277
+ return True
278
+ except Exception:
279
+ continue
280
+
281
+ # Fase 2: JS text-matching (CMP custom, traduzioni non standard)
282
+ try:
283
+ clicked = await page.evaluate(_COOKIE_DISMISS_JS)
284
+ if clicked:
285
+ await page.wait_for_timeout(300)
286
+ _logger.debug("Cookie banner dismissed via JS text-match: '%s'", clicked)
287
+ return True
288
+ except Exception:
289
+ pass
290
+
291
+ return False
292
+
293
+
294
+ async def _extract_text_trafilatura(page: Any, url: str = "", max_chars: int = MAX_TEXT) -> str:
295
+ """
296
+ Estrae il testo mainbody via trafilatura (Readability-quality).
297
+ Fallback: DOM innerText evaluation se trafilatura non disponibile o restituisce poco.
298
+
299
+ Problematiche:
300
+ - trafilatura su SPA: l'HTML di page.content() include il DOM post-JS →
301
+ trafilatura può estrarre più testo rispetto all'HTML statico iniziale
302
+ - trafilatura su pagine non-article (login, 404): restituisce None →
303
+ fallback a DOM innerText (sempre disponibile)
304
+ - max_chars applicato sia a trafilatura che al fallback
305
+ """
306
+ try:
307
+ import trafilatura # type: ignore[import-untyped]
308
+ html = await page.content()
309
+ extracted = trafilatura.extract(
310
+ html,
311
+ url=url or None,
312
+ include_comments=False,
313
+ include_tables=True,
314
+ include_images=False,
315
+ deduplicate=True,
316
+ favor_recall=True,
317
+ )
318
+ if extracted and len(extracted.strip()) > 200:
319
+ return extracted[:max_chars]
320
+ except Exception:
321
+ pass
322
+
323
+ # Fallback: DOM evaluation (pre-esistente, sempre funziona)
324
+ try:
325
+ text = await page.evaluate(
326
+ "() => (document.querySelector('main,[role=main],article,.content,#content') || document.body)"
327
+ f".innerText.replace(/\\s+/g,' ').trim().slice(0,{max_chars})"
328
+ )
329
+ return str(text)
330
+ except Exception:
331
+ return ""
332
+
333
+
334
  async def _try_persist_screenshot(url: str, png_b64: str, title: str) -> None:
335
  try:
336
  sb_url = os.getenv("SUPABASE_URL", "")
 
463
  text: Optional[str] = None
464
  links: list[dict] = []
465
  inputs: list[dict] = []
466
+ headings: list[dict] = []
467
+ modals: list[dict] = []
468
 
469
  class BrowserResult(BaseModel):
470
  ok: bool
 
475
  text_content: Optional[str] = None
476
  dom: Optional[DomSnapshot] = None
477
  error: Optional[str] = None
478
+ warnings: list[str] = []
 
479
 
 
480
 
481
+ # ─── verify_goal_browser ──────────────────────────────────────────────────────
482
+ # Sprint 3b ITEM 8
483
  async def verify_goal_browser(
484
  goal: str,
485
  url: str,
486
  requirements: "list | None" = None,
487
  timeout_s: float = 30.0,
488
  ) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  if not _safe_url(url):
490
  return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "URL non consentita"}
491
 
492
+ # S701: se requirements=None, fallback a basic DOM check (era UNKNOWN immediato)
493
+ # Prima: 90%+ dei casi usciva subito senza verificare nulla.
494
+ # Ora: check DOM non-empty + zero white screen + JS error interception.
495
+ if not requirements:
496
+ try:
497
+ import playwright # noqa: F401
498
+ except ImportError:
499
+ return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": "playwright non installato"}
500
+ try:
501
+ from playwright.async_api import async_playwright
502
+ _js_errs: list[str] = []
503
+ async with async_playwright() as _pw2:
504
+ _b2 = await asyncio.wait_for(
505
+ _pw2.chromium.launch(headless=True, args=_LAUNCH_ARGS), timeout=10.0)
506
+ _ctx2 = await _b2.new_context(viewport={"width": 1280, "height": 800}, user_agent=_UA_DESKTOP)
507
+ _pg2 = await _ctx2.new_page()
508
+ _pg2.on("pageerror", lambda e: _js_errs.append(str(e)))
509
+ try:
510
+ await asyncio.wait_for(
511
+ _goto_with_networkidle(_pg2, url, GOTO_TIMEOUT),
512
+ timeout=min(timeout_s, 15.0),
513
+ )
514
+ await _pg2.wait_for_timeout(600)
515
+ _body_txt = await _pg2.evaluate("document.body ? document.body.innerText.trim() : ''")
516
+ _body_html = await _pg2.evaluate("document.body ? document.body.innerHTML.trim() : ''")
517
+ _title2 = await _pg2.title()
518
+ finally:
519
+ await _ctx2.close()
520
+ await _b2.close()
521
+ _is_white = len(_body_txt) < 5 and len(_body_html) < 30
522
+ _has_js_err = bool(_js_errs)
523
+ if _is_white:
524
+ _overall2 = "FAIL"
525
+ _crit2 = {"dom_not_empty": "FAIL", "js_errors": "PASS" if not _has_js_err else "FAIL"}
526
+ elif _has_js_err:
527
+ _overall2 = "FAIL"
528
+ _crit2 = {"dom_not_empty": "PASS", "js_errors": "FAIL"}
529
+ else:
530
+ _overall2 = "PASS"
531
+ _crit2 = {"dom_not_empty": "PASS", "js_errors": "PASS"}
532
+ # S701 R5: telemetria DOM check
533
+ try:
534
+ from api.state import increment_stat as _inc_dom
535
+ _inc_dom("browser_dom_check_pass" if _overall2 == "PASS" else "browser_dom_check_fail")
536
+ except Exception:
537
+ pass
538
+ return {"ok": True, "overall": _overall2, "per_criterion": _crit2,
539
+ "error": None, "title": _title2, "js_errors": _js_errs[:3]}
540
+ except asyncio.TimeoutError:
541
+ return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": "timeout"}
542
+ except Exception as _be:
543
+ return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": str(_be)[:200]}
544
+ try:
545
+ import playwright # noqa: F401
546
+ except ImportError:
547
+ return {
548
+ "ok": True, "overall": "UNKNOWN", "per_criterion": {},
549
+ "error": "playwright non installato — browser verify disabilitato",
550
+ }
551
+
552
  per_criterion: dict[str, str] = {}
553
 
554
  try:
 
561
  )
562
  _page = await _ctx.new_page()
563
  try:
564
+ # W-NAV3: usa networkidle anche per verify_goal_browser
565
  await asyncio.wait_for(
566
+ _goto_with_networkidle(_page, url, GOTO_TIMEOUT),
567
  timeout=min(timeout_s, GOTO_TIMEOUT / 1000),
568
  )
569
  await _page.wait_for_timeout(1000)
570
 
 
571
  _criteria: list[str] = []
572
  for _req in (requirements or [])[:5]:
573
  _ac = getattr(_req, "acceptance_criteria", None) or []
 
591
  _el = await _page.query_selector("form, input, textarea, button[type=submit]")
592
  _verdict = "PASS" if _el else "FAIL"
593
  elif any(k in _low for k in ["errore", "error", "400", "401", "403", "fallisce"]):
594
+ _verdict = "UNKNOWN"
595
  else:
596
  _title = await _page.title()
597
  _verdict = "PASS" if _title else "FAIL"
 
616
  except ImportError:
617
  return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "Playwright non installato"}
618
  except Exception as _e:
619
+ return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:300]} # S588
620
 
621
 
622
+ # ─── /screenshot ─────────────────────────────────────────────────────────────
623
 
624
  @router.post("/screenshot", response_model=BrowserResult)
625
  async def browser_screenshot(req: ScreenshotRequest):
 
634
  ctx = await _make_context(browser, req.width, req.height, req.mobile)
635
  page = await ctx.new_page()
636
  try:
637
+ # W-NAV3: networkidle per SPA
638
+ await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
639
+ # W-NAV2: dismiss cookie banner prima dello screenshot
640
+ await _dismiss_cookie_banner(page)
641
  await page.wait_for_timeout(req.wait_ms)
642
  png = await page.screenshot(type="png", full_page=False)
643
  title = await page.title()
 
645
  asyncio.create_task(_try_persist_screenshot(req.url, png_b64, title))
646
  return BrowserResult(ok=True, screenshot_b64=png_b64, title=title, url=page.url)
647
  except Exception as e:
648
+ return BrowserResult(ok=False, error=str(e)[:500]) # S599: 300→500
649
  finally:
650
  await ctx.close()
651
  await browser.close()
652
  except Exception as e:
653
+ return BrowserResult(ok=False, error=str(e)[:500])
654
 
655
 
656
+ # ─── /navigate ────────────────────────────────────────────────────────────────
657
 
658
  @router.post("/navigate", response_model=BrowserResult)
659
  async def browser_navigate(req: NavigateRequest):
660
+ """
661
+ Naviga, esegui azioni, restituisce screenshot + testo (stateless).
662
+ W-NAV: text_content ora estratto via trafilatura (da 2000→5000 chars utili).
663
+ """
664
  if not _safe_url(req.url):
665
  raise HTTPException(400, "URL non consentita")
666
  async with _browser_lock:
 
671
  ctx = await _make_context(browser, req.width, req.height, req.mobile)
672
  page = await ctx.new_page()
673
  try:
674
+ # W-NAV3: networkidle per SPA
675
+ await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
676
+ # W-NAV2: dismiss cookie prima delle azioni
677
+ await _dismiss_cookie_banner(page)
678
  await page.wait_for_timeout(500)
679
  await _execute_actions(page, req.actions)
680
  await page.wait_for_timeout(req.wait_ms)
681
+
682
  png = await page.screenshot(type="png", full_page=False)
683
  title = await page.title()
684
+ # W-NAV: trafilatura estrae mainbody, molto più testo utile
685
+ text = await _extract_text_trafilatura(page, req.url, max_chars=5000)
686
+
 
687
  png_b64 = base64.b64encode(png).decode()
688
  asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
689
  return BrowserResult(
690
  ok=True, screenshot_b64=png_b64, title=title,
691
+ url=page.url, text_content=text[:5000] if text else None,
692
  )
693
  except Exception as e:
694
+ return BrowserResult(ok=False, error=str(e)[:500])
695
  finally:
696
  await ctx.close()
697
  await browser.close()
698
  except Exception as e:
699
+ return BrowserResult(ok=False, error=str(e)[:500])
700
 
701
 
702
+ # ─── /open ────────────────────────────────────────────────────────────────────
703
 
704
  @router.post("/open", response_model=BrowserResult)
705
  async def browser_open(req: BrowserOpenRequest):
706
  """
707
  Apre una sessione Playwright persistente, naviga all'URL, restituisce
708
+ session_id + screenshot + mappa DOM + text_content (trafilatura).
 
709
  """
710
  if not _safe_url(req.url):
711
  raise HTTPException(400, "URL non consentita")
712
 
 
713
  if len(_sessions) >= SESSION_LIMIT:
714
  oldest = min(_sessions, key=lambda sid: _sessions[sid]["last_used"])
715
  await _close_session(oldest, "OOM guard")
 
719
  from playwright.async_api import async_playwright
720
  pw = await async_playwright().start()
721
  browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
722
+ ctx = await _make_context(browser, req.width, 800, req.mobile)
723
  page = await ctx.new_page()
724
 
725
+ # W-NAV3: networkidle per SPA
726
+ await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
727
+ # W-NAV2: dismiss cookie prima dell'estrazione DOM
728
+ await _dismiss_cookie_banner(page)
729
  await page.wait_for_timeout(500)
730
  await _execute_actions(page, req.actions)
731
  await page.wait_for_timeout(req.wait_ms)
 
734
  title = await page.title()
735
  png_b64 = base64.b64encode(png).decode()
736
  dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
737
+ # W-NAV: text_content via trafilatura (aggiunto — prima non era nella risposta /open)
738
+ text = await _extract_text_trafilatura(page, req.url, max_chars=MAX_TEXT)
739
 
740
  sid = uuid.uuid4().hex[:16]
741
  _sessions[sid] = {
742
  "pw": pw, "browser": browser, "context": ctx, "page": page,
743
  "created_at": time.time(), "last_used": time.time(), "url": page.url,
744
+ "click_history": [],
745
+ "visited_urls": [page.url],
746
+ "action_log": [],
 
 
747
  }
748
  asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
749
 
 
752
  ok=True, session_id=sid,
753
  screenshot_b64=png_b64, title=title, url=page.url,
754
  dom=dom,
755
+ text_content=text[:MAX_TEXT] if text else None,
756
  )
757
  except Exception as e:
758
+ return BrowserResult(ok=False, error=str(e)[:500]) # S603: 400→500
759
 
760
 
761
+ # ─── /act ─────────────────────────────────────────────────────────────────────
762
 
763
  @router.post("/act", response_model=BrowserResult)
764
  async def browser_act(req: BrowserActRequest):
765
  """
766
+ Esegue azioni su una sessione aperta.
767
+ Restituisce screenshot + mappa DOM + warnings anti-loop.
768
  """
769
  sess = _sessions.get(req.session_id)
770
  if not sess:
 
774
  page = sess["page"]
775
  warnings: list[str] = []
776
 
 
777
  CLICK_HISTORY_MAX = 20
778
+ LOOP_THRESHOLD = 3
779
  for action in req.actions:
780
  if action.type == "click" and action.selector:
781
  history: list[str] = sess.get("click_history", [])
 
796
  url_after = page.url
797
  sess["url"] = url_after
798
 
 
799
  visited: list[str] = sess.get("visited_urls", [])
800
  if url_after not in visited:
801
  visited.append(url_after)
802
  sess["visited_urls"] = visited[-30:]
803
 
 
804
  action_log: list[dict] = sess.get("action_log", [])
805
  for a in req.actions:
806
  action_log.append({
 
813
  dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
814
  dom = DomSnapshot(**dom_raw) if isinstance(dom_raw, dict) else None
815
 
 
816
  if dom and dom.modals:
817
+ modal_titles = [m.get("title") or m.get("role", "modal") for m in dom.modals]
818
+ warnings.append(
819
+ f"🔔 MODAL RILEVATO: {', '.join(str(t) for t in modal_titles)} "
820
+ "— potrebbe bloccare le azioni. Chiudilo prima di continuare."
821
+ )
822
 
823
  result = BrowserResult(
824
  ok=True, session_id=req.session_id,
 
833
 
834
  return result
835
  except Exception as e:
836
+ return BrowserResult(ok=False, session_id=req.session_id, error=str(e)[:500], warnings=warnings) # S603
837
 
838
 
839
+ # ─── /close ───────────────────────────────────────────────────────────────────
840
 
841
  @router.post("/close", response_model=BrowserResult)
842
  async def browser_close(req: BrowserCloseRequest):
 
847
  return BrowserResult(ok=True, session_id=req.session_id)
848
 
849
 
850
+ # ─── GET /screenshot/{session_id} ─────────────────────────────────────────────
851
 
852
  @router.get("/screenshot/{session_id}", response_model=BrowserResult)
853
  async def browser_session_screenshot(session_id: str, full_page: bool = False):
854
+ """Snapshot della pagina corrente senza azioni. Aggiorna last_used."""
 
 
 
 
 
 
 
 
 
 
855
  sess = _sessions.get(session_id)
856
  if not sess:
857
  raise HTTPException(404, f"Sessione {session_id} non trovata o scaduta")
 
869
  screenshot_b64=png_b64, title=title, url=page.url,
870
  )
871
  except Exception as e:
872
+ return BrowserResult(ok=False, session_id=session_id, error=str(e)[:500]) # S603
873
 
874
 
875
+ # ─── /sessions ────────────────────────────────────────────────────────────────
876
 
877
  @router.get("/sessions")
878
  async def list_sessions():
 
887
  }
888
 
889
 
 
890
  _start_cleanup()
api/coding.py CHANGED
@@ -1,23 +1,40 @@
1
- """api/coding.py — Coding agent endpoints"""
 
 
 
 
 
2
  from fastapi import APIRouter
 
3
  from pydantic import BaseModel
4
- from typing import Optional,List
5
- from agents.code_agent import CodeAgent
 
 
 
 
 
 
 
 
6
 
7
- router=APIRouter(prefix="/code",tags=["coding"])
8
- _agent=None
9
- def get_agent()->CodeAgent:
10
- global _agent
11
- if _agent is None: from main import ollama; _agent=CodeAgent(ollama)
12
- return _agent
 
 
 
 
13
 
14
- class AnalyzeReq(BaseModel): filepath:str; content:str; goal:str
15
- class SessionReq(BaseModel): goal:str; files:List[dict]; model:Optional[str]=None
16
 
17
  @router.post("/analyze")
18
- async def analyze(req:AnalyzeReq):
19
- return await get_agent().analyze_file(req.filepath,req.content,req.goal)
 
20
 
21
  @router.post("/session")
22
- async def session(req:SessionReq):
23
- return await get_agent().full_session(req.goal,req.files)
 
1
+ """api/coding.py — Coding agent endpoints
2
+
3
+ S437: CodeAgent (OllamaClient/localhost:11434) rimosso — non funzionava su HF/Railway/CF.
4
+ Stub 501 per retrocompatibilità router (main.py importa questo router).
5
+ Il loop principale usa AIClient multi-provider — usare /api/agent/run.
6
+ """
7
  from fastapi import APIRouter
8
+ from fastapi.responses import JSONResponse
9
  from pydantic import BaseModel
10
+ from typing import Optional, List
11
+
12
+ router = APIRouter(prefix="/code", tags=["coding"])
13
+
14
+ _STUB_MSG = (
15
+ "Endpoint deprecato (S437) — il CodeAgent basato su Ollama (localhost:11434) "
16
+ "non è disponibile su cloud (HF/Railway/CF). "
17
+ "Usa il loop principale via /api/agent/run che sfrutta AIClient multi-provider."
18
+ )
19
+
20
 
21
+ class AnalyzeReq(BaseModel):
22
+ filepath: str
23
+ content: str
24
+ goal: str
25
+
26
+
27
+ class SessionReq(BaseModel):
28
+ goal: str
29
+ files: List[dict]
30
+ model: Optional[str] = None
31
 
 
 
32
 
33
  @router.post("/analyze")
34
+ async def analyze(_req: AnalyzeReq) -> JSONResponse:
35
+ return JSONResponse(status_code=501, content={"error": _STUB_MSG})
36
+
37
 
38
  @router.post("/session")
39
+ async def session(_req: SessionReq) -> JSONResponse:
40
+ return JSONResponse(status_code=501, content={"error": _STUB_MSG})
api/database.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ database.py — Esecuzione query SQL su database configurato.
3
+
4
+ Configurazione HF Space:
5
+ DATABASE_URL — URL connessione (postgresql://user:pass@host:5432/dbname oppure sqlite:///path.db)
6
+ READ_ONLY_DB — "true" blocca INSERT/UPDATE/DELETE/DROP (default: true per sicurezza)
7
+
8
+ Supporta:
9
+ - PostgreSQL (richiede psycopg2-binary in requirements.txt)
10
+ - SQLite (built-in Python)
11
+
12
+ Sicurezza:
13
+ In modalità read-only (default) blocca keyword DML/DDL pericolose.
14
+ MAX 500 righe per query per evitare payload giganti.
15
+ """
16
+ import asyncio, os, logging
17
+ from fastapi import APIRouter
18
+ from pydantic import BaseModel
19
+
20
+ router = APIRouter(prefix="/api/database", tags=["database"])
21
+ _logger = logging.getLogger("database")
22
+
23
+ _DANGEROUS = frozenset({"drop","truncate","delete","update","insert","alter","create","grant","revoke"})
24
+ _MAX_ROWS = 500
25
+
26
+
27
+ class QueryRequest(BaseModel):
28
+ sql: str
29
+ params: list = []
30
+ read_only: bool = True
31
+
32
+
33
+ def _is_dangerous(sql: str) -> str | None:
34
+ s = sql.strip().lower()
35
+ for kw in _DANGEROUS:
36
+ if s.startswith(kw + " ") or s.startswith(kw + "\n") or f" {kw} " in s:
37
+ return kw
38
+ return None
39
+
40
+
41
+ @router.post("/query")
42
+ async def database_query(req: QueryRequest):
43
+ db_url = os.getenv("DATABASE_URL", "").strip()
44
+ if not db_url:
45
+ return {
46
+ "ok": False, "error": "DATABASE_URL non configurata.",
47
+ "hint": "Aggiungi DATABASE_URL nelle variabili dell'HF Space (es. postgresql://user:pass@host:5432/db).",
48
+ }
49
+
50
+ if req.read_only or os.getenv("READ_ONLY_DB", "true").lower() != "false":
51
+ kw = _is_dangerous(req.sql)
52
+ if kw:
53
+ return {
54
+ "ok": False,
55
+ "error": f"Query bloccata (read-only): '{kw.upper()}' non consentito.",
56
+ "hint": "Usa read_only: false per query di scrittura, oppure imposta READ_ONLY_DB=false nell'HF Space.",
57
+ }
58
+
59
+ try:
60
+ if db_url.startswith(("postgresql://", "postgres://")):
61
+ return await _pg_query(db_url, req.sql, req.params)
62
+ elif db_url.startswith("sqlite"):
63
+ return await _sqlite_query(db_url, req.sql, req.params)
64
+ else:
65
+ return {"ok": False, "error": f"Database non supportato: {db_url[:40]}…"}
66
+ except Exception as e:
67
+ return {"ok": False, "error": str(e)[:400]}
68
+
69
+
70
+ async def _pg_query(db_url: str, sql: str, params: list):
71
+ try:
72
+ import psycopg2, psycopg2.extras
73
+ except ImportError:
74
+ return {"ok": False, "error": "psycopg2 non installato. Aggiungi 'psycopg2-binary' a requirements.txt."}
75
+
76
+ def _run():
77
+ conn = psycopg2.connect(db_url)
78
+ try:
79
+ cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
80
+ cur.execute(sql, params or None)
81
+ try:
82
+ rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
83
+ cols = [d.name for d in (cur.description or [])]
84
+ except psycopg2.ProgrammingError:
85
+ rows, cols = [], []
86
+ conn.commit()
87
+ finally:
88
+ conn.close()
89
+ return rows, cols
90
+
91
+ rows, cols = await asyncio.to_thread(_run)
92
+ return {
93
+ "ok": True, "rows": rows, "columns": cols, "count": len(rows),
94
+ "truncated": len(rows) == _MAX_ROWS,
95
+ }
96
+
97
+
98
+ async def _sqlite_query(db_url: str, sql: str, params: list):
99
+ import sqlite3
100
+ path = db_url.replace("sqlite:///", "").replace("sqlite://", "") or ":memory:"
101
+
102
+ def _run():
103
+ conn = sqlite3.connect(path)
104
+ conn.row_factory = sqlite3.Row
105
+ try:
106
+ cur = conn.cursor()
107
+ cur.execute(sql, params or [])
108
+ try:
109
+ rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
110
+ cols = [d[0] for d in (cur.description or [])]
111
+ except Exception:
112
+ rows, cols = [], []
113
+ conn.commit()
114
+ finally:
115
+ conn.close()
116
+ return rows, cols
117
+
118
+ rows, cols = await asyncio.to_thread(_run)
119
+ return {
120
+ "ok": True, "rows": rows, "columns": cols, "count": len(rows),
121
+ "truncated": len(rows) == _MAX_ROWS,
122
+ }
api/email.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ email.py — Invio email via Resend API (https://resend.com).
3
+
4
+ Endpoint:
5
+ POST /api/email/send — Invia email transazionale/markdown
6
+
7
+ Configurazione:
8
+ RESEND_API_KEY env var — richiesto (chiave Resend, formato re_...)
9
+ Default from: noreply@<dominio configurato in Resend>
10
+
11
+ Comportamento:
12
+ - body può essere testo plain o HTML
13
+ - Se html=true, body viene inviato come HTML (altrimenti text/plain)
14
+ - Supporta cc, bcc, reply_to, allegati (future extension)
15
+ - Rate limit Resend free tier: 100 email/day, 1 email/sec
16
+
17
+ Error handling:
18
+ - Resend 422: validazione campi (to/from non validi)
19
+ - Resend 429: rate limit
20
+ - Mancanza RESEND_API_KEY → istruzione chiara per configurazione
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os, logging, httpx
25
+ from fastapi import APIRouter
26
+ from pydantic import BaseModel, EmailStr, validator
27
+ from typing import Optional, List
28
+
29
+ router = APIRouter(prefix="/api/email", tags=["email"])
30
+ _logger = logging.getLogger("email_api")
31
+
32
+ # ── Modelli ─────────────────────────────────────────────────────────────────
33
+
34
+ class SendEmailRequest(BaseModel):
35
+ to: str # destinatario (email o "Nome <email>")
36
+ subject: str # oggetto
37
+ body: str # corpo (testo plain o HTML)
38
+ html: bool = False # True → invia come text/html
39
+ from_name: str = "Agente AI"
40
+ from_email: str = "" # auto: noreply@dominio o default Resend
41
+ cc: List[str] = []
42
+ bcc: List[str] = []
43
+ reply_to: str = ""
44
+
45
+ @validator("subject", "body")
46
+ def not_empty(cls, v: str, field) -> str: # noqa: N805
47
+ if not v.strip():
48
+ raise ValueError(f"Il campo '{field.name}' non può essere vuoto")
49
+ return v.strip()
50
+
51
+
52
+ class SendEmailResponse(BaseModel):
53
+ ok: bool
54
+ id: Optional[str] = None
55
+ message: str
56
+ provider: str = "resend"
57
+
58
+
59
+ # ── Endpoint ─────────────────────────────────────────────────────────────────
60
+
61
+ @router.post("/send", response_model=SendEmailResponse)
62
+ async def send_email(req: SendEmailRequest) -> SendEmailResponse:
63
+ """
64
+ Invia email via Resend API.
65
+ Richiede RESEND_API_KEY nell'ambiente del backend HF Space.
66
+ """
67
+ api_key = os.getenv("RESEND_API_KEY", "")
68
+ if not api_key:
69
+ _logger.warning("RESEND_API_KEY non configurata")
70
+ return SendEmailResponse(
71
+ ok=False,
72
+ message=(
73
+ "❌ RESEND_API_KEY non configurata. "
74
+ "Per abilitare send_email: "
75
+ "1. Registrati su https://resend.com (gratuito fino a 3.000 email/mese) "
76
+ "2. Crea un API key in Dashboard → API Keys "
77
+ "3. Aggiungi RESEND_API_KEY nei Secrets dell'HF Space "
78
+ "4. (opzionale) Verifica il tuo dominio per il campo 'from'"
79
+ ),
80
+ )
81
+
82
+ # Costruisci mittente
83
+ from_email = req.from_email or os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev")
84
+ from_field = f"{req.from_name} <{from_email}>" if req.from_name else from_email
85
+
86
+ # Payload Resend
87
+ payload: dict = {
88
+ "from": from_field,
89
+ "to": [req.to] if isinstance(req.to, str) else req.to,
90
+ "subject": req.subject,
91
+ }
92
+ if req.html:
93
+ payload["html"] = req.body
94
+ else:
95
+ payload["text"] = req.body
96
+ if req.cc:
97
+ payload["cc"] = req.cc
98
+ if req.bcc:
99
+ payload["bcc"] = req.bcc
100
+ if req.reply_to:
101
+ payload["reply_to"] = req.reply_to
102
+
103
+ try:
104
+ async with httpx.AsyncClient(timeout=20.0) as client:
105
+ resp = await client.post(
106
+ "https://api.resend.com/emails",
107
+ json=payload,
108
+ headers={
109
+ "Authorization": f"Bearer {api_key}",
110
+ "Content-Type": "application/json",
111
+ },
112
+ )
113
+
114
+ if resp.status_code in (200, 201):
115
+ data = resp.json()
116
+ email_id = data.get("id", "")
117
+ _logger.info("Email inviata OK → id=%s to=%s", email_id, req.to)
118
+ return SendEmailResponse(
119
+ ok=True,
120
+ id=email_id,
121
+ message=f"✅ Email inviata con successo a {req.to} (id: {email_id})",
122
+ )
123
+
124
+ # Gestione errori specifici Resend
125
+ err_body: dict = {}
126
+ try:
127
+ err_body = resp.json()
128
+ except Exception:
129
+ pass
130
+ err_msg = err_body.get("message") or err_body.get("error") or resp.text[:200]
131
+
132
+ if resp.status_code == 422:
133
+ return SendEmailResponse(
134
+ ok=False,
135
+ message=f"❌ Email non valida: {err_msg}. Verifica indirizzo 'to' e 'from'.",
136
+ )
137
+ if resp.status_code == 429:
138
+ return SendEmailResponse(
139
+ ok=False,
140
+ message="❌ Rate limit Resend raggiunto (100 email/giorno sul piano free). Riprova tra qualche ora.",
141
+ )
142
+ if resp.status_code == 401:
143
+ return SendEmailResponse(
144
+ ok=False,
145
+ message="❌ RESEND_API_KEY non valida. Verifica il valore nei Secrets dell'HF Space.",
146
+ )
147
+
148
+ return SendEmailResponse(
149
+ ok=False,
150
+ message=f"❌ Resend errore {resp.status_code}: {err_msg}",
151
+ )
152
+
153
+ except httpx.TimeoutException:
154
+ return SendEmailResponse(
155
+ ok=False, message="❌ Timeout: Resend API non risponde. Riprova."
156
+ )
157
+ except Exception as exc:
158
+ _logger.error("send_email exception: %s", exc)
159
+ return SendEmailResponse(
160
+ ok=False, message=f"❌ Errore invio email: {str(exc)[:200]}"
161
+ )
api/exec.py CHANGED
@@ -312,4 +312,4 @@ async def llm_fix_code(req: FixRequest):
312
  last_err = str(e)
313
  continue
314
 
315
- return {'fixed_code': None, 'error': f'Tutti i provider falliti. Ultimo: {last_err[:200]}'}
 
312
  last_err = str(e)
313
  continue
314
 
315
+ return {'fixed_code': None, 'error': f'Tutti i provider falliti. Ultimo: {last_err[:300]}'} # S588: 200→300
api/exec_sandbox.py CHANGED
@@ -86,13 +86,13 @@ def run_in_sandbox(
86
  )
87
  return {
88
  "returncode": proc.returncode,
89
- "stdout": proc.stdout[:800],
90
- "stderr": proc.stderr[:500],
91
  }
92
  except subprocess.TimeoutExpired:
93
  return {"returncode": 1, "stdout": "", "stderr": f"timeout after {effective_timeout}s"}
94
  except Exception as e:
95
- return {"returncode": 1, "stdout": "", "stderr": str(e)[:200]}
96
  finally:
97
  cleanup_sandbox(sandbox) # S365: guaranteed cleanup even on exception
98
 
@@ -118,6 +118,8 @@ async def run_in_sandbox_async(
118
  cmd = [sys.executable, "-c", code]
119
  elif lang in ("js", "javascript", "node"):
120
  cmd = ["node", "-e", code]
 
 
121
  else:
122
  return {"returncode": 1, "stdout": "", "stderr": f"unsupported lang: {lang}"}
123
 
@@ -143,6 +145,108 @@ async def run_in_sandbox_async(
143
  "stderr": stderr.decode("utf-8", errors="replace")[:4000],
144
  }
145
  except Exception as e:
146
- return {"returncode": 1, "stdout": "", "stderr": str(e)[:200]}
147
  finally:
148
  cleanup_sandbox(sandbox) # S365: guaranteed cleanup
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  )
87
  return {
88
  "returncode": proc.returncode,
89
+ "stdout": proc.stdout[:8000], # S568: allineato con async variant (era 800)
90
+ "stderr": proc.stderr[:4000], # S568: allineato con async variant (era 500)
91
  }
92
  except subprocess.TimeoutExpired:
93
  return {"returncode": 1, "stdout": "", "stderr": f"timeout after {effective_timeout}s"}
94
  except Exception as e:
95
+ return {"returncode": 1, "stdout": "", "stderr": str(e)[:300]} # S588: 200→300
96
  finally:
97
  cleanup_sandbox(sandbox) # S365: guaranteed cleanup even on exception
98
 
 
118
  cmd = [sys.executable, "-c", code]
119
  elif lang in ("js", "javascript", "node"):
120
  cmd = ["node", "-e", code]
121
+ elif lang in ("bash", "shell", "sh"):
122
+ cmd = ["bash", "-c", code] # S679: bash support per execute_shell sandbox
123
  else:
124
  return {"returncode": 1, "stdout": "", "stderr": f"unsupported lang: {lang}"}
125
 
 
145
  "stderr": stderr.decode("utf-8", errors="replace")[:4000],
146
  }
147
  except Exception as e:
148
+ return {"returncode": 1, "stdout": "", "stderr": str(e)[:300]} # S589: 200→300
149
  finally:
150
  cleanup_sandbox(sandbox) # S365: guaranteed cleanup
151
+
152
+
153
+ # ── Session-based execution (S694: Persistenza sandbox) ───────────────────
154
+ import threading as _threading
155
+ import time as _time
156
+
157
+
158
+ class _SessionSandboxManager:
159
+ """Singleton: gestisce sandbox persistenti per session_id.
160
+ La sandbox sopravvive tra le chiamate — permette installare lib e riusarle.
161
+ """
162
+ _MAX_SESSIONS = 30
163
+ _TTL_S = 1800 # 30 min inattività
164
+
165
+ def __init__(self) -> None:
166
+ self._sessions: dict[str, dict] = {}
167
+ self._lock = _threading.Lock()
168
+
169
+ def get_or_create(self, session_id: str) -> Path:
170
+ with self._lock:
171
+ if session_id in self._sessions:
172
+ self._sessions[session_id]['last_used'] = _time.monotonic()
173
+ return Path(self._sessions[session_id]['path'])
174
+ if len(self._sessions) >= self._MAX_SESSIONS:
175
+ oldest = min(self._sessions, key=lambda k: self._sessions[k]['last_used'])
176
+ self._evict(oldest)
177
+ path = tempfile.mkdtemp(prefix=f"sess_{session_id[:16].replace('/', '_')}_")
178
+ self._sessions[session_id] = {'path': path, 'last_used': _time.monotonic()}
179
+ return Path(path)
180
+
181
+ def evict(self, session_id: str) -> None:
182
+ with self._lock:
183
+ self._evict(session_id)
184
+
185
+ def _evict(self, session_id: str) -> None:
186
+ entry = self._sessions.pop(session_id, None)
187
+ if entry:
188
+ shutil.rmtree(entry['path'], ignore_errors=True)
189
+
190
+ def prune_stale(self) -> int:
191
+ now = _time.monotonic()
192
+ with self._lock:
193
+ dead = [sid for sid, e in self._sessions.items()
194
+ if now - e['last_used'] > self._TTL_S]
195
+ for sid in dead:
196
+ self._evict(sid)
197
+ return len(dead)
198
+
199
+
200
+ _session_manager = _SessionSandboxManager()
201
+
202
+
203
+ async def run_in_sandbox_session(
204
+ code: str,
205
+ lang: str = "python",
206
+ session_id: str = "default",
207
+ timeout: float = float(_HARD_TIMEOUT_S),
208
+ ) -> dict:
209
+ """
210
+ Esegui codice in una sandbox persistente per session_id.
211
+ La sandbox sopravvive tra le chiamate — stato, file e librerie installate
212
+ restano disponibili per la stessa sessione (fino a TTL o evict manuale).
213
+ Returns: {returncode, stdout, stderr, session_id}
214
+ Never raises.
215
+ """
216
+ effective_timeout = min(timeout, float(_HARD_TIMEOUT_S))
217
+ sandbox = _session_manager.get_or_create(session_id)
218
+ try:
219
+ if lang in ("python", "py"):
220
+ cmd = [sys.executable, "-c", code]
221
+ elif lang in ("js", "javascript", "node"):
222
+ cmd = ["node", "-e", code]
223
+ elif lang in ("bash", "shell", "sh"):
224
+ cmd = ["bash", "-c", code]
225
+ else:
226
+ return {"returncode": 1, "stdout": "", "stderr": f"unsupported lang: {lang}"}
227
+
228
+ proc = await asyncio.create_subprocess_exec(
229
+ *cmd,
230
+ stdout=asyncio.subprocess.PIPE,
231
+ stderr=asyncio.subprocess.PIPE,
232
+ cwd=str(sandbox),
233
+ env=_safe_env(str(sandbox)),
234
+ )
235
+ try:
236
+ stdout, stderr = await asyncio.wait_for(
237
+ proc.communicate(), timeout=effective_timeout
238
+ )
239
+ except asyncio.TimeoutError:
240
+ proc.kill()
241
+ await proc.wait()
242
+ return {"returncode": -1, "stdout": "", "stderr": f"timeout after {effective_timeout}s", "session_id": session_id}
243
+
244
+ return {
245
+ "returncode": proc.returncode,
246
+ "stdout": stdout.decode("utf-8", errors="replace")[:8000],
247
+ "stderr": stderr.decode("utf-8", errors="replace")[:4000],
248
+ "session_id": session_id,
249
+ }
250
+ except Exception as e:
251
+ return {"returncode": 1, "stdout": "", "stderr": str(e)[:300], "session_id": session_id}
252
+ # NON chiama cleanup_sandbox — la sandbox persiste per tutta la sessione
api/linter.py CHANGED
@@ -8,6 +8,7 @@ Supportato:
8
  - Python: ast.parse() — zero deps, istantaneo
9
  - JSON: json.loads()
10
  - TypeScript/JavaScript: node --check (se Node.js disponibile)
 
11
  - Altro: skip silenzioso
12
 
13
  Design: max _LINT_TIMEOUT=5s, silent failures, never raises
@@ -17,6 +18,7 @@ import ast as _ast
17
  import asyncio
18
  import json as _json
19
  import os
 
20
  import tempfile
21
 
22
  _LINT_TIMEOUT = 5.0 # secondi — mai blocca oltre questo
@@ -30,7 +32,7 @@ def _lint_python(content: str) -> dict:
30
  except SyntaxError as e:
31
  return {'ok': False, 'errors': [f"SyntaxError riga {e.lineno}: {e.msg}"]}
32
  except Exception as e:
33
- return {'ok': False, 'errors': [str(e)[:200]]}
34
 
35
 
36
  def _lint_json(content: str) -> dict:
@@ -60,7 +62,8 @@ async def _lint_node(content: str, ext: str) -> dict:
60
  return {'ok': True, 'errors': []}
61
  err_text = stderr.decode('utf-8', errors='replace').strip()
62
  err_text = err_text.replace(fname, '<file>').replace('/tmp/', '')
63
- return {'ok': False, 'errors': [err_text[:300]]}
 
64
  finally:
65
  try:
66
  os.unlink(fname)
@@ -71,7 +74,70 @@ async def _lint_node(content: str, ext: str) -> dict:
71
  except FileNotFoundError:
72
  return {'ok': True, 'errors': [], 'skipped': 'node not found'}
73
  except Exception as e:
74
- return {'ok': True, 'errors': [], 'skipped': str(e)[:100]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
 
77
  async def lint_code(content: str, language: str, path: str = '') -> dict:
@@ -96,13 +162,13 @@ async def lint_code(content: str, language: str, path: str = '') -> dict:
96
  timeout=_LINT_TIMEOUT,
97
  )
98
  elif lang in ('tsx', 'jsx') or ext in ('tsx', 'jsx'):
99
- result = {'ok': True, 'errors': [], 'skipped': 'tsx/jsx: node non supporta JSX'}
100
  else:
101
  result = {'ok': True, 'errors': [], 'skipped': f'no linter for {lang or ext or "unknown"}'}
102
  except asyncio.TimeoutError:
103
  result = {'ok': True, 'errors': [], 'skipped': 'lint timeout'}
104
  except Exception as e:
105
- result = {'ok': True, 'errors': [], 'skipped': str(e)[:100]}
106
 
107
  result['language'] = language
108
  result['path'] = path
 
8
  - Python: ast.parse() — zero deps, istantaneo
9
  - JSON: json.loads()
10
  - TypeScript/JavaScript: node --check (se Node.js disponibile)
11
+ - TSX/JSX: S702 — _lint_tsx_jsx() structural checker (brace balance + JSX tag balance)
12
  - Altro: skip silenzioso
13
 
14
  Design: max _LINT_TIMEOUT=5s, silent failures, never raises
 
18
  import asyncio
19
  import json as _json
20
  import os
21
+ import re
22
  import tempfile
23
 
24
  _LINT_TIMEOUT = 5.0 # secondi — mai blocca oltre questo
 
32
  except SyntaxError as e:
33
  return {'ok': False, 'errors': [f"SyntaxError riga {e.lineno}: {e.msg}"]}
34
  except Exception as e:
35
+ return {'ok': False, 'errors': [str(e)[:300]]} # S588: 200→300
36
 
37
 
38
  def _lint_json(content: str) -> dict:
 
62
  return {'ok': True, 'errors': []}
63
  err_text = stderr.decode('utf-8', errors='replace').strip()
64
  err_text = err_text.replace(fname, '<file>').replace('/tmp/', '')
65
+ # S599: 300→500 linter stderr può contenere path + messaggio lungo
66
+ return {'ok': False, 'errors': [err_text[:500]]}
67
  finally:
68
  try:
69
  os.unlink(fname)
 
74
  except FileNotFoundError:
75
  return {'ok': True, 'errors': [], 'skipped': 'node not found'}
76
  except Exception as e:
77
+ return {'ok': True, 'errors': [], 'skipped': str(e)[:300]} # S606: 200→300
78
+
79
+
80
+ # S702: HTML void elements — non richiedono tag di chiusura in JSX
81
+ _JSX_VOID_TAGS: frozenset[str] = frozenset({
82
+ 'br', 'hr', 'img', 'input', 'meta', 'link', 'area', 'base', 'col',
83
+ 'embed', 'param', 'source', 'track', 'wbr',
84
+ })
85
+
86
+
87
+ def _lint_tsx_jsx(content: str) -> dict:
88
+ """
89
+ S702: Structural syntax checker per TSX/JSX — zero dipendenze esterne.
90
+ Controlla: bilanciamento brace/paren, bilanciamento tag JSX.
91
+ Ritorna partial=True (non è un full parser — checker strutturale).
92
+ """
93
+ errors: list[str] = []
94
+
95
+ # Strip commenti e stringhe per ridurre falsi positivi
96
+ stripped = re.sub(r'//[^\n]*', '', content)
97
+ stripped = re.sub(r'/\*[\s\S]*?\*/', '', stripped)
98
+ stripped = re.sub(r'"(?:[^"\\]|\\.)*"', '""', stripped)
99
+ stripped = re.sub(r"'(?:[^'\\]|\\.)*'", "''", stripped)
100
+ stripped = re.sub(r'`(?:[^`\\]|\\.)*`', '``', stripped)
101
+
102
+ # 1. Brace/paren balance
103
+ brace_delta = stripped.count('{') - stripped.count('}')
104
+ paren_delta = stripped.count('(') - stripped.count(')')
105
+ if abs(brace_delta) > 2:
106
+ errors.append(f"Parentesi graffe sbilanciate: {{}} (delta {brace_delta:+d})")
107
+ if abs(paren_delta) > 2:
108
+ errors.append(f"Parentesi tonde sbilanciate: () (delta {paren_delta:+d})")
109
+
110
+ # 2. JSX tag balance — solo tag con nome (non tag vuoti HTML)
111
+ tag_count: dict[str, int] = {}
112
+
113
+ # Opening tags: <TagName (non self-closing, non closing, non comment)
114
+ for m in re.finditer(r'<([A-Za-z][A-Za-z0-9.]*)(?:\s[^>]*)?>(?!\s*/)', content):
115
+ tag = m.group(1)
116
+ if tag.lower() not in _JSX_VOID_TAGS:
117
+ tag_count[tag] = tag_count.get(tag, 0) + 1
118
+
119
+ # Self-closing: <TagName ... />
120
+ for m in re.finditer(r'<([A-Za-z][A-Za-z0-9.]*)[^>]*/>', content):
121
+ tag = m.group(1)
122
+ tag_count[tag] = tag_count.get(tag, 0) - 1
123
+
124
+ # Closing: </TagName>
125
+ for m in re.finditer(r'</([A-Za-z][A-Za-z0-9.]*)>', content):
126
+ tag = m.group(1)
127
+ tag_count[tag] = tag_count.get(tag, 0) - 1
128
+
129
+ unbalanced = [
130
+ (t, c) for t, c in tag_count.items()
131
+ if abs(c) > 1 and t.lower() not in _JSX_VOID_TAGS
132
+ ]
133
+ for tag, delta in sorted(unbalanced, key=lambda x: -abs(x[1]))[:2]:
134
+ errors.append(f"Tag JSX non bilanciato: <{tag}> (delta {delta:+d})")
135
+
136
+ return {
137
+ 'ok': len(errors) == 0,
138
+ 'errors': errors,
139
+ 'partial': True, # checker strutturale — non full parser
140
+ }
141
 
142
 
143
  async def lint_code(content: str, language: str, path: str = '') -> dict:
 
162
  timeout=_LINT_TIMEOUT,
163
  )
164
  elif lang in ('tsx', 'jsx') or ext in ('tsx', 'jsx'):
165
+ result = _lint_tsx_jsx(content) # S702: structural checker (non più skip)
166
  else:
167
  result = {'ok': True, 'errors': [], 'skipped': f'no linter for {lang or ext or "unknown"}'}
168
  except asyncio.TimeoutError:
169
  result = {'ok': True, 'errors': [], 'skipped': 'lint timeout'}
170
  except Exception as e:
171
+ result = {'ok': True, 'errors': [], 'skipped': str(e)[:300]} # S606: 200→300
172
 
173
  result['language'] = language
174
  result['path'] = path
api/patch_tool.py CHANGED
@@ -185,6 +185,6 @@ async def patch_file_in_vfs(
185
  "success": False,
186
  "path": path,
187
  "lines_changed": 0,
188
- "errors": [str(e)[:200]],
189
- "error": str(e)[:200],
190
  }
 
185
  "success": False,
186
  "path": path,
187
  "lines_changed": 0,
188
+ "errors": [str(e)[:300]], # S588: 200→300
189
+ "error": str(e)[:300], # S588: 200→300
190
  }
api/providers.py CHANGED
@@ -11,6 +11,12 @@ _logger = logging.getLogger('agente_ai')
11
  _HEARTBEAT_INTERVAL_S = int(os.getenv("HEARTBEAT_INTERVAL", "90"))
12
  _HEARTBEAT_WARMUP_TOKENS = 3
13
 
 
 
 
 
 
 
14
 
15
  # ── Health / Status ────────────────────────────────────────────────────────────
16
 
@@ -24,6 +30,45 @@ async def health():
24
  }
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  @router.get('/api/tools')
28
  async def list_tools():
29
  try:
@@ -79,7 +124,7 @@ async def ai_provider_health():
79
  except Exception as exc:
80
  ms = round((time.monotonic() - t0) * 1000)
81
  return {"name": provider.name, "ok": False, "status": "error", "latency_ms": ms,
82
- "error": str(exc)[:80], "model": provider.default_model.split("/")[-1][:28]}
83
 
84
  results = list(await asyncio.gather(*[_probe(p) for p in client.providers]))
85
  payload = {"providers": results, "tested_at": int(time.time() * 1000)}
@@ -113,7 +158,7 @@ async def _heartbeat_probe_all() -> list:
113
  return {"name": provider.name, "ok": True, "latency_ms": ms}
114
  except Exception as exc:
115
  ms = round((time.monotonic() - t0) * 1000)
116
- return {"name": provider.name, "ok": False, "latency_ms": ms, "error": str(exc)[:80]}
117
 
118
  return list(await asyncio.gather(*[_probe(p) for p in client.providers]))
119
  except Exception as exc:
@@ -149,19 +194,28 @@ async def _heartbeat_loop() -> None:
149
  _ai_health_cache["at"] = time.monotonic()
150
  except Exception as exc:
151
  _heartbeat_state["status"] = "error"
152
- _heartbeat_state["error"] = str(exc)[:200]
153
  _logger.error("heartbeat error: %s", exc)
154
  await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
155
 
156
 
157
  def start_heartbeat() -> None:
158
- """Avvia il loop heartbeat — chiamato da main.py startup event."""
 
 
 
159
  try:
160
  loop = asyncio.get_event_loop()
161
- if loop.is_running():
162
- asyncio.create_task(_heartbeat_loop())
163
- except Exception:
164
- pass
 
 
 
 
 
 
165
 
166
 
167
  @router.get("/debug/timing")
 
11
  _HEARTBEAT_INTERVAL_S = int(os.getenv("HEARTBEAT_INTERVAL", "90"))
12
  _HEARTBEAT_WARMUP_TOKENS = 3
13
 
14
+ # S442-FIX1: singleton guard — previene task duplicati se start_heartbeat() chiamata 2+ volte.
15
+ # Scenario: riavvio anomalo del lifespan, hot-reload, test runner che importa più volte.
16
+ # _heartbeat_task è None prima del primo avvio, poi punta all'asyncio.Task in corso.
17
+ # Se il task è done() (crash/cancel), viene riavviato.
18
+ _heartbeat_task: asyncio.Task | None = None
19
+
20
 
21
  # ── Health / Status ────────────────────────────────────────────────────────────
22
 
 
30
  }
31
 
32
 
33
+ @router.get('/api/version')
34
+ async def api_version():
35
+ """S456-X3: versione dettagliata con sprint, capabilities e soglie refusal.
36
+ Il frontend legge questo endpoint all'avvio per verificare l'allineamento
37
+ tra la versione del loop browser (agentLoop.ts) e il loop backend (unified_loop.py).
38
+ """
39
+ return {
40
+ 'sprint': 'S460',
41
+ 'version': '3.3.0',
42
+ 'build_date': '2026-06-09',
43
+ 'capabilities': [
44
+ 'never_give_up', # S197: retry forzato su rifiuto LLM
45
+ 'reflective_debug', # S455-P14: fallback chain _reflective_debug
46
+ 'structured_memory', # S401: projectMemory con sessionStorage backup
47
+ 'goal_verifier_v2', # S410: GoalVerifier 2.0 con coverage check
48
+ 'speculative_tools', # S361: pre-fire tool speculativi in parallelo
49
+ 'project_context', # S456-X5: project memory iniettato dal frontend
50
+ 'learning_hints', # S456-X4: failure pattern dal selfLearning frontend
51
+ 'severity_retry', # S376: retry adattivo syntax/runtime/logic
52
+ 'consensus_mode', # S91: multi-provider consensus su task complessi
53
+ 'vision_tools', # V001: analyze_image/generate_image/search_images/screenshot
54
+ 'email_send', # V002: send_email via Resend API
55
+ 'database_query', # V003: PostgreSQL + SQLite query
56
+ 'web_research', # V004: multi-URL research + Groq synthesis
57
+ 'execute_sql', # V005: SQL execution frontend sandbox
58
+ 'create_pdf', # V006: PDF generation frontend (jsPDF)
59
+ 'call_api', # V007: direct REST API calls
60
+ ],
61
+ 'refusal': {
62
+ # Soglie INTENZIONALMENTE separate (azioni diverse):
63
+ # threshold_retry: backend retry aggressivo (cheap) → soglia alta
64
+ # threshold_validate: frontend quality penalty (conservativo) → soglia bassa
65
+ 'threshold_retry': 600,
66
+ 'threshold_validate': 350,
67
+ 'phrases_canonical': True, # S456-X2: set di frasi sincronizzato frontend/backend
68
+ },
69
+ }
70
+
71
+
72
  @router.get('/api/tools')
73
  async def list_tools():
74
  try:
 
124
  except Exception as exc:
125
  ms = round((time.monotonic() - t0) * 1000)
126
  return {"name": provider.name, "ok": False, "status": "error", "latency_ms": ms,
127
+ "error": str(exc)[:300], "model": provider.default_model.split("/")[-1][:28]} # S606: 200→300
128
 
129
  results = list(await asyncio.gather(*[_probe(p) for p in client.providers]))
130
  payload = {"providers": results, "tested_at": int(time.time() * 1000)}
 
158
  return {"name": provider.name, "ok": True, "latency_ms": ms}
159
  except Exception as exc:
160
  ms = round((time.monotonic() - t0) * 1000)
161
+ return {"name": provider.name, "ok": False, "latency_ms": ms, "error": str(exc)[:300]} # S606: 200→300
162
 
163
  return list(await asyncio.gather(*[_probe(p) for p in client.providers]))
164
  except Exception as exc:
 
194
  _ai_health_cache["at"] = time.monotonic()
195
  except Exception as exc:
196
  _heartbeat_state["status"] = "error"
197
+ _heartbeat_state["error"] = str(exc)[:300] # S588: 200→300
198
  _logger.error("heartbeat error: %s", exc)
199
  await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
200
 
201
 
202
  def start_heartbeat() -> None:
203
+ """Avvia il loop heartbeat — chiamato da main.py startup event.
204
+ S442-FIX1: singleton guard — crea il task solo se non esiste già o se è crashed/cancelled.
205
+ """
206
+ global _heartbeat_task
207
  try:
208
  loop = asyncio.get_event_loop()
209
+ if not loop.is_running():
210
+ return
211
+ # Guard: non avviare se il task è ancora vivo
212
+ if _heartbeat_task is not None and not _heartbeat_task.done():
213
+ _logger.info("start_heartbeat: task già in esecuzione, skip duplicato")
214
+ return
215
+ _heartbeat_task = asyncio.create_task(_heartbeat_loop())
216
+ _logger.info("start_heartbeat: task avviato (pid=%s)", id(_heartbeat_task))
217
+ except Exception as exc:
218
+ _logger.warning("start_heartbeat failed: %s", exc)
219
 
220
 
221
  @router.get("/debug/timing")
api/quality_guardian.py CHANGED
@@ -1,24 +1,38 @@
1
  """
2
- quality_guardian.py — Quality Guardian / Test-Resolution Engine (S363)
3
 
4
  Quando l'agente genera codice Python:
5
  1. Estrae il blocco codice dall'output LLM
6
  2. Usa Groq-8b (Role.TESTER) per generare un test minimale
7
  3. Esegue il test in subprocess sandboxato (30s max)
8
- 4. Se FAIL: usa Groq-8b come Debugger per diagnosi + hint di fix
 
 
 
9
  5. Emette evento `test_result` via on_event callback
10
 
 
 
 
 
 
 
 
 
 
11
  Invarianti:
12
  - Mai blocca il loop principale (fire-and-forget da unified_loop)
13
  - Mai lancia eccezioni (fallback silenzioso totale)
14
- - Timeout hard: 30s totali, 10s gen test, 12s exec, 8s debug
15
  """
16
  from __future__ import annotations
17
 
18
  import asyncio
 
19
  import re
20
  import subprocess
21
  import sys
 
22
  from typing import Any, Awaitable, Callable
23
 
24
  # ── Regex ─────────────────────────────────────────────────────────────────────
@@ -27,20 +41,38 @@ _CODE_FENCE_RE = re.compile(
27
  re.IGNORECASE,
28
  )
29
 
 
 
 
 
 
30
  _SECURITY_BLOCKLIST_RE = re.compile(
31
  r'\b(subprocess|import\s+os|shutil|socket|open\s*\(|eval\(|exec\(|__import__)\b'
32
  )
33
 
 
 
 
 
 
 
 
 
 
 
 
34
  # ── Prompts ───────────────────────────────────────────────────────────────────
35
  _TESTER_SYS = (
36
- "You are a minimal test generator. Given a Python function or class, "
37
- "write the SHORTEST possible test that asserts the main behavior.\n"
 
38
  "Rules:\n"
39
- "1. Output ONLY Python code, no explanations\n"
40
- "2. Use only assert statements, no pytest/unittest\n"
41
- "3. Test must run standalone, finish in <5s\n"
42
- "4. No file I/O, no network, no subprocess\n"
43
- "5. If code cannot be tested, output exactly: # SKIP\n"
 
44
  )
45
 
46
  _DEBUGGER_SYS = (
@@ -50,6 +82,227 @@ _DEBUGGER_SYS = (
50
  "FIX: <minimal code correction>\n"
51
  )
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  # ── Entry point ───────────────────────────────────────────────────────────────
55
 
@@ -63,21 +316,42 @@ async def run_quality_check(
63
  try:
64
  return await asyncio.wait_for(
65
  _check(task_id, goal, llm_output, on_event),
66
- timeout=30.0,
67
  )
68
  except Exception:
69
  return {"passed": None, "skipped": True, "reason": "guardian_timeout"}
70
 
71
 
72
  async def _check(task_id, goal, llm_output, on_event) -> dict:
73
- # 1. Extract code
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  blocks = _CODE_FENCE_RE.findall(llm_output)
75
  if not blocks:
76
  return {"passed": None, "skipped": True, "reason": "no_code_blocks"}
77
 
78
  code = blocks[0].strip()
79
 
80
- # Skip if code has security-sensitive patterns
81
  if _SECURITY_BLOCKLIST_RE.search(code):
82
  return {"passed": None, "skipped": True, "reason": "security_skip"}
83
 
@@ -89,7 +363,8 @@ async def _check(task_id, goal, llm_output, on_event) -> dict:
89
  tester.chat(
90
  [
91
  {"role": "system", "content": _TESTER_SYS},
92
- {"role": "user", "content": f"Goal: {goal[:200]}\n\n```python\n{code[:1200]}\n```"},
 
93
  ],
94
  temperature=0,
95
  max_tokens=400,
@@ -103,14 +378,41 @@ async def _check(task_id, goal, llm_output, on_event) -> dict:
103
  if "# SKIP" in test_code[:60]:
104
  return {"passed": None, "skipped": True, "reason": "guardian_skipped"}
105
 
106
- # Strip markdown fences if present
107
  test_code = re.sub(r'^```\w*\s*', '', test_code)
108
  test_code = re.sub(r'\s*```$', '', test_code)
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  # 3. Run sandboxed subprocess
111
- full_code = f"{code}\n\n{test_code}"
112
  exec_result = await asyncio.get_event_loop().run_in_executor(
113
- None, _run_subprocess, full_code
114
  )
115
 
116
  passed = exec_result["returncode"] == 0
@@ -119,59 +421,165 @@ async def _check(task_id, goal, llm_output, on_event) -> dict:
119
  if on_event:
120
  try:
121
  val = on_event({
122
- "type": "test_result",
123
- "action": "test_result",
124
- "taskId": task_id,
125
- "passed": passed,
126
- "stdout": exec_result["stdout"][:400],
127
- "stderr": exec_result["stderr"][:300],
128
  })
129
  if asyncio.iscoroutine(val):
130
  await val
131
  except Exception:
132
  pass
133
 
134
- # 5. Diagnose failure
135
  fix_hint: str | None = None
136
- if not passed and exec_result["stderr"]:
137
- try:
138
- from models.role_router import RoleRouter, Role
139
- debugger = RoleRouter.get_client(Role.TESTER)
140
- fix_hint = await asyncio.wait_for(
141
- debugger.chat(
142
- [
143
- {"role": "system", "content": _DEBUGGER_SYS},
144
- {"role": "user", "content": (
145
- f"Code:\n```python\n{code[:600]}\n```\n"
146
- f"Error:\n{exec_result['stderr'][:400]}"
147
- )},
148
- ],
149
- temperature=0,
150
- max_tokens=150,
151
- ),
152
- timeout=8.0,
153
- )
154
- except Exception:
155
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  return {
158
- "passed": passed,
159
- "skipped": False,
160
- "stdout": exec_result["stdout"][:400],
161
- "stderr": exec_result["stderr"][:300],
162
  "fix_hint": fix_hint,
163
  }
164
 
165
 
166
  def _run_subprocess(code: str) -> dict:
167
- # S365: prefer exec_sandbox for isolated execution (stripped env, isolated cwd, guaranteed cleanup)
168
  try:
169
- from api.exec_sandbox import run_in_sandbox # S365
170
  return run_in_sandbox(code, task_id="quality_check")
171
  except ImportError:
172
- pass # S365: fallback to plain subprocess if exec_sandbox unavailable
173
 
174
- # Fallback (exec_sandbox not importable)
175
  try:
176
  proc = subprocess.run(
177
  [sys.executable, "-c", code],
@@ -179,10 +587,11 @@ def _run_subprocess(code: str) -> dict:
179
  text=True,
180
  timeout=12,
181
  )
 
182
  return {
183
  "returncode": proc.returncode,
184
- "stdout": proc.stdout[:800],
185
- "stderr": proc.stderr[:500],
186
  }
187
  except subprocess.TimeoutExpired:
188
  return {"returncode": 1, "stdout": "", "stderr": "timeout after 12s"}
 
1
  """
2
+ quality_guardian.py — Quality Guardian / Test-Resolution Engine (S363, S701, S703, S704)
3
 
4
  Quando l'agente genera codice Python:
5
  1. Estrae il blocco codice dall'output LLM
6
  2. Usa Groq-8b (Role.TESTER) per generare un test minimale
7
  3. Esegue il test in subprocess sandboxato (30s max)
8
+ 4. Se FAIL: loop 3-iter repair (S703):
9
+ - Iter 1: diagnosi BUG/FIX
10
+ - Iter 2: rewrite guidato dalla diagnosi → re-exec
11
+ - Iter 3: versione semplificata → re-exec
12
  5. Emette evento `test_result` via on_event callback
13
 
14
+ S701: aggiunto browser testing per HTML/JS/React via Playwright
15
+ - Se il codice contiene blocchi HTML/JS/React → _browser_quality_check()
16
+ - Screenshot DOM check + JS console error interception
17
+
18
+ S704: screenshot quality gate
19
+ - page.screenshot(JPEG quality:30) → file size < 2500B = blank
20
+ - document.querySelectorAll('*').length < 5 = sparse DOM
21
+ - Fallback silenzioso se screenshot fallisce
22
+
23
  Invarianti:
24
  - Mai blocca il loop principale (fire-and-forget da unified_loop)
25
  - Mai lancia eccezioni (fallback silenzioso totale)
26
+ - Timeout hard: 45s totali (S703: 30→45 per loop 3-iter)
27
  """
28
  from __future__ import annotations
29
 
30
  import asyncio
31
+ import os
32
  import re
33
  import subprocess
34
  import sys
35
+ import tempfile
36
  from typing import Any, Awaitable, Callable
37
 
38
  # ── Regex ─────────────────────────────────────────────────────────────────────
 
41
  re.IGNORECASE,
42
  )
43
 
44
+ _HTML_FENCE_RE = re.compile(
45
+ r'```(?:html|htm|css|javascript|js|jsx|tsx|ts|react|vue|svelte)\s*\n([\s\S]+?)```',
46
+ re.IGNORECASE,
47
+ )
48
+
49
  _SECURITY_BLOCKLIST_RE = re.compile(
50
  r'\b(subprocess|import\s+os|shutil|socket|open\s*\(|eval\(|exec\(|__import__)\b'
51
  )
52
 
53
+ # S701: rileva task HTML/JS/React
54
+ _HTML_TASK_GOAL_RE = re.compile(
55
+ r'\b(html|css|javascript|react|vue|svelte|dom|component|webpage|landing.?page|frontend|web.?app)\b',
56
+ re.IGNORECASE,
57
+ )
58
+
59
+ _HTML_CODE_FENCE_RE = re.compile(
60
+ r'```(?:html|htm|javascript|js|jsx|tsx|react|vue|svelte)\s*\n',
61
+ re.IGNORECASE,
62
+ )
63
+
64
  # ── Prompts ───────────────────────────────────────────────────────────────────
65
  _TESTER_SYS = (
66
+ # S698: 2-3 assert coverage (normal+edge+error) vs precedente "shortest possible"
67
+ "You are a code validator. Given a Python function or class, "
68
+ "write 2-3 assert-based tests covering different scenarios.\n"
69
  "Rules:\n"
70
+ "1. Output ONLY valid Python code, no explanations, no markdown fences\n"
71
+ "2. Use only assert statements no pytest, no unittest, no extra imports\n"
72
+ "3. Cover: (a) normal case, (b) edge case (empty/zero/None), (c) error or boundary\n"
73
+ "4. The function is already defined above — call it directly, no re-imports\n"
74
+ "5. Every test must finish in <5s, no file I/O, no network, no subprocess\n"
75
+ "6. If the code cannot be tested in isolation, output exactly: # SKIP\n"
76
  )
77
 
78
  _DEBUGGER_SYS = (
 
82
  "FIX: <minimal code correction>\n"
83
  )
84
 
85
+ # S703: prompt per iter 2 (rewrite guidato)
86
+ _REWRITER_SYS = (
87
+ "You are a code fixer. Given a Python function and its failing test error, "
88
+ "produce a corrected version of the function.\n"
89
+ "Rules:\n"
90
+ "1. Output ONLY the corrected function code — no tests, no markdown, no explanation\n"
91
+ "2. Keep the same function name and signature\n"
92
+ "3. Fix the specific error shown\n"
93
+ "4. Valid Python only — no placeholders\n"
94
+ )
95
+
96
+ # S703: prompt per iter 3 (simplificazione)
97
+ _SIMPLIFIER_SYS = (
98
+ "You are a code simplifier. Given a Python function that has bugs after 2 fix attempts, "
99
+ "produce a minimal working version that handles the basic cases correctly.\n"
100
+ "Rules:\n"
101
+ "1. Output ONLY valid Python code — no markdown, no explanation\n"
102
+ "2. Keep the same function name and signature\n"
103
+ "3. Prioritize correctness over completeness — simplify edge cases if needed\n"
104
+ )
105
+
106
+ _HTML_DEBUGGER_SYS = (
107
+ "You are a frontend debugger. Given browser errors on an HTML/JS page, identify the bug.\n"
108
+ "Response format (2 lines max):\n"
109
+ "BUG: <description in Italian>\n"
110
+ "FIX: <minimal code correction>\n"
111
+ )
112
+
113
+
114
+ # ��─ S701+S704: Browser Quality Check (HTML/JS/React) ──────────────────────────
115
+
116
+ async def _browser_quality_check(
117
+ task_id: str,
118
+ goal: str,
119
+ html_code: str,
120
+ on_event: Callable | None,
121
+ ) -> dict:
122
+ """
123
+ S701+S704: testa HTML/JS/React via Playwright headless Chromium.
124
+ Checks: DOM non-empty, zero JS errors, screenshot size (S704), element count (S704).
125
+ Timeout: 15s totali.
126
+ Fallback silenzioso se playwright non installato.
127
+ """
128
+ try:
129
+ from playwright.async_api import async_playwright
130
+ except ImportError:
131
+ return {"passed": None, "skipped": True, "reason": "playwright_not_installed"}
132
+
133
+ # Wrap in documento HTML completo se necessario
134
+ html_content = html_code
135
+ if not re.search(r'<html|<!DOCTYPE', html_content, re.IGNORECASE):
136
+ html_content = (
137
+ "<!DOCTYPE html>\n<html lang=\"it\">\n"
138
+ "<head><meta charset=\"UTF-8\"><title>Test</title></head>\n"
139
+ f"<body>\n{html_content}\n</body>\n</html>"
140
+ )
141
+
142
+ tmp_html: str | None = None
143
+ try:
144
+ with tempfile.NamedTemporaryFile(suffix=".html", mode="w", delete=False, encoding="utf-8") as f:
145
+ f.write(html_content)
146
+ tmp_html = f.name
147
+
148
+ js_errors: list[str] = []
149
+ console_errors: list[str] = []
150
+
151
+ async with async_playwright() as p:
152
+ browser = await asyncio.wait_for(
153
+ p.chromium.launch(
154
+ headless=True,
155
+ args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
156
+ ),
157
+ timeout=10.0,
158
+ )
159
+ try:
160
+ # S704: viewport ridotto per screenshot size check più sensibile
161
+ page = await browser.new_page(
162
+ viewport={"width": 800, "height": 600},
163
+ )
164
+ page.on("pageerror", lambda err: js_errors.append(str(err)))
165
+ page.on("console", lambda msg: (
166
+ console_errors.append(msg.text) if msg.type == "error" else None
167
+ ))
168
+
169
+ await asyncio.wait_for(
170
+ page.goto(f"file://{tmp_html}", wait_until="domcontentloaded"),
171
+ timeout=8.0,
172
+ )
173
+ await page.wait_for_timeout(400)
174
+
175
+ body_text = await page.evaluate(
176
+ "document.body ? document.body.innerText.trim() : ''"
177
+ )
178
+ body_html = await page.evaluate(
179
+ "document.body ? document.body.innerHTML.trim() : ''"
180
+ )
181
+
182
+ # S704: element count — sparse DOM detection
183
+ is_sparse_dom = False
184
+ try:
185
+ elem_count = await page.evaluate(
186
+ "document.querySelectorAll('*').length"
187
+ )
188
+ is_sparse_dom = int(elem_count) < 5
189
+ except Exception:
190
+ pass
191
+
192
+ # S704: screenshot size — blank page detection
193
+ is_blank_screenshot: bool | None = None
194
+ try:
195
+ ss_bytes = await page.screenshot(
196
+ type="jpeg", quality=30, full_page=False
197
+ )
198
+ # JPEG quality:30 blank white 800×600 ≈ 1–2 KB; real content > 3 KB
199
+ is_blank_screenshot = len(ss_bytes) < 2500
200
+ except Exception:
201
+ pass # screenshot non critico — skip silenzioso
202
+
203
+ finally:
204
+ await browser.close()
205
+
206
+ is_white_screen = (
207
+ (len(body_text) < 5 and len(body_html) < 20)
208
+ or is_blank_screenshot is True
209
+ )
210
+ has_js_errors = bool(js_errors or console_errors)
211
+ passed = not is_white_screen and not has_js_errors and not is_sparse_dom
212
+ fix_hint: str | None = None
213
+
214
+ if not passed:
215
+ all_errors = (js_errors + console_errors)[:3]
216
+ if is_white_screen:
217
+ fix_hint = (
218
+ "BUG: Pagina bianca — nessun contenuto visibile nel DOM\n"
219
+ "FIX: Verifica che il codice HTML/JS inserisca contenuto nel <body>"
220
+ )
221
+ elif is_sparse_dom:
222
+ fix_hint = (
223
+ "BUG: DOM troppo scarso — meno di 5 elementi nella pagina\n"
224
+ "FIX: Aggiungere elementi visibili nel body (divs, paragrafi, componenti)"
225
+ )
226
+ elif has_js_errors:
227
+ err_txt = "; ".join(all_errors)
228
+ try:
229
+ from models.role_router import RoleRouter, Role
230
+ dbg = RoleRouter.get_client(Role.TESTER)
231
+ fix_hint = await asyncio.wait_for(
232
+ dbg.chat(
233
+ [
234
+ {"role": "system", "content": _HTML_DEBUGGER_SYS},
235
+ {"role": "user", "content": (
236
+ f"Goal: {goal[:300]}\n"
237
+ f"JS Errors:\n{err_txt[:600]}\n\n"
238
+ f"Code:\n```html\n{html_code[:1200]}\n```"
239
+ )},
240
+ ],
241
+ temperature=0,
242
+ max_tokens=200,
243
+ ),
244
+ timeout=6.0,
245
+ )
246
+ except Exception:
247
+ fix_hint = f"BUG: Errori JS — {err_txt[:200]}\nFIX: Correggi gli errori JavaScript"
248
+
249
+ if on_event:
250
+ try:
251
+ # S704: includi screenshot_blank e sparse_dom nel evento
252
+ val = on_event({
253
+ "type": "test_result",
254
+ "action": "test_result",
255
+ "taskId": task_id,
256
+ "passed": passed,
257
+ "stdout": (
258
+ f"browser DOM: {'OK' if not is_white_screen else 'WHITE_SCREEN'}"
259
+ f" | JS errors: {len(js_errors)}"
260
+ f" | sparse_dom: {is_sparse_dom}"
261
+ f" | blank_ss: {is_blank_screenshot}"
262
+ ),
263
+ "stderr": "; ".join((js_errors + console_errors)[:3])[:500] if not passed else "",
264
+ "mode": "browser",
265
+ "screenshot_blank": is_blank_screenshot,
266
+ "sparse_dom": is_sparse_dom,
267
+ })
268
+ if asyncio.iscoroutine(val):
269
+ await val
270
+ except Exception:
271
+ pass
272
+
273
+ # S701+S704: telemetry counters
274
+ try:
275
+ from api.state import increment_stat as _inc_stat
276
+ _inc_stat("browser_quality_pass" if passed else "browser_quality_fail")
277
+ if is_blank_screenshot is True:
278
+ _inc_stat("browser_screenshot_blank")
279
+ if is_sparse_dom:
280
+ _inc_stat("browser_dom_sparse")
281
+ except Exception:
282
+ pass
283
+
284
+ return {
285
+ "passed": passed,
286
+ "skipped": False,
287
+ "stdout": f"browser_test: {'PASS' if passed else 'FAIL'}",
288
+ "stderr": "; ".join((js_errors + console_errors)[:3])[:500],
289
+ "fix_hint": fix_hint,
290
+ "mode": "browser",
291
+ "screenshot_blank": is_blank_screenshot,
292
+ "sparse_dom": is_sparse_dom,
293
+ }
294
+
295
+ except asyncio.TimeoutError:
296
+ return {"passed": None, "skipped": True, "reason": "browser_timeout"}
297
+ except Exception as exc:
298
+ return {"passed": None, "skipped": True, "reason": f"browser_error: {str(exc)[:100]}"}
299
+ finally:
300
+ if tmp_html:
301
+ try:
302
+ os.unlink(tmp_html)
303
+ except Exception:
304
+ pass
305
+
306
 
307
  # ── Entry point ───────────────────────────────────────────────────────────────
308
 
 
316
  try:
317
  return await asyncio.wait_for(
318
  _check(task_id, goal, llm_output, on_event),
319
+ timeout=45.0, # S703: 30→45s per loop 3-iter repair
320
  )
321
  except Exception:
322
  return {"passed": None, "skipped": True, "reason": "guardian_timeout"}
323
 
324
 
325
  async def _check(task_id, goal, llm_output, on_event) -> dict:
326
+ # S701: route HTML/JS/React tasks to browser test
327
+ if _HTML_CODE_FENCE_RE.search(llm_output) or (
328
+ _HTML_TASK_GOAL_RE.search(goal) and re.search(r'<[a-zA-Z][^>]{0,40}>', llm_output)
329
+ ):
330
+ html_blocks = _HTML_FENCE_RE.findall(llm_output)
331
+ if not html_blocks:
332
+ # Prova raw HTML dal testo
333
+ raw = re.findall(
334
+ r'(<(?:html|body|div|script)[^>]*>[\s\S]+?</(?:html|body|div|script)>)',
335
+ llm_output, re.IGNORECASE,
336
+ )
337
+ if raw:
338
+ html_blocks = [raw[0]]
339
+ if html_blocks:
340
+ try:
341
+ return await asyncio.wait_for(
342
+ _browser_quality_check(task_id, goal, html_blocks[0].strip(), on_event),
343
+ timeout=20.0,
344
+ )
345
+ except Exception:
346
+ pass # fallthrough to Python check se browser fallisce
347
+
348
+ # 1. Extract Python code
349
  blocks = _CODE_FENCE_RE.findall(llm_output)
350
  if not blocks:
351
  return {"passed": None, "skipped": True, "reason": "no_code_blocks"}
352
 
353
  code = blocks[0].strip()
354
 
 
355
  if _SECURITY_BLOCKLIST_RE.search(code):
356
  return {"passed": None, "skipped": True, "reason": "security_skip"}
357
 
 
363
  tester.chat(
364
  [
365
  {"role": "system", "content": _TESTER_SYS},
366
+ # S589/S597: goal 500 chars
367
+ {"role": "user", "content": f"Goal: {goal[:500]}\n\n```python\n{code[:2000]}\n```"},
368
  ],
369
  temperature=0,
370
  max_tokens=400,
 
378
  if "# SKIP" in test_code[:60]:
379
  return {"passed": None, "skipped": True, "reason": "guardian_skipped"}
380
 
 
381
  test_code = re.sub(r'^```\w*\s*', '', test_code)
382
  test_code = re.sub(r'\s*```$', '', test_code)
383
 
384
+ # S698: valida sintassi prima di eseguire
385
+ try:
386
+ compile(test_code, '<generated_test>', 'exec')
387
+ except SyntaxError as _syn_err:
388
+ try:
389
+ _test_raw2 = await asyncio.wait_for(
390
+ tester.chat(
391
+ [
392
+ {"role": "system", "content": _TESTER_SYS},
393
+ {"role": "user", "content": (
394
+ f"Goal: {goal[:500]}\n\n" + chr(96) * 3 + f"python\n{code[:2000]}\n" + chr(96) * 3
395
+ + f"\n\nPrevious attempt had SyntaxError: {_syn_err}\n"
396
+ "Output ONLY valid Python, no markdown, no explanation."
397
+ )},
398
+ ],
399
+ temperature=0,
400
+ max_tokens=400,
401
+ ),
402
+ timeout=8.0,
403
+ )
404
+ _tc2 = re.sub(r'^[^a-zA-Z_#]*', '', _test_raw2.strip())
405
+ try:
406
+ compile(_tc2, '<generated_test_retry>', 'exec')
407
+ test_code = _tc2
408
+ except SyntaxError:
409
+ return {"passed": None, "skipped": True, "reason": "test_syntax_invalid"}
410
+ except Exception:
411
+ return {"passed": None, "skipped": True, "reason": "test_syntax_invalid"}
412
+
413
  # 3. Run sandboxed subprocess
 
414
  exec_result = await asyncio.get_event_loop().run_in_executor(
415
+ None, _run_subprocess, f"{code}\n\n{test_code}"
416
  )
417
 
418
  passed = exec_result["returncode"] == 0
 
421
  if on_event:
422
  try:
423
  val = on_event({
424
+ "type": "test_result",
425
+ "action": "test_result",
426
+ "taskId": task_id,
427
+ "passed": passed,
428
+ "stdout": exec_result["stdout"][:500], # S604
429
+ "stderr": exec_result["stderr"][:500], # S597
430
  })
431
  if asyncio.iscoroutine(val):
432
  await val
433
  except Exception:
434
  pass
435
 
436
+ # 5. S703: 3-iter repair loop (iter 1 = diagnosi, iter 2 = rewrite, iter 3 = simplify)
437
  fix_hint: str | None = None
438
+ active_code = code # aggiornato se un iter produce codice corretto
439
+
440
+ if not passed:
441
+ # ── Iter 1: diagnosi BUG/FIX ────────────────────────────────────────
442
+ if exec_result["stderr"]:
443
+ try:
444
+ from models.role_router import RoleRouter, Role
445
+ debugger = RoleRouter.get_client(Role.TESTER)
446
+ fix_hint = await asyncio.wait_for(
447
+ debugger.chat(
448
+ [
449
+ {"role": "system", "content": _DEBUGGER_SYS},
450
+ {"role": "user", "content": (
451
+ f"Code:\n```python\n{code[:1200]}\n```\n"
452
+ f"Error:\n{exec_result['stderr'][:600]}"
453
+ )},
454
+ ],
455
+ temperature=0,
456
+ max_tokens=300, # S587
457
+ ),
458
+ timeout=6.0, # S703: 8→6s per lasciare budget a iter 2+3
459
+ )
460
+ except Exception:
461
+ pass
462
+
463
+ # ── Iter 2: rewrite guidato dalla diagnosi (S703) ───────────────────
464
+ if fix_hint and not passed:
465
+ try:
466
+ from models.role_router import RoleRouter, Role
467
+ from api.state import increment_stat as _inc2
468
+ _inc2("repair_iter2_used")
469
+ rewriter = RoleRouter.get_client(Role.TESTER)
470
+ fixed_raw = await asyncio.wait_for(
471
+ rewriter.chat(
472
+ [
473
+ {"role": "system", "content": _REWRITER_SYS},
474
+ {"role": "user", "content": (
475
+ f"Goal: {goal[:300]}\n"
476
+ f"Original code:\n```python\n{code[:1200]}\n```\n"
477
+ f"Test error:\n{exec_result['stderr'][:400]}\n"
478
+ f"Diagnosis:\n{fix_hint[:400]}"
479
+ )},
480
+ ],
481
+ temperature=0,
482
+ max_tokens=600,
483
+ ),
484
+ timeout=8.0,
485
+ )
486
+ fixed_code = re.sub(r'^```\w*\s*', '', fixed_raw.strip())
487
+ fixed_code = re.sub(r'\s*```$', '', fixed_code).strip()
488
+ if fixed_code and len(fixed_code) > 20:
489
+ r2 = await asyncio.get_event_loop().run_in_executor(
490
+ None, _run_subprocess, f"{fixed_code}\n\n{test_code}"
491
+ )
492
+ if r2["returncode"] == 0:
493
+ passed = True
494
+ active_code = fixed_code
495
+ exec_result = r2
496
+ # Emetti evento aggiornato
497
+ if on_event:
498
+ try:
499
+ val = on_event({
500
+ "type": "test_result",
501
+ "action": "test_result",
502
+ "taskId": task_id,
503
+ "passed": True,
504
+ "stdout": r2["stdout"][:500],
505
+ "stderr": "",
506
+ "repairIter": 2,
507
+ })
508
+ if asyncio.iscoroutine(val):
509
+ await val
510
+ except Exception:
511
+ pass
512
+ except Exception:
513
+ pass
514
+
515
+ # ── Iter 3: semplificazione (S703) — solo se iter 2 fallita ─────────
516
+ if not passed:
517
+ try:
518
+ from models.role_router import RoleRouter, Role
519
+ from api.state import increment_stat as _inc3
520
+ _inc3("repair_iter3_used")
521
+ simplifier = RoleRouter.get_client(Role.TESTER)
522
+ simple_raw = await asyncio.wait_for(
523
+ simplifier.chat(
524
+ [
525
+ {"role": "system", "content": _SIMPLIFIER_SYS},
526
+ {"role": "user", "content": (
527
+ f"Goal: {goal[:300]}\n"
528
+ f"Broken code (2 fix attempts failed):\n"
529
+ f"```python\n{code[:1000]}\n```\n"
530
+ f"Errors:\n{exec_result['stderr'][:300]}"
531
+ )},
532
+ ],
533
+ temperature=0.1,
534
+ max_tokens=500,
535
+ ),
536
+ timeout=6.0,
537
+ )
538
+ simple_code = re.sub(r'^```\w*\s*', '', simple_raw.strip())
539
+ simple_code = re.sub(r'\s*```$', '', simple_code).strip()
540
+ if simple_code and len(simple_code) > 20:
541
+ r3 = await asyncio.get_event_loop().run_in_executor(
542
+ None, _run_subprocess, f"{simple_code}\n\n{test_code}"
543
+ )
544
+ if r3["returncode"] == 0:
545
+ passed = True
546
+ active_code = simple_code
547
+ exec_result = r3
548
+ if on_event:
549
+ try:
550
+ val = on_event({
551
+ "type": "test_result",
552
+ "action": "test_result",
553
+ "taskId": task_id,
554
+ "passed": True,
555
+ "stdout": r3["stdout"][:500],
556
+ "stderr": "",
557
+ "repairIter": 3,
558
+ })
559
+ if asyncio.iscoroutine(val):
560
+ await val
561
+ except Exception:
562
+ pass
563
+ except Exception:
564
+ pass
565
 
566
  return {
567
+ "passed": passed,
568
+ "skipped": False,
569
+ "stdout": exec_result["stdout"][:500], # S604
570
+ "stderr": exec_result["stderr"][:500], # S598
571
  "fix_hint": fix_hint,
572
  }
573
 
574
 
575
  def _run_subprocess(code: str) -> dict:
576
+ # S365: prefer exec_sandbox
577
  try:
578
+ from api.exec_sandbox import run_in_sandbox
579
  return run_in_sandbox(code, task_id="quality_check")
580
  except ImportError:
581
+ pass
582
 
 
583
  try:
584
  proc = subprocess.run(
585
  [sys.executable, "-c", code],
 
587
  text=True,
588
  timeout=12,
589
  )
590
+ # S571: stdout/stderr limits allineati a exec_sandbox
591
  return {
592
  "returncode": proc.returncode,
593
+ "stdout": proc.stdout[:8000],
594
+ "stderr": proc.stderr[:4000],
595
  }
596
  except subprocess.TimeoutExpired:
597
  return {"returncode": 1, "stdout": "", "stderr": "timeout after 12s"}
api/research.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ research.py — Ricerca web multi-URL con estrazione e strutturazione contenuto.
3
+
4
+ Endpoint:
5
+ POST /api/web/research — cerca, legge e struttura N fonti su un argomento
6
+
7
+ Flusso (V2 — usa web_search.py pipeline):
8
+ 1. Ricerca via Brave → Tavily → Wikipedia → HackerNews (pipeline parallela)
9
+ Fallback DDG HTML parse se nessuna chiave API configurata
10
+ 2. Scarica e pulisce ogni pagina (trafilatura primary, regex fallback)
11
+ 3. Struttura il risultato con titolo, estratto, URL
12
+ 4. Se configurata GROQ_API_KEY, aggiunge sintesi AI (llama3-8b-8192)
13
+
14
+ Limiti:
15
+ - Max 8 URL per ricerca (protezione risorse HF free)
16
+ - Timeout 10s per URL, skip automatico se irraggiungibile
17
+ - Contenuto troncato a 2000 chars per fonte per evitare payload giganti
18
+ """
19
+ import asyncio, os, re, logging
20
+ import httpx
21
+ from fastapi import APIRouter
22
+ from pydantic import BaseModel
23
+
24
+ router = APIRouter(prefix="/api/web", tags=["web-research"])
25
+ _logger = logging.getLogger("research")
26
+
27
+ _UA = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
28
+ _MAX_SRC = 8
29
+
30
+
31
+ class ResearchRequest(BaseModel):
32
+ topic: str
33
+ depth: int = 4 # number of sources
34
+ lang: str = "it" # language for results
35
+ synthesize: bool = True # use LLM to synthesize if key available
36
+
37
+
38
+ # ─── Search: usa pipeline web_search.py (Brave → Tavily → Wikipedia → HN) ────
39
+
40
+ async def _pipeline_search(query: str, n: int) -> list[dict]:
41
+ """Usa la pipeline condivisa web_search.py — stessa logica di _run_direct_tools."""
42
+ try:
43
+ from tools.web_search import web_search as _ws
44
+ result = await _ws(query, max_results=n)
45
+ hits = result.get("results", [])
46
+ if hits:
47
+ return [{"url": h["url"], "title": h.get("title", "")} for h in hits if h.get("url")]
48
+ except Exception as exc:
49
+ _logger.debug("pipeline_search fallback: %s", exc)
50
+ return []
51
+
52
+
53
+ async def _ddg_fallback_search(query: str, n: int) -> list[dict]:
54
+ """Fallback DDG HTML parse quando nessuna chiave API è configurata."""
55
+ try:
56
+ async with httpx.AsyncClient(timeout=10, headers={"User-Agent": _UA, "Accept-Language": "it-IT,it;q=0.9,en;q=0.8"}) as c:
57
+ r = await c.get("https://html.duckduckgo.com/html/", params={"q": query, "kl": "it-it"})
58
+ if r.status_code != 200:
59
+ return []
60
+ html = r.text
61
+ link_pattern = re.compile(r'<a[^>]+class="result__url"[^>]*href="([^"]+)"[^>]*>([^<]*)</a>', re.DOTALL)
62
+ title_pattern = re.compile(r'<a[^>]+class="result__a"[^>]*href="[^"]+"[^>]*>([^<]+)</a>', re.DOTALL)
63
+ links = link_pattern.findall(html)
64
+ titles = [re.sub(r"\s+", " ", t).strip() for t in title_pattern.findall(html)]
65
+ results = []
66
+ for i, (url, _) in enumerate(links[:n]):
67
+ if url.startswith("http"):
68
+ results.append({"url": url, "title": titles[i] if i < len(titles) else url})
69
+ return results[:n]
70
+ except Exception:
71
+ return []
72
+
73
+
74
+ # ─── Page content extraction ──────────────────────────────────────────────────
75
+
76
+ async def _fetch_page(url: str, max_chars: int = 2000) -> dict:
77
+ try:
78
+ async with httpx.AsyncClient(timeout=10, follow_redirects=True, headers={"User-Agent": _UA}) as c:
79
+ r = await c.get(url)
80
+ if r.status_code != 200:
81
+ return {"url": url, "ok": False, "error": f"HTTP {r.status_code}"}
82
+ html = r.text
83
+ # Try trafilatura for Readability-quality extraction
84
+ try:
85
+ import trafilatura
86
+ text = trafilatura.extract(
87
+ html, include_comments=False, include_tables=False,
88
+ favor_recall=True, deduplicate=True,
89
+ ) or ""
90
+ except ImportError:
91
+ text = re.sub(r"<[^>]+>", " ", html)
92
+ text = re.sub(r"\s{2,}", " ", text).strip()
93
+ # Remove boilerplate noise
94
+ noise = {"cookie","accept all cookies","privacy policy","terms of service","subscribe","follow us on"}
95
+ lines = [l for l in text.split("\n") if len(l.strip()) > 4 and not any(n in l.lower() for n in noise)]
96
+ text = "\n".join(lines)
97
+ title_m = re.search(r"<title[^>]*>([^<]+)</title>", html, re.IGNORECASE)
98
+ title = title_m.group(1).strip()[:120] if title_m else url
99
+ return {"url": url, "title": title, "text": text[:max_chars], "ok": True}
100
+ except Exception as e:
101
+ return {"url": url, "ok": False, "error": str(e)[:100]}
102
+
103
+
104
+ # ─── LLM synthesis (Groq) ─────────────────────────────────────────────────────
105
+
106
+ async def _synthesize(topic: str, sources: list[dict]) -> str:
107
+ groq_key = os.getenv("GROQ_API_KEY", "")
108
+ if not groq_key:
109
+ return ""
110
+ context = "\n\n".join(
111
+ f"[{i+1}] {s['title']}\n{s['text'][:800]}"
112
+ for i, s in enumerate(sources) if s.get("ok") and s.get("text")
113
+ )[:6000]
114
+ try:
115
+ async with httpx.AsyncClient(timeout=30) as c:
116
+ r = await c.post(
117
+ "https://api.groq.com/openai/v1/chat/completions",
118
+ headers={"Authorization": f"Bearer {groq_key}", "Content-Type": "application/json"},
119
+ json={
120
+ "model": "llama-3.1-8b-instant",
121
+ "max_tokens": 700,
122
+ "messages": [
123
+ {"role": "system", "content": "Sei un assistente che sintetizza informazioni web. Rispondi sempre in italiano. Sii conciso e preciso."},
124
+ {"role": "user", "content": (
125
+ f"Argomento: **{topic}**\n\n"
126
+ f"Fonti trovate:\n{context}\n\n"
127
+ "Sintetizza le informazioni principali in 3-5 punti chiave, citando le fonti [N]."
128
+ )},
129
+ ],
130
+ },
131
+ )
132
+ if r.status_code == 200:
133
+ return r.json()["choices"][0]["message"]["content"]
134
+ except Exception as exc:
135
+ _logger.debug("synthesis error: %s", exc)
136
+ return ""
137
+
138
+
139
+ # ─── Main endpoint ────────────────────────────────────────────────────────────
140
+
141
+ @router.post("/research")
142
+ async def web_research(req: ResearchRequest):
143
+ n = min(max(int(req.depth), 1), _MAX_SRC)
144
+
145
+ # 1. Search: pipeline (Brave+Tavily+Wikipedia) → DDG fallback
146
+ results = await _pipeline_search(req.topic, n)
147
+ if not results:
148
+ _logger.info("Pipeline search returned 0 results — falling back to DDG")
149
+ results = await _ddg_fallback_search(req.topic, n)
150
+ if not results:
151
+ return {"ok": False, "error": "Ricerca fallita (nessun provider disponibile). Configura BRAVE_SEARCH_API_KEY o TAVILY_API_KEY."}
152
+
153
+ # 2. Fetch pages in parallel (max _MAX_SRC)
154
+ pages = await asyncio.gather(*[_fetch_page(r["url"]) for r in results[:n]])
155
+ ok_pages = [p for p in pages if p.get("ok") and p.get("text")]
156
+
157
+ if not ok_pages:
158
+ return {"ok": False, "error": "Nessuna pagina leggibile trovata (tutte bloccate o vuote)."}
159
+
160
+ # 3. Synthesize with Groq if available and requested
161
+ synthesis = ""
162
+ if req.synthesize:
163
+ synthesis = await _synthesize(req.topic, ok_pages)
164
+
165
+ return {
166
+ "ok": True,
167
+ "topic": req.topic,
168
+ "sources": [
169
+ {"url": p["url"], "title": p.get("title", ""), "excerpt": p["text"][:500]}
170
+ for p in ok_pages
171
+ ],
172
+ "synthesis": synthesis,
173
+ "count": len(ok_pages),
174
+ }
api/search.py CHANGED
@@ -49,7 +49,7 @@ def _clean_html(raw: str, max_chars: int = 20_000) -> tuple[str, list[str]]:
49
  def _get_title(raw: str) -> str:
50
  m = _re.search(r'<title[^>]*>(.*?)</title>', raw, _re.S | _re.I)
51
  if m:
52
- return html.unescape(_re.sub(r'<[^>]+>', '', m.group(1))).strip()[:120]
53
  return ""
54
 
55
 
@@ -74,7 +74,8 @@ async def _wiki_search(q: str, limit: int) -> list[dict]:
74
  results.append({
75
  'title': item.get('title', ''),
76
  'url': 'https://en.wikipedia.org/wiki/' + item.get('title', '').replace(' ', '_'),
77
- 'snippet': html.unescape(snippet)[:300],
 
78
  'source': 'web',
79
  })
80
  return results
@@ -103,7 +104,8 @@ async def _ddg_html_search(q: str, limit: int) -> list[dict]:
103
  title = html.unescape(_re.sub(r'<[^>]+>', '', title_html)).strip()
104
  snippet = html.unescape(_re.sub(r'<[^>]+>', '', snippets_raw[i] if i < len(snippets_raw) else '')).strip()
105
  if title and href.startswith('http'):
106
- results.append({'title': title, 'url': href, 'snippet': snippet[:300], 'source': 'web'})
 
107
  return results
108
  except Exception:
109
  return []
@@ -121,9 +123,10 @@ async def _searxng_search(q: str, limit: int) -> list[dict]:
121
  )
122
  return [
123
  {
124
- 'title': item.get('title', '')[:100],
125
  'url': item.get('url', ''),
126
- 'snippet': item.get('content', '')[:300],
 
127
  'source': 'web',
128
  }
129
  for item in sx_data.get('results', [])[:limit]
@@ -132,6 +135,71 @@ async def _searxng_search(q: str, limit: int) -> list[dict]:
132
  return []
133
 
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  async def _ddg_instant_search(q: str, limit: int) -> list[dict]:
136
  try:
137
  qs = urllib.parse.urlencode({'q': q, 'format': 'json', 'no_html': '1', 'no_redirect': '1', 'skip_disambig': '1'})
@@ -147,15 +215,16 @@ async def _ddg_instant_search(q: str, limit: int) -> list[dict]:
147
  results.append({
148
  'title': data.get('Heading', q),
149
  'url': data.get('AbstractURL', ''),
150
- 'snippet': data['AbstractText'][:300],
 
151
  'source': 'web',
152
  })
153
  for topic in data.get('RelatedTopics', [])[:limit]:
154
  if isinstance(topic, dict) and topic.get('Text'):
155
  results.append({
156
- 'title': topic.get('Text', '')[:80],
157
  'url': topic.get('FirstURL', ''),
158
- 'snippet': topic.get('Text', '')[:200],
159
  'source': 'web',
160
  })
161
  return results
@@ -189,7 +258,7 @@ async def proxy_search(req: SearchRequest):
189
  results.append({
190
  'title': item.get('full_name', ''),
191
  'url': item.get('html_url', ''),
192
- 'snippet': (item.get('description') or '')[:200],
193
  'source': 'github',
194
  })
195
 
@@ -203,7 +272,7 @@ async def proxy_search(req: SearchRequest):
203
  results.append({
204
  'title': pkg.get('name', ''),
205
  'url': f"https://www.npmjs.com/package/{pkg.get('name', '')}",
206
- 'snippet': pkg.get('description', '')[:200],
207
  'source': 'docs',
208
  })
209
 
@@ -217,21 +286,34 @@ async def proxy_search(req: SearchRequest):
217
  results.append({
218
  'title': info.get('name', q),
219
  'url': info.get('project_url') or f'https://pypi.org/project/{q}/',
220
- 'snippet': (info.get('summary') or '')[:200],
221
  'source': 'docs',
222
  })
223
  except Exception:
224
  pass
225
 
226
  else:
227
- # S358: tutti e 4 i backend web eseguono in parallelo
228
- batches = await asyncio.gather(
229
- _wiki_search(q, req.limit),
230
- _ddg_html_search(q, req.limit),
231
- _searxng_search(q, req.limit),
232
- _ddg_instant_search(q, req.limit),
233
- return_exceptions=True,
234
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  seen_urls: set[str] = set()
236
  for batch in batches:
237
  if isinstance(batch, Exception):
@@ -361,13 +443,13 @@ async def analyze_image(body: AnalyzeImageRequest):
361
  pass
362
  if raw:
363
  return {
364
- 'ok': True, 'description': raw[:250],
365
  'tags': [], 'objects': [], 'text_in_image': None, 'mood': None,
366
  'provider': f'{pname}/{model}', 'filename': body.filename,
367
  }
368
  except Exception as exc:
369
  last_err = str(exc)
370
- tried.append(f'{pname}/{model}: {str(exc)[:60]}')
371
  continue
372
 
373
  return {'ok': False, 'error': f'Tutti i provider vision falliti. Ultimo: {last_err}', 'tried': tried[:5]}
 
49
  def _get_title(raw: str) -> str:
50
  m = _re.search(r'<title[^>]*>(.*?)</title>', raw, _re.S | _re.I)
51
  if m:
52
+ return html.unescape(_re.sub(r'<[^>]+>', '', m.group(1))).strip()[:200] # S580: 120→200
53
  return ""
54
 
55
 
 
74
  results.append({
75
  'title': item.get('title', ''),
76
  'url': 'https://en.wikipedia.org/wiki/' + item.get('title', '').replace(' ', '_'),
77
+ # S598: snippet 300→500 — snippet Wikipedia troncati (spesso > 300 chars)
78
+ 'snippet': html.unescape(snippet)[:500],
79
  'source': 'web',
80
  })
81
  return results
 
104
  title = html.unescape(_re.sub(r'<[^>]+>', '', title_html)).strip()
105
  snippet = html.unescape(_re.sub(r'<[^>]+>', '', snippets_raw[i] if i < len(snippets_raw) else '')).strip()
106
  if title and href.startswith('http'):
107
+ # S598: snippet 300→500 snippet DDG HTML troncati
108
+ results.append({'title': title, 'url': href, 'snippet': snippet[:500], 'source': 'web'})
109
  return results
110
  except Exception:
111
  return []
 
123
  )
124
  return [
125
  {
126
+ 'title': item.get('title', '')[:200], # S580: 100→200
127
  'url': item.get('url', ''),
128
+ # S598: snippet 300→500 — SearXNG content troncato
129
+ 'snippet': item.get('content', '')[:500],
130
  'source': 'web',
131
  }
132
  for item in sx_data.get('results', [])[:limit]
 
135
  return []
136
 
137
 
138
+ async def _brave_search(q: str, limit: int) -> list[dict]:
139
+ """S601: Brave Search API — attivo solo se BRAVE_SEARCH_API_KEY impostata."""
140
+ api_key = os.environ.get("BRAVE_SEARCH_API_KEY", "")
141
+ if not api_key:
142
+ return []
143
+ try:
144
+ import httpx as _httpx_b
145
+ qs = urllib.parse.urlencode({"q": q, "count": min(limit, 20), "search_lang": "it"})
146
+ async with _httpx_b.AsyncClient(timeout=8.0) as c:
147
+ r = await c.get(
148
+ f"https://api.search.brave.com/res/v1/web/search?{qs}",
149
+ headers={
150
+ "Accept": "application/json",
151
+ "Accept-Encoding": "gzip",
152
+ "X-Subscription-Token": api_key,
153
+ },
154
+ )
155
+ data = r.json()
156
+ return [
157
+ {
158
+ "title": item.get("title", "")[:200],
159
+ "url": item.get("url", ""),
160
+ "snippet": item.get("description", "")[:500],
161
+ "source": "web",
162
+ }
163
+ for item in data.get("web", {}).get("results", [])[:limit]
164
+ if item.get("url")
165
+ ]
166
+ except Exception:
167
+ return []
168
+
169
+
170
+ async def _tavily_search(q: str, limit: int) -> list[dict]:
171
+ """S601: Tavily Search API — attivo solo se TAVILY_API_KEY impostata."""
172
+ api_key = os.environ.get("TAVILY_API_KEY", "")
173
+ if not api_key:
174
+ return []
175
+ try:
176
+ import httpx as _httpx_t
177
+ async with _httpx_t.AsyncClient(timeout=10.0) as c:
178
+ r = await c.post(
179
+ "https://api.tavily.com/search",
180
+ json={
181
+ "api_key": api_key,
182
+ "query": q,
183
+ "max_results": min(limit, 10),
184
+ "search_depth": "basic",
185
+ },
186
+ headers={"Content-Type": "application/json"},
187
+ )
188
+ data = r.json()
189
+ return [
190
+ {
191
+ "title": item.get("title", "")[:200],
192
+ "url": item.get("url", ""),
193
+ "snippet": item.get("content", "")[:500],
194
+ "source": "web",
195
+ }
196
+ for item in data.get("results", [])[:limit]
197
+ if item.get("url")
198
+ ]
199
+ except Exception:
200
+ return []
201
+
202
+
203
  async def _ddg_instant_search(q: str, limit: int) -> list[dict]:
204
  try:
205
  qs = urllib.parse.urlencode({'q': q, 'format': 'json', 'no_html': '1', 'no_redirect': '1', 'skip_disambig': '1'})
 
215
  results.append({
216
  'title': data.get('Heading', q),
217
  'url': data.get('AbstractURL', ''),
218
+ # S598: snippet 300→500 — DDG instant AbstractText troncato
219
+ 'snippet': data['AbstractText'][:500],
220
  'source': 'web',
221
  })
222
  for topic in data.get('RelatedTopics', [])[:limit]:
223
  if isinstance(topic, dict) and topic.get('Text'):
224
  results.append({
225
+ 'title': topic.get('Text', '')[:200], # S580: 80→200
226
  'url': topic.get('FirstURL', ''),
227
+ 'snippet': topic.get('Text', '')[:300], # S580: 200→300
228
  'source': 'web',
229
  })
230
  return results
 
258
  results.append({
259
  'title': item.get('full_name', ''),
260
  'url': item.get('html_url', ''),
261
+ 'snippet': (item.get('description') or '')[:300], # S584: 200→300
262
  'source': 'github',
263
  })
264
 
 
272
  results.append({
273
  'title': pkg.get('name', ''),
274
  'url': f"https://www.npmjs.com/package/{pkg.get('name', '')}",
275
+ 'snippet': pkg.get('description', '')[:300], # S584: 200→300
276
  'source': 'docs',
277
  })
278
 
 
286
  results.append({
287
  'title': info.get('name', q),
288
  'url': info.get('project_url') or f'https://pypi.org/project/{q}/',
289
+ 'snippet': (info.get('summary') or '')[:300], # S584: 200→300
290
  'source': 'docs',
291
  })
292
  except Exception:
293
  pass
294
 
295
  else:
296
+ # S601: Brave+Tavily come provider primari se API key disponibile;
297
+ # fallback automatico a Wikipedia+DDG+SearXNG se non configurati.
298
+ _brave_res = await _brave_search(q, req.limit)
299
+ _tavily_res = await _tavily_search(q, req.limit)
300
+ if _brave_res or _tavily_res:
301
+ # Provider premium disponibili → usa solo loro + Wikipedia per contesto enciclopedico
302
+ batches = await asyncio.gather(
303
+ asyncio.sleep(0, result=_brave_res),
304
+ asyncio.sleep(0, result=_tavily_res),
305
+ _wiki_search(q, min(req.limit, 3)),
306
+ return_exceptions=True,
307
+ )
308
+ else:
309
+ # Fallback: Wikipedia + DDG + SearXNG (free tier)
310
+ batches = await asyncio.gather(
311
+ _wiki_search(q, req.limit),
312
+ _ddg_html_search(q, req.limit),
313
+ _searxng_search(q, req.limit),
314
+ _ddg_instant_search(q, req.limit),
315
+ return_exceptions=True,
316
+ )
317
  seen_urls: set[str] = set()
318
  for batch in batches:
319
  if isinstance(batch, Exception):
 
443
  pass
444
  if raw:
445
  return {
446
+ 'ok': True, 'description': raw[:300], # S584: 250→300
447
  'tags': [], 'objects': [], 'text_in_image': None, 'mood': None,
448
  'provider': f'{pname}/{model}', 'filename': body.filename,
449
  }
450
  except Exception as exc:
451
  last_err = str(exc)
452
+ tried.append(f'{pname}/{model}: {str(exc)[:200]}') # S608: 60→200
453
  continue
454
 
455
  return {'ok': False, 'error': f'Tutti i provider vision falliti. Ultimo: {last_err}', 'tried': tried[:5]}
api/speculative.py CHANGED
@@ -57,9 +57,9 @@ def _goal_hash(goal: str) -> str:
57
 
58
  def _tool_key(tool_name: str, args: dict) -> str:
59
  try:
60
- s = json.dumps(args, sort_keys=True, ensure_ascii=False)[:200]
61
  except Exception:
62
- s = str(args)[:200]
63
  return f"{tool_name}::{s}"
64
 
65
  def get_speculative_result(goal: str, tool_name: str, args: dict) -> str | None:
@@ -138,7 +138,7 @@ async def _extract_tools_fast(goal: str) -> list[dict]:
138
  model="llama-3.1-8b-instant",
139
  messages=[{"role": "user", "content": prompt}],
140
  temperature=0.0,
141
- max_tokens=256,
142
  ),
143
  timeout=3.0,
144
  )
 
57
 
58
  def _tool_key(tool_name: str, args: dict) -> str:
59
  try:
60
+ s = json.dumps(args, sort_keys=True, ensure_ascii=False)[:400] # S608: 200→400
61
  except Exception:
62
+ s = str(args)[:400] # S608: 200→400
63
  return f"{tool_name}::{s}"
64
 
65
  def get_speculative_result(goal: str, tool_name: str, args: dict) -> str | None:
 
138
  model="llama-3.1-8b-instant",
139
  messages=[{"role": "user", "content": prompt}],
140
  temperature=0.0,
141
+ max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
142
  ),
143
  timeout=3.0,
144
  )
api/state.py CHANGED
@@ -4,7 +4,7 @@ backend/api/state.py — Shared state for all API routers (S354).
4
  Contains: Supabase client, in-memory stores, singleton getters, shared Pydantic models,
5
  TTL constants, prune helpers. Extracted from main.py — zero behaviour change.
6
  """
7
- import os, time
8
  from typing import Optional, Any
9
  from fastapi import HTTPException
10
  from pydantic import BaseModel, field_validator, model_validator
@@ -96,6 +96,10 @@ _REPAIR_STATS: dict[str, int] = {
96
  "runtime_errors": 0, # runtime errors rilevati
97
  "runtime_repaired": 0, # repair runtime OK (LLM ha prodotto fix)
98
  "runtime_failed": 0, # repair runtime fallito (LLM timeout / error)
 
 
 
 
99
  "green_confirmed": 0, # re-esecuzione post-repair: GREEN (rc==0)
100
  "green_failed": 0, # re-esecuzione post-repair: ancora errori
101
  # S410: GoalVerifier telemetry
@@ -111,6 +115,17 @@ _REPAIR_STATS: dict[str, int] = {
111
  "req_engine_used": 0, # RequirementEngine attivato (Sprint 2)
112
  "req_engine_reqs_total": 0, # requisiti decomposed totali
113
  "goal_verifier_v2_used": 0, # GoalVerifier 2.0 attivato su goal con requisiti
 
 
 
 
 
 
 
 
 
 
 
114
  }
115
 
116
 
@@ -140,9 +155,50 @@ _AGENT_TASK_MAX = 200
140
  # ── MemoryManager singleton ────────────────────────────────────────────────────
141
  _mem_manager: Any = None
142
  _mem_manager_inited: bool = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
 
145
  def _get_mem_manager() -> Any:
 
 
 
 
 
 
 
146
  global _mem_manager, _mem_manager_inited
147
  if _mem_manager is not None:
148
  if not _mem_manager_inited:
@@ -152,7 +208,9 @@ def _get_mem_manager() -> Any:
152
  _loop_inner.create_task(_mem_manager.init())
153
  _mem_manager_inited = True
154
  except RuntimeError:
155
- pass
 
 
156
  return _mem_manager
157
  try:
158
  from memory.manager import MemoryManager
@@ -163,6 +221,8 @@ def _get_mem_manager() -> Any:
163
  loop.create_task(_mem_manager.init())
164
  _mem_manager_inited = True
165
  except RuntimeError:
 
 
166
  pass
167
  except Exception:
168
  _mem_manager = None
@@ -264,6 +324,10 @@ class ReasonLoopIn(BaseModel):
264
  goal: str
265
  context: list[dict] = []
266
  max_steps: int = 8
 
 
 
 
267
 
268
  @field_validator('goal', mode='before')
269
  @classmethod
@@ -283,12 +347,29 @@ class ReasonLoopIn(BaseModel):
283
  return []
284
  return []
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
  class AgentTaskIn(BaseModel):
288
  goal: str
289
  context: list[dict] = []
290
  max_steps: int = 8
291
  taskId: Optional[str] = None
 
 
 
292
 
293
  @field_validator('goal', mode='before')
294
  @classmethod
@@ -307,3 +388,17 @@ class AgentTaskIn(BaseModel):
307
  if isinstance(v, str):
308
  return []
309
  return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  Contains: Supabase client, in-memory stores, singleton getters, shared Pydantic models,
5
  TTL constants, prune helpers. Extracted from main.py — zero behaviour change.
6
  """
7
+ import os, time, asyncio as _asyncio_mod
8
  from typing import Optional, Any
9
  from fastapi import HTTPException
10
  from pydantic import BaseModel, field_validator, model_validator
 
96
  "runtime_errors": 0, # runtime errors rilevati
97
  "runtime_repaired": 0, # repair runtime OK (LLM ha prodotto fix)
98
  "runtime_failed": 0, # repair runtime fallito (LLM timeout / error)
99
+ "browser_dom_check_pass": 0, # S701: verify_goal_browser senza req → DOM ok
100
+ "browser_dom_check_fail": 0, # S701: verify_goal_browser senza req → white screen/JS err
101
+ "browser_quality_pass": 0, # quality_guardian HTML/browser test PASS
102
+ "browser_quality_fail": 0, # quality_guardian HTML/browser test FAIL
103
  "green_confirmed": 0, # re-esecuzione post-repair: GREEN (rc==0)
104
  "green_failed": 0, # re-esecuzione post-repair: ancora errori
105
  # S410: GoalVerifier telemetry
 
115
  "req_engine_used": 0, # RequirementEngine attivato (Sprint 2)
116
  "req_engine_reqs_total": 0, # requisiti decomposed totali
117
  "goal_verifier_v2_used": 0, # GoalVerifier 2.0 attivato su goal con requisiti
118
+ # S701: browser verify outcome counters (dynamic key in unified_loop)
119
+ "browser_verify_pass": 0, # verify_goal_browser → PASS
120
+ "browser_verify_fail": 0, # verify_goal_browser → FAIL
121
+ "browser_verify_unknown": 0, # verify_goal_browser → UNKNOWN (timeout/no-url)
122
+ "browser_verify_timeout": 0, # verify_goal_browser asyncio.TimeoutError
123
+ # S703: repair iteration counters
124
+ "repair_iter2_used": 0, # quality_guardian iter 2 (rewrite) attivato
125
+ "repair_iter3_used": 0, # quality_guardian iter 3 (simplify) attivato
126
+ # S704: browser screenshot/DOM quality counters
127
+ "browser_screenshot_blank": 0, # screenshot < 2500B = pagina bianca/vuota
128
+ "browser_dom_sparse": 0, # DOM < 5 elementi = pagina quasi vuota
129
  }
130
 
131
 
 
155
  # ── MemoryManager singleton ────────────────────────────────────────────────────
156
  _mem_manager: Any = None
157
  _mem_manager_inited: bool = False
158
+ _mem_manager_lock: Any = None # asyncio.Lock creato lazily (il loop async potrebbe non esistere a import-time)
159
+
160
+
161
+ def _get_mem_manager_lock() -> Any:
162
+ """Lazy asyncio.Lock — N-1-FIX: protegge da race condition su init() concorrenti."""
163
+ global _mem_manager_lock
164
+ if _mem_manager_lock is None:
165
+ _mem_manager_lock = _asyncio_mod.Lock()
166
+ return _mem_manager_lock
167
+
168
+
169
+ async def _get_mem_manager_async() -> Any:
170
+ """N-1-FIX: versione async con asyncio.Lock per evitare race condition su request concorrenti.
171
+ Usare in tutti i contesti async — evita la creazione di N istanze MemoryManager in parallelo
172
+ su boot con 3+ worker uvicorn che arrivano contemporaneamente quando _mem_manager è None."""
173
+ global _mem_manager, _mem_manager_inited
174
+ if _mem_manager is not None and _mem_manager_inited:
175
+ return _mem_manager
176
+ async with _get_mem_manager_lock():
177
+ if _mem_manager is None:
178
+ try:
179
+ from memory.manager import MemoryManager
180
+ _mem_manager = MemoryManager()
181
+ await _mem_manager.init()
182
+ _mem_manager_inited = True
183
+ except Exception:
184
+ _mem_manager = None
185
+ elif not _mem_manager_inited:
186
+ try:
187
+ await _mem_manager.init()
188
+ _mem_manager_inited = True
189
+ except Exception:
190
+ _mem_manager_inited = True # evita retry infiniti
191
+ return _mem_manager
192
 
193
 
194
  def _get_mem_manager() -> Any:
195
+ """
196
+ S442-FIX2: init più robusto.
197
+ - _mem_manager_inited viene impostato a True anche su RuntimeError (nessun loop in corso)
198
+ così da non ritentare il get_running_loop() ad ogni request (era un retry silenzioso infinito).
199
+ - Se il loop non era disponibile al primo call (es. startup sync), asyncio.ensure_future()
200
+ viene usato come fallback al successivo call in contesto async.
201
+ """
202
  global _mem_manager, _mem_manager_inited
203
  if _mem_manager is not None:
204
  if not _mem_manager_inited:
 
208
  _loop_inner.create_task(_mem_manager.init())
209
  _mem_manager_inited = True
210
  except RuntimeError:
211
+ # Nessun loop in esecuzione ora — segniamo come inizializzato per evitare
212
+ # retry infiniti. Il init() verrà tentato al prossimo call in contesto async.
213
+ _mem_manager_inited = True
214
  return _mem_manager
215
  try:
216
  from memory.manager import MemoryManager
 
221
  loop.create_task(_mem_manager.init())
222
  _mem_manager_inited = True
223
  except RuntimeError:
224
+ # Chiamata in contesto sync (es. import-time) — init rimandato al primo call async.
225
+ # _mem_manager_inited resta False → verrà ritentato al prossimo call in loop.
226
  pass
227
  except Exception:
228
  _mem_manager = None
 
324
  goal: str
325
  context: list[dict] = []
326
  max_steps: int = 8
327
+ # S456-X5: project memory context injected by frontend (projectMemory.getContext())
328
+ project_context: str = ""
329
+ # S456-X4: top failure patterns from frontend selfLearning engine
330
+ learning_hints: list[str] = []
331
 
332
  @field_validator('goal', mode='before')
333
  @classmethod
 
347
  return []
348
  return []
349
 
350
+ @field_validator('project_context', mode='before')
351
+ @classmethod
352
+ def coerce_project_context(cls, v: object) -> str:
353
+ if not isinstance(v, str):
354
+ return ""
355
+ return v.strip()[:2000] # cap a 2000 chars per evitare prompt bloat
356
+
357
+ @field_validator('learning_hints', mode='before')
358
+ @classmethod
359
+ def coerce_learning_hints(cls, v: object) -> list:
360
+ if not isinstance(v, list):
361
+ return []
362
+ return [str(h)[:300] for h in v[:5]] # S606: 200→300 — hint completo
363
+
364
 
365
  class AgentTaskIn(BaseModel):
366
  goal: str
367
  context: list[dict] = []
368
  max_steps: int = 8
369
  taskId: Optional[str] = None
370
+ # S456-X5/X4: stesso payload di ReasonLoopIn per il path tasks
371
+ project_context: str = ""
372
+ learning_hints: list[str] = []
373
 
374
  @field_validator('goal', mode='before')
375
  @classmethod
 
388
  if isinstance(v, str):
389
  return []
390
  return []
391
+
392
+ @field_validator('project_context', mode='before')
393
+ @classmethod
394
+ def coerce_project_context(cls, v: object) -> str:
395
+ if not isinstance(v, str):
396
+ return ""
397
+ return v.strip()[:2000]
398
+
399
+ @field_validator('learning_hints', mode='before')
400
+ @classmethod
401
+ def coerce_learning_hints(cls, v: object) -> list:
402
+ if not isinstance(v, list):
403
+ return []
404
+ return [str(h)[:300] for h in v[:5]] # S606: 200→300
api/terminal.py CHANGED
@@ -21,8 +21,11 @@ async def terminal_ws(ws: WebSocket):
21
 
22
  master_fd, slave_fd = pty.openpty()
23
 
 
 
 
24
  proc = await asyncio.create_subprocess_exec(
25
- '/bin/bash', '--login',
26
  stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
27
  close_fds=True,
28
  env={**os.environ, 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor'},
@@ -71,6 +74,15 @@ async def terminal_ws(ws: WebSocket):
71
  proc.terminate()
72
  except Exception:
73
  pass
 
 
 
 
 
 
 
 
 
74
  try:
75
  os.close(master_fd)
76
  except Exception:
 
21
 
22
  master_fd, slave_fd = pty.openpty()
23
 
24
+ # S447: tmux new-session -A mantiene la sessione bash viva quando il WebSocket si chiude.
25
+ # Al reconnect, tmux -A fa attach alla sessione esistente invece di crearne una nuova.
26
+ # Questo è esattamente come funziona Replit internamente (tmux multiplex).
27
  proc = await asyncio.create_subprocess_exec(
28
+ 'tmux', 'new-session', '-A', '-s', 'main',
29
  stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
30
  close_fds=True,
31
  env={**os.environ, 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor'},
 
74
  proc.terminate()
75
  except Exception:
76
  pass
77
+ # Gap-5-FIX: attendi terminazione subprocess con timeout — previene processi zombie
78
+ try:
79
+ await asyncio.wait_for(proc.wait(), timeout=2.0)
80
+ except (asyncio.TimeoutError, Exception):
81
+ try:
82
+ proc.kill() # SIGKILL se SIGTERM non basta entro 2s
83
+ except Exception:
84
+ pass
85
+ # Chiudi PTY master dopo la terminazione del processo
86
  try:
87
  os.close(master_fd)
88
  except Exception:
api/vision.py ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ vision.py — Generazione e analisi immagini + ricerca immagini.
3
+
4
+ Endpoints:
5
+ POST /api/vision/generate — FLUX.1-schnell (HF Inference API)
6
+ POST /api/vision/analyze — Groq llama-3.2-vision / GPT-4o-mini / BLIP fallback
7
+ GET /api/vision/search — Pexels > Pixabay > Unsplash Source (zero API key)
8
+
9
+ Problematiche HF Inference API:
10
+ - 503 "loading": cold-start fino a 60s → retry con backoff
11
+ - Output generate: raw bytes PNG (non JSON)
12
+ - BLIP: captioning solo, non risponde a domande aperte
13
+ - Rate limit senza HF_TOKEN: ~10 req/hr per IP
14
+
15
+ Fallback chain analyze_image:
16
+ 1. Groq llama-3.2-11b-vision-preview (free tier, veloce, richiede GROQ_API_KEY)
17
+ 2. GPT-4o-mini vision (richiede OPENAI_API_KEY)
18
+ 3. BLIP-large captioning (HF Inference, libero ma solo didascalia)
19
+ """
20
+ import asyncio, base64, os, httpx, logging
21
+ from fastapi import APIRouter
22
+ from pydantic import BaseModel
23
+
24
+ router = APIRouter(prefix="/api/vision", tags=["vision"])
25
+ _logger = logging.getLogger("vision")
26
+
27
+ _HF_API = "https://api-inference.huggingface.co"
28
+ _USER_AGENT = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
29
+
30
+ _MODEL_MAP: dict[str, str] = {
31
+ "FLUX.1-schnell": "black-forest-labs/FLUX.1-schnell",
32
+ "FLUX.1-dev": "black-forest-labs/FLUX.1-dev",
33
+ "sdxl": "stabilityai/stable-diffusion-xl-base-1.0",
34
+ "flux": "black-forest-labs/FLUX.1-schnell",
35
+ "flux-schnell": "black-forest-labs/FLUX.1-schnell",
36
+ }
37
+
38
+
39
+ def _hf_headers(content_type: str = "application/json") -> dict:
40
+ token = os.getenv("HF_TOKEN", "")
41
+ h = {"User-Agent": _USER_AGENT, "Content-Type": content_type}
42
+ if token:
43
+ h["Authorization"] = f"Bearer {token}"
44
+ return h
45
+
46
+
47
+ # ─── Models ───────────────────────────────────────────────────────────────────
48
+
49
+ class GenerateImageRequest(BaseModel):
50
+ prompt: str
51
+ negative_prompt: str = ""
52
+ width: int = 512
53
+ height: int = 512
54
+ steps: int = 4
55
+ model: str = "FLUX.1-schnell"
56
+ save_path: str = ""
57
+
58
+
59
+ class AnalyzeImageRequest(BaseModel):
60
+ url: str = ""
61
+ base64_image: str = ""
62
+ question: str = "Descrivi questa immagine in dettaglio in italiano."
63
+
64
+
65
+ # ─── /generate ────────────────────────────────────────────────────────────────
66
+
67
+ @router.post("/generate")
68
+ async def generate_image(req: GenerateImageRequest):
69
+ """
70
+ Genera immagine via HF Inference API (FLUX.1-schnell default).
71
+
72
+ Strategia retry:
73
+ - Se il modello è in cold-start (503), aspetta estimated_time (max 45s)
74
+ e riprova una volta sola. Due tentativi totali.
75
+ - HF restituisce raw bytes PNG — non JSON.
76
+ - steps ottimali FLUX.1-schnell: 4 (veloce) – 8 (qualità).
77
+ """
78
+ model_id = _MODEL_MAP.get(req.model, "black-forest-labs/FLUX.1-schnell")
79
+ url = f"{_HF_API}/models/{model_id}"
80
+
81
+ payload: dict = {"inputs": req.prompt.strip()[:400]}
82
+ params: dict = {"num_inference_steps": min(max(req.steps, 1), 8)}
83
+ if req.width != 512: params["width"] = min(max(req.width, 256), 1024)
84
+ if req.height != 512: params["height"] = min(max(req.height, 256), 1024)
85
+ if req.negative_prompt:
86
+ params["negative_prompt"] = req.negative_prompt[:200]
87
+ payload["parameters"] = params
88
+
89
+ async with httpx.AsyncClient(timeout=90) as client:
90
+ for attempt in range(2):
91
+ try:
92
+ r = await client.post(url, headers=_hf_headers(), json=payload)
93
+
94
+ if r.status_code == 200:
95
+ b64 = base64.b64encode(r.content).decode()
96
+ return {
97
+ "ok": True, "image_b64": b64, "mime": "image/png",
98
+ "model": req.model, "prompt": req.prompt[:100],
99
+ }
100
+
101
+ if r.status_code == 503 and attempt == 0:
102
+ try:
103
+ wait = min(float(r.json().get("estimated_time", 20)), 45)
104
+ except Exception:
105
+ wait = 20
106
+ _logger.info("HF model loading, waiting %.0fs…", wait)
107
+ await asyncio.sleep(wait)
108
+ continue
109
+
110
+ try:
111
+ err = r.json().get("error", r.text[:200])
112
+ except Exception:
113
+ err = r.text[:200]
114
+ return {
115
+ "ok": False, "error": f"HF API {r.status_code}: {err}",
116
+ "hint": "Aggiungi HF_TOKEN nelle variabili d'ambiente per più richieste/ora.",
117
+ }
118
+
119
+ except httpx.TimeoutException:
120
+ return {"ok": False, "error": "Timeout 90s — modello in cold-start. Riprova tra 30s."}
121
+ except Exception as e:
122
+ return {"ok": False, "error": str(e)[:300]}
123
+
124
+ return {"ok": False, "error": "Impossibile generare dopo 2 tentativi."}
125
+
126
+
127
+ # ─── /analyze ─────────────────────────────────────────────────────────────────
128
+
129
+ @router.post("/analyze")
130
+ async def analyze_image(req: AnalyzeImageRequest):
131
+ """
132
+ Analizza immagine con vision LLM.
133
+
134
+ Chain:
135
+ 1. Groq llama-3.2-11b-vision (free tier, 30 img/min)
136
+ 2. GPT-4o-mini vision
137
+ 3. BLIP-large captioning (HF, puro captioning senza Q&A)
138
+ """
139
+ # Scarica immagine se URL
140
+ image_b64 = req.base64_image
141
+ image_mime = "image/jpeg"
142
+
143
+ if not image_b64 and req.url:
144
+ try:
145
+ async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
146
+ r = await c.get(req.url, headers={"User-Agent": _USER_AGENT})
147
+ if r.status_code == 200:
148
+ ct = r.headers.get("content-type", "")
149
+ image_mime = "image/png" if "png" in ct else "image/webp" if "webp" in ct else "image/jpeg"
150
+ image_b64 = base64.b64encode(r.content).decode()
151
+ else:
152
+ return {"ok": False, "error": f"Download immagine fallito: HTTP {r.status_code}"}
153
+ except Exception as e:
154
+ return {"ok": False, "error": f"Errore download: {str(e)[:200]}"}
155
+
156
+ if not image_b64:
157
+ return {"ok": False, "error": "Nessuna immagine (url o base64_image richiesti)."}
158
+
159
+ img_data_url = f"data:{image_mime};base64,{image_b64}"
160
+ question = req.question.strip() or "Descrivi questa immagine in dettaglio in italiano."
161
+ vision_body_msgs = [{"role": "user", "content": [
162
+ {"type": "image_url", "image_url": {"url": img_data_url}},
163
+ {"type": "text", "text": question},
164
+ ]}]
165
+
166
+ # 1. Groq vision (free tier)
167
+ _groq_key = os.getenv("GROQ_API_KEY", "")
168
+ if _groq_key:
169
+ try:
170
+ async with httpx.AsyncClient(timeout=30) as c:
171
+ r = await c.post(
172
+ "https://api.groq.com/openai/v1/chat/completions",
173
+ headers={"Authorization": f"Bearer {_groq_key}", "Content-Type": "application/json"},
174
+ json={"model": "llama-3.2-11b-vision-preview", "max_tokens": 600,
175
+ "messages": vision_body_msgs},
176
+ )
177
+ if r.status_code == 200:
178
+ return {"ok": True, "description": r.json()["choices"][0]["message"]["content"],
179
+ "provider": "llama-3.2-vision"}
180
+ except Exception:
181
+ pass
182
+
183
+ # 2. OpenAI GPT-4o-mini vision
184
+ _openai_key = os.getenv("OPENAI_API_KEY", "")
185
+ _openai_base = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
186
+ if _openai_key:
187
+ try:
188
+ async with httpx.AsyncClient(timeout=30) as c:
189
+ r = await c.post(
190
+ f"{_openai_base}/chat/completions",
191
+ headers={"Authorization": f"Bearer {_openai_key}", "Content-Type": "application/json"},
192
+ json={"model": "gpt-4o-mini", "max_tokens": 600, "messages": vision_body_msgs},
193
+ )
194
+ if r.status_code == 200:
195
+ return {"ok": True, "description": r.json()["choices"][0]["message"]["content"],
196
+ "provider": "gpt-4o-mini"}
197
+ except Exception:
198
+ pass
199
+
200
+ # 3. HF BLIP-large (captioning only)
201
+ try:
202
+ img_bytes = base64.b64decode(image_b64)
203
+ async with httpx.AsyncClient(timeout=30) as c:
204
+ r = await c.post(
205
+ f"{_HF_API}/models/Salesforce/blip-image-captioning-large",
206
+ headers={k: v for k, v in _hf_headers("application/octet-stream").items()},
207
+ content=img_bytes,
208
+ )
209
+ if r.status_code == 200:
210
+ results = r.json()
211
+ caption = (results[0].get("generated_text", "") if isinstance(results, list) and results else "")
212
+ if caption:
213
+ note = ("\n\n_BLIP fornisce solo didascalia base. Per Q&A su immagini, "
214
+ "aggiungi GROQ_API_KEY (gratuito su console.groq.com)._")
215
+ return {"ok": True, "description": caption + note, "provider": "blip-large"}
216
+ elif r.status_code == 503:
217
+ return {"ok": False, "error": "BLIP in avvio (cold-start ~30s). Riprova tra qualche secondo.",
218
+ "hint": "Aggiungi GROQ_API_KEY per analisi rapida e senza limiti di cold-start."}
219
+ except Exception:
220
+ pass
221
+
222
+ return {
223
+ "ok": False, "error": "Analisi immagini non disponibile.",
224
+ "hint": ("Aggiungi GROQ_API_KEY (free su console.groq.com) o OPENAI_API_KEY "
225
+ "nelle variabili del tuo HF Space."),
226
+ }
227
+
228
+
229
+ # ─── /search ──────────────────────────────────────────────────────────────────
230
+
231
+ @router.get("/search")
232
+ async def search_images(query: str, limit: int = 5, safe: bool = True):
233
+ """
234
+ Ricerca immagini: Pexels > Pixabay > Unsplash Source fallback.
235
+
236
+ Pexels API (PEXELS_API_KEY, gratuita 200 req/hr):
237
+ https://www.pexels.com/api/
238
+
239
+ Pixabay API (PIXABAY_API_KEY, gratuita 500 req/hr):
240
+ https://pixabay.com/api/docs/
241
+
242
+ Unsplash Source (zero API key — fallback):
243
+ URL deterministica ma rilevante per la query.
244
+ Non è una vera ricerca — genera varianti random per lo stesso query.
245
+ """
246
+ n = min(max(int(limit), 1), 10)
247
+
248
+ # Pexels
249
+ _pexels = os.getenv("PEXELS_API_KEY", "")
250
+ if _pexels:
251
+ try:
252
+ async with httpx.AsyncClient(timeout=10) as c:
253
+ r = await c.get(
254
+ "https://api.pexels.com/v1/search",
255
+ headers={"Authorization": _pexels},
256
+ params={"query": query, "per_page": n, "size": "medium"},
257
+ )
258
+ if r.status_code == 200:
259
+ photos = r.json().get("photos", [])
260
+ return {"ok": True, "source": "pexels", "results": [
261
+ {"url": p["src"]["medium"], "thumb": p["src"]["tiny"],
262
+ "alt": p.get("alt", query), "source": "Pexels",
263
+ "page_url": p["url"], "author": p["photographer"]}
264
+ for p in photos
265
+ ]}
266
+ except Exception:
267
+ pass
268
+
269
+ # Pixabay
270
+ _pixabay = os.getenv("PIXABAY_API_KEY", "")
271
+ if _pixabay:
272
+ try:
273
+ async with httpx.AsyncClient(timeout=10) as c:
274
+ r = await c.get(
275
+ "https://pixabay.com/api/",
276
+ params={"key": _pixabay, "q": query, "per_page": n,
277
+ "image_type": "photo", "safesearch": "true" if safe else "false"},
278
+ )
279
+ if r.status_code == 200:
280
+ return {"ok": True, "source": "pixabay", "results": [
281
+ {"url": h["webformatURL"], "thumb": h["previewURL"],
282
+ "alt": h.get("tags", query), "source": "Pixabay",
283
+ "page_url": h["pageURL"], "author": h["user"]}
284
+ for h in r.json().get("hits", [])
285
+ ]}
286
+ except Exception:
287
+ pass
288
+
289
+ # Unsplash Source fallback (zero key — random ma rilevante)
290
+ sq = query.replace(" ", ",")[:80]
291
+ return {
292
+ "ok": True, "source": "unsplash_source",
293
+ "note": "Usa PEXELS_API_KEY o PIXABAY_API_KEY per ricerca precisa.",
294
+ "results": [
295
+ {"url": f"https://source.unsplash.com/600x400/?{sq}&sig={i}",
296
+ "thumb": f"https://source.unsplash.com/200x150/?{sq}&sig={i}",
297
+ "alt": f"{query} — immagine {i + 1}",
298
+ "source": "Unsplash", "page_url": f"https://unsplash.com/s/photos/{sq}",
299
+ "author": "Unsplash"}
300
+ for i in range(n)
301
+ ],
302
+ }
303
+
304
+
305
+ class ScreenshotRequest(BaseModel):
306
+ url: str
307
+ width: int = 1280
308
+ height: int = 900
309
+ mobile: bool = False
310
+ wait: float = 2.0 # secondi di attesa dopo il caricamento
311
+ full_page: bool = False
312
+
313
+
314
+ @router.post("/screenshot")
315
+ async def screenshot_url(req: ScreenshotRequest):
316
+ """
317
+ Screenshot Playwright (Chromium headless) di qualsiasi URL.
318
+ Restituisce PNG come data URL base64.
319
+ Fallback automatico a Microlink se Playwright non disponibile.
320
+ """
321
+ try:
322
+ from playwright.async_api import async_playwright # noqa: PLC0415
323
+ except ImportError:
324
+ return {"ok": False, "error": "playwright non installato sul server.", "fallback": "microlink"}
325
+
326
+ w = min(max(int(req.width) or 1280, 320), 2560)
327
+ h = min(max(int(req.height) or 900, 240), 1440)
328
+
329
+ try:
330
+ async with async_playwright() as p:
331
+ browser = await p.chromium.launch(
332
+ headless=True,
333
+ args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--single-process"],
334
+ )
335
+ ctx = await browser.new_context(
336
+ viewport={"width": 375 if req.mobile else w, "height": 812 if req.mobile else h},
337
+ device_scale_factor=2 if req.mobile else 1,
338
+ user_agent=(
339
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
340
+ if req.mobile else
341
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36"
342
+ ),
343
+ )
344
+ page = await ctx.new_page()
345
+ await page.goto(str(req.url), wait_until="domcontentloaded", timeout=20_000)
346
+ if req.wait > 0:
347
+ await asyncio.sleep(min(float(req.wait), 5.0))
348
+ png_bytes = await page.screenshot(full_page=req.full_page, type="png")
349
+ await browser.close()
350
+
351
+ data_url = "data:image/png;base64," + base64.b64encode(png_bytes).decode()
352
+ return {
353
+ "ok": True,
354
+ "image_url": data_url,
355
+ "width": 375 if req.mobile else w,
356
+ "height": 812 if req.mobile else h,
357
+ "device": "mobile" if req.mobile else "desktop",
358
+ }
359
+ except Exception as e:
360
+ _logger.warning("screenshot %s: playwright failed: %s — trying Microlink", req.url, str(e)[:80])
361
+ # S601: Microlink fallback — chiamata reale invece di semplice errore
362
+ try:
363
+ import httpx as _httpx_ml
364
+ async with _httpx_ml.AsyncClient(timeout=15) as _mc:
365
+ _ml_r = await _mc.get(
366
+ "https://api.microlink.io/",
367
+ params={
368
+ "url": str(req.url),
369
+ "screenshot": "true",
370
+ "meta": "false",
371
+ "embed": "screenshot.url",
372
+ "viewport.width": 375 if req.mobile else w,
373
+ "viewport.height": 812 if req.mobile else h,
374
+ },
375
+ headers={"User-Agent": "agente-ai/3.2"},
376
+ )
377
+ if _ml_r.status_code == 200:
378
+ _ml_data = _ml_r.json()
379
+ _img_url = (_ml_data.get("data") or {}).get("screenshot", {}).get("url") or _ml_data.get("data")
380
+ if isinstance(_img_url, str) and _img_url.startswith("http"):
381
+ # Download image and convert to base64 data URL
382
+ _img_resp = await _mc.get(_img_url, timeout=10)
383
+ if _img_resp.status_code == 200:
384
+ _ct = _img_resp.headers.get("content-type", "image/jpeg")
385
+ _data_url = f"data:{_ct};base64," + base64.b64encode(_img_resp.content).decode()
386
+ return {
387
+ "ok": True,
388
+ "image_url": _data_url,
389
+ "width": 375 if req.mobile else w,
390
+ "height": 812 if req.mobile else h,
391
+ "device": "mobile" if req.mobile else "desktop",
392
+ "source": "microlink",
393
+ }
394
+ except Exception as _ml_exc:
395
+ _logger.warning("screenshot %s: microlink also failed: %s", req.url, str(_ml_exc)[:80])
396
+ return {"ok": False, "error": str(e)[:200], "fallback": "microlink_unavailable"}
397
+
398
+
399
+ # ── PDF Generation ─────────────────────────────────────────────────────────────
400
+
401
+ class PdfRequest(BaseModel):
402
+ content: str
403
+ filename: str = "documento.pdf"
404
+ format: str = "html" # html | markdown | text
405
+
406
+
407
+ @router.post("/pdf")
408
+ async def create_pdf(req: PdfRequest):
409
+ """
410
+ S601: Genera PDF da HTML, Markdown o testo.
411
+ Tenta WeasyPrint → reportlab → risposta JSON+base64 del contenuto raw.
412
+ Restituisce {ok, pdf_b64, filename, size_bytes} oppure {ok: false, error}.
413
+ """
414
+ import base64 as _b64
415
+ content = req.content[:80_000]
416
+
417
+ # 1. WeasyPrint (miglior qualità HTML→PDF)
418
+ try:
419
+ from weasyprint import HTML as _WP_HTML, CSS as _WP_CSS # noqa: PLC0415
420
+ if req.format == "html":
421
+ _html = content
422
+ elif req.format == "markdown":
423
+ try:
424
+ import markdown as _md # noqa: PLC0415
425
+ _html = _md.markdown(content, extensions=["tables", "fenced_code"])
426
+ except ImportError:
427
+ _html = f"<pre>{content}</pre>"
428
+ else:
429
+ _html = f"<pre style='font-family:monospace;white-space:pre-wrap'>{content}</pre>"
430
+ _pdf_bytes = _WP_HTML(string=_html).write_pdf()
431
+ return {
432
+ "ok": True,
433
+ "pdf_b64": _b64.b64encode(_pdf_bytes).decode(),
434
+ "filename": req.filename,
435
+ "size_bytes": len(_pdf_bytes),
436
+ "engine": "weasyprint",
437
+ }
438
+ except ImportError:
439
+ pass # WeasyPrint non installato — prova reportlab
440
+ except Exception as _wp_exc:
441
+ _logger.warning("pdf weasyprint: %s", str(_wp_exc)[:120])
442
+
443
+ # 2. reportlab (fallback leggero)
444
+ try:
445
+ from reportlab.lib.pagesizes import A4 # noqa: PLC0415
446
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer # noqa: PLC0415
447
+ from reportlab.lib.styles import getSampleStyleSheet # noqa: PLC0415
448
+ import io as _io
449
+ _buf = _io.BytesIO()
450
+ _doc = SimpleDocTemplate(_buf, pagesize=A4)
451
+ _styles = getSampleStyleSheet()
452
+ _story = []
453
+ for line in content.split("\n"):
454
+ if line.strip():
455
+ _story.append(Paragraph(line.replace("<", "&lt;").replace(">", "&gt;"), _styles["Normal"]))
456
+ else:
457
+ _story.append(Spacer(1, 12))
458
+ _doc.build(_story)
459
+ _pdf_bytes = _buf.getvalue()
460
+ return {
461
+ "ok": True,
462
+ "pdf_b64": _b64.b64encode(_pdf_bytes).decode(),
463
+ "filename": req.filename,
464
+ "size_bytes": len(_pdf_bytes),
465
+ "engine": "reportlab",
466
+ }
467
+ except ImportError:
468
+ pass
469
+ except Exception as _rl_exc:
470
+ _logger.warning("pdf reportlab: %s", str(_rl_exc)[:120])
471
+
472
+ # 3. Graceful degradation — restituisce il contenuto raw encodato
473
+ _raw = content.encode("utf-8")
474
+ return {
475
+ "ok": False,
476
+ "error": "Nessun engine PDF disponibile (WeasyPrint/reportlab non installati). Contenuto allegato come testo.",
477
+ "content_b64": _b64.b64encode(_raw).decode(),
478
+ "filename": req.filename.replace(".pdf", ".txt"),
479
+ "size_bytes": len(_raw),
480
+ }
main.py CHANGED
@@ -16,7 +16,7 @@ Mappa dei router:
16
  api.coding → /api/coding/** (pre-esistente)
17
  api.web → /web/** (pre-esistente)
18
  """
19
- import os, logging, secrets as _secrets_mod
20
  from fastapi import FastAPI, Request
21
  from fastapi.staticfiles import StaticFiles
22
  from fastapi.responses import JSONResponse
@@ -112,7 +112,7 @@ async def _preflight_fallback(path: str, request: Request):
112
  })
113
 
114
  # ── WARN-1 fix: body size hard limit — S292 ─────────────────────────────────
115
- _MAX_BODY_BYTES = 32_000 # 32 KB
116
 
117
  @app.middleware('http')
118
  async def _body_size_middleware(request: Request, call_next):
@@ -121,13 +121,42 @@ async def _body_size_middleware(request: Request, call_next):
121
  try:
122
  if int(cl) > _MAX_BODY_BYTES:
123
  return JSONResponse(
124
- {'detail': f'Payload troppo grande: max {_MAX_BODY_BYTES} bytes. (R-S292)'},
125
  status_code=413,
126
  )
127
  except ValueError:
128
  pass
129
  return await call_next(request)
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  # ── Include routers (S354 split) ─────────────────────────────────────────────
132
  from api.conversations import router as _conv_router
133
  from api.agent_memory import router as _mem_router
@@ -142,6 +171,20 @@ from api.terminal import router as _terminal_router
142
  from api.browser import router as _browser_router
143
  from api.coding import router as _coding_router
144
  from api.web import router as _web_router
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
  app.include_router(_conv_router)
147
  app.include_router(_mem_router)
@@ -156,6 +199,12 @@ app.include_router(_terminal_router)
156
  app.include_router(_browser_router)
157
  app.include_router(_coding_router)
158
  app.include_router(_web_router)
 
 
 
 
 
 
159
 
160
  # ── Startup: heartbeat + warmup ────────────────────────────────────────────────
161
  @app.on_event('startup')
 
16
  api.coding → /api/coding/** (pre-esistente)
17
  api.web → /web/** (pre-esistente)
18
  """
19
+ import os, time, logging, secrets as _secrets_mod
20
  from fastapi import FastAPI, Request
21
  from fastapi.staticfiles import StaticFiles
22
  from fastapi.responses import JSONResponse
 
112
  })
113
 
114
  # ── WARN-1 fix: body size hard limit — S292 ─────────────────────────────────
115
+ _MAX_BODY_BYTES = 512_000 # 512 KB — increased for PDF/vision content (V006)
116
 
117
  @app.middleware('http')
118
  async def _body_size_middleware(request: Request, call_next):
 
121
  try:
122
  if int(cl) > _MAX_BODY_BYTES:
123
  return JSONResponse(
124
+ {'detail': f'Payload troppo grande: max {_MAX_BODY_BYTES // 1024}KB. (R-S292)'},
125
  status_code=413,
126
  )
127
  except ValueError:
128
  pass
129
  return await call_next(request)
130
 
131
+ # ── S477-SEC4: Rate limiting in-memory per IP ────────────────────────────────
132
+ # VITE_INTERNAL_TOKEN è visibile nel bundle JS → rate limiting come mitigazione
133
+ # pratica all'abuso (CF Worker proxy è il fix definitivo, non ancora implementato).
134
+ # 120 req/min globale per IP; OPTIONS exempt (preflight CORS non contano).
135
+ # In-memory: si resetta a ogni restart HF Space — accettabile su free tier.
136
+ _rl_store: dict[str, list[float]] = {}
137
+ _RL_WINDOW = 60.0
138
+ _RL_LIMIT = 120 # req/minuto per IP
139
+
140
+ @app.middleware('http')
141
+ async def _rate_limit_middleware(request: Request, call_next):
142
+ if request.method == "OPTIONS":
143
+ return await call_next(request)
144
+ ip = (request.client.host if request.client else None) or "unknown"
145
+ now = time.monotonic()
146
+ hits = [t for t in _rl_store.get(ip, []) if now - t < _RL_WINDOW]
147
+ if len(hits) >= _RL_LIMIT:
148
+ return JSONResponse({"detail": "Too many requests"}, status_code=429)
149
+ hits.append(now)
150
+ _rl_store[ip] = hits
151
+ # S572: prune _rl_store ogni ~500 req — evita leak memoria con molti IP unici.
152
+ # Rimuove IP con zero hit nella finestra (inattivi da > _RL_WINDOW secondi).
153
+ if len(_rl_store) > 500:
154
+ _cutoff = now - _RL_WINDOW
155
+ _stale = [_k for _k, _v in list(_rl_store.items()) if not _v or _v[-1] < _cutoff]
156
+ for _k in _stale:
157
+ _rl_store.pop(_k, None)
158
+ return await call_next(request)
159
+
160
  # ── Include routers (S354 split) ─────────────────────────────────────────────
161
  from api.conversations import router as _conv_router
162
  from api.agent_memory import router as _mem_router
 
171
  from api.browser import router as _browser_router
172
  from api.coding import router as _coding_router
173
  from api.web import router as _web_router
174
+ from api.vision import router as _vision_router # V001: generate_image / analyze_image / search_images
175
+ from api.email import router as _email_router # V002: send_email via Resend API
176
+ from api.database import router as _db_router # V003: database_query PostgreSQL/SQLite
177
+ from api.research import router as _research_router # V004: web_research multi-URL + Groq synthesis
178
+ # Doc2-1b-FIX: memory/sync router non era montato — endpoint /api/memory/sync/* non raggiungibili
179
+ # NOTA: create_memory_sync_router(memory) è una factory — richiede l'istanza MemoryManager.
180
+ try:
181
+ from memory.sync import create_memory_sync_router as _create_sync_router
182
+ from api.state import _get_mem_manager as _gmm
183
+ _sync_router = _create_sync_router(_gmm())
184
+ _sync_router_available = True
185
+ except Exception as _sync_err:
186
+ _sync_router_available = False
187
+ print(f'BOOT: memory/sync router non disponibile — {_sync_err}', flush=True)
188
 
189
  app.include_router(_conv_router)
190
  app.include_router(_mem_router)
 
199
  app.include_router(_browser_router)
200
  app.include_router(_coding_router)
201
  app.include_router(_web_router)
202
+ app.include_router(_vision_router)
203
+ app.include_router(_email_router)
204
+ app.include_router(_db_router)
205
+ app.include_router(_research_router)
206
+ if _sync_router_available:
207
+ app.include_router(_sync_router)
208
 
209
  # ── Startup: heartbeat + warmup ────────────────────────────────────────────────
210
  @app.on_event('startup')
memory/episodic.py CHANGED
@@ -121,10 +121,18 @@ class EpisodicMemory:
121
  def search_text(self, query: str, n: int = 5) -> list[Episode]:
122
  if self._sb:
123
  try:
124
- rows = self._sb.table("episodes").select("*").ilike("task", f"%{query}%").limit(n).execute().data or []
125
- if not rows:
126
- rows = self._sb.table("episodes").select("*").ilike("output", f"%{query}%").limit(n).execute().data or []
127
- return [self._from_sb(r) for r in rows]
 
 
 
 
 
 
 
 
128
  except Exception:
129
  pass
130
  if not self._db:
 
121
  def search_text(self, query: str, n: int = 5) -> list[Episode]:
122
  if self._sb:
123
  try:
124
+ # S571-GAP4: due query indipendenti possono restituire lo stesso episode
125
+ # (quando task e output matchano entrambi). Dedup by id prima del return.
126
+ task_rows = self._sb.table("episodes").select("*").ilike("task", f"%{query}%").limit(n).execute().data or []
127
+ output_rows = self._sb.table("episodes").select("*").ilike("output", f"%{query}%").limit(n).execute().data or []
128
+ seen: set[int] = set()
129
+ merged: list[dict] = []
130
+ for r in task_rows + output_rows:
131
+ rid = r.get("id")
132
+ if rid not in seen:
133
+ seen.add(rid)
134
+ merged.append(r)
135
+ return [self._from_sb(r) for r in merged[:n]]
136
  except Exception:
137
  pass
138
  if not self._db:
memory/manager.py CHANGED
@@ -29,27 +29,30 @@ class MemoryManager:
29
  parts = []
30
 
31
  # 1. Working memory — conversazione recente
32
- working_ctx = self.working.get_context_string(n=6)
 
33
  if working_ctx:
34
  parts.append(working_ctx)
35
 
36
  # 2. Semantic memory — conoscenza rilevante
37
  if self.semantic.available:
38
- semantic_hits = self.semantic.search(query, n_results=4)
 
39
  if semantic_hits:
40
- lines = [f"- {h['content'][:200]}" for h in semantic_hits if h["similarity"] > 0.3]
41
  if lines:
42
  parts.append("Conoscenza rilevante:\n" + "\n".join(lines))
43
 
44
  # 3. Reflection lessons — errori da evitare
45
- lessons = self.reflection.get_relevant_lessons(query, n=2)
 
46
  if lessons:
47
  lesson_lines = []
48
  for l in lessons:
49
  if l["type"] == "failure":
50
- lesson_lines.append(f"- EVITA: {l['avoid'][:100]}")
51
  else:
52
- lesson_lines.append(f"- STRATEGIA: {l['strategy'][:100]}")
53
  if lesson_lines:
54
  parts.append("Lezioni passate:\n" + "\n".join(lesson_lines))
55
 
@@ -62,12 +65,13 @@ class MemoryManager:
62
  if user_msg:
63
  self.working.add("user", user_msg)
64
  self.working.add("assistant", response)
65
- # Episodic: salva la coppia
66
- self.episodic.add("chat", user_msg[:300], response[:500], True)
67
- # Semantic: indicizza per similarity search futura
68
  if self.semantic.available and user_msg and len(response) > 50:
69
- combined = f"Q: {user_msg[:200]} A: {response[:400]}"
70
- self.semantic.add(combined, {"type": "chat", "query": user_msg[:100]})
 
71
 
72
  async def save_episode(self, type_: str, task: str, output: str, success: bool, tags: list | None = None):
73
  self.episodic.add(type_, task, output, success, tags)
@@ -81,7 +85,7 @@ class MemoryManager:
81
  results.append({**h, "layer": "semantic"})
82
  if layer in (None, "episodic"):
83
  for ep in self.episodic.search_text(query, n=n):
84
- results.append({"content": f"{ep.task} → {ep.output[:200]}", "layer": "episodic", "type": ep.type, "success": ep.success})
85
  if layer == "reflection":
86
  lessons = self.reflection.get_relevant_lessons(query, n=n)
87
  results.extend([{**l, "layer": "reflection"} for l in lessons])
@@ -89,15 +93,18 @@ class MemoryManager:
89
 
90
  async def reflect(self, task: str, output: str, success: bool, error: str | None = None) -> dict:
91
  if success:
92
- self.reflection.record_success(task, output[:300])
 
93
  await self.save_episode("fix", task, output, True)
94
  else:
95
- self.reflection.record_failure(task, error or output[:300])
96
- await self.save_episode("error", task, error or output[:300], False)
 
97
  return {
98
  "recorded": True,
99
- "top_patterns": self.reflection.get_top_patterns(3),
100
- "lessons": self.reflection.get_relevant_lessons(task, 2),
 
101
  }
102
 
103
  def stats(self) -> dict:
 
29
  parts = []
30
 
31
  # 1. Working memory — conversazione recente
32
+ # S592: n=6→10 — più turns di working memory nel contesto
33
+ working_ctx = self.working.get_context_string(n=10)
34
  if working_ctx:
35
  parts.append(working_ctx)
36
 
37
  # 2. Semantic memory — conoscenza rilevante
38
  if self.semantic.available:
39
+ # S590: n_results=4→6 — più hit semantici nel contesto di memoria
40
+ semantic_hits = self.semantic.search(query, n_results=6)
41
  if semantic_hits:
42
+ lines = [f"- {h['content'][:300]}" for h in semantic_hits if h["similarity"] > 0.3] # S585: 200→300
43
  if lines:
44
  parts.append("Conoscenza rilevante:\n" + "\n".join(lines))
45
 
46
  # 3. Reflection lessons — errori da evitare
47
+ # S592: n=2→4 — più lessons rilevanti nel contesto (context lookup, diverso da record_outcome)
48
+ lessons = self.reflection.get_relevant_lessons(query, n=4)
49
  if lessons:
50
  lesson_lines = []
51
  for l in lessons:
52
  if l["type"] == "failure":
53
+ lesson_lines.append(f"- EVITA: {l['avoid'][:300]}") # S607: 200→300
54
  else:
55
+ lesson_lines.append(f"- STRATEGIA: {l['strategy'][:300]}") # S607: 200→300
56
  if lesson_lines:
57
  parts.append("Lezioni passate:\n" + "\n".join(lesson_lines))
58
 
 
65
  if user_msg:
66
  self.working.add("user", user_msg)
67
  self.working.add("assistant", response)
68
+ # Episodic: salva la coppia — S571: 500→2000 (episodic.add cappella già a 2000)
69
+ self.episodic.add("chat", user_msg[:500], response[:2000], True)
70
+ # Semantic: indicizza per similarity search futura — S571: combined 600→1100 chars
71
  if self.semantic.available and user_msg and len(response) > 50:
72
+ # S600: user_msg 300→500 — più contesto query per semantic similarity
73
+ combined = f"Q: {user_msg[:500]} A: {response[:800]}"
74
+ self.semantic.add(combined, {"type": "chat", "query": user_msg[:300]}) # S607: 200→300
75
 
76
  async def save_episode(self, type_: str, task: str, output: str, success: bool, tags: list | None = None):
77
  self.episodic.add(type_, task, output, success, tags)
 
85
  results.append({**h, "layer": "semantic"})
86
  if layer in (None, "episodic"):
87
  for ep in self.episodic.search_text(query, n=n):
88
+ results.append({"content": f"{ep.task} → {ep.output[:300]}", "layer": "episodic", "type": ep.type, "success": ep.success}) # S584: 200→300
89
  if layer == "reflection":
90
  lessons = self.reflection.get_relevant_lessons(query, n=n)
91
  results.extend([{**l, "layer": "reflection"} for l in lessons])
 
93
 
94
  async def reflect(self, task: str, output: str, success: bool, error: str | None = None) -> dict:
95
  if success:
96
+ # S601: output 300→500 — parity con reflection.record_success (ora a 500)
97
+ self.reflection.record_success(task, output[:500])
98
  await self.save_episode("fix", task, output, True)
99
  else:
100
+ # S601: output 300→500 x2 — errori completi passati a record_failure/save_episode
101
+ self.reflection.record_failure(task, error or output[:500])
102
+ await self.save_episode("error", task, error or output[:500], False)
103
  return {
104
  "recorded": True,
105
+ # S591: top_patterns 3→5, lessons 2→4 — più pattern/lessons nel feedback
106
+ "top_patterns": self.reflection.get_top_patterns(5),
107
+ "lessons": self.reflection.get_relevant_lessons(task, 4),
108
  }
109
 
110
  def stats(self) -> dict:
memory/migration_pgvector.sql ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- migration_pgvector.sql — S569: pgvector su Supabase per semantic memory
2
+ --
3
+ -- Esegui UNA VOLTA nel SQL Editor di Supabase (Project → SQL Editor → New Query).
4
+ -- Riavvia il backend dopo l'esecuzione.
5
+ --
6
+ -- Prerequisito: la tabella semantic_memory deve già esistere.
7
+ -- Se non esiste, creala prima:
8
+ -- CREATE TABLE IF NOT EXISTS public.semantic_memory (
9
+ -- id TEXT PRIMARY KEY,
10
+ -- content TEXT NOT NULL,
11
+ -- metadata JSONB DEFAULT '{}'
12
+ -- );
13
+
14
+
15
+ -- ── 1. Estensione pgvector ────────────────────────────────────────────────────
16
+ -- Disponibile su tutti i piani Supabase (PostgreSQL ≥ 14.x).
17
+ CREATE EXTENSION IF NOT EXISTS vector;
18
+
19
+
20
+ -- ── 2. Colonna embedding ──────────────────────────────────────────────────────
21
+ -- BAAI/bge-small-en-v1.5 produce vettori a 384 dimensioni.
22
+ ALTER TABLE public.semantic_memory
23
+ ADD COLUMN IF NOT EXISTS embedding vector(384);
24
+
25
+
26
+ -- ── 3. Funzione RPC per similarity search ────────────────────────────────────
27
+ -- Chiamata da semantic.py: self._sb.rpc('match_semantic_memory', {...}).execute()
28
+ -- match_threshold 0.2 = soglia coseno minima; 1.0 = identico, 0.0 = ortogonale.
29
+ CREATE OR REPLACE FUNCTION match_semantic_memory(
30
+ query_embedding vector(384),
31
+ match_count int DEFAULT 5,
32
+ match_threshold float DEFAULT 0.2
33
+ )
34
+ RETURNS TABLE (
35
+ id text,
36
+ content text,
37
+ metadata jsonb,
38
+ similarity float
39
+ )
40
+ LANGUAGE sql STABLE
41
+ AS $$
42
+ SELECT
43
+ id,
44
+ content,
45
+ metadata,
46
+ (1 - (embedding <=> query_embedding))::float AS similarity
47
+ FROM public.semantic_memory
48
+ WHERE embedding IS NOT NULL
49
+ AND (1 - (embedding <=> query_embedding)) > match_threshold
50
+ ORDER BY embedding <=> query_embedding
51
+ LIMIT match_count;
52
+ $$;
53
+
54
+
55
+ -- ── 4. Indice IVFFlat per ricerca ANN veloce (opzionale) ─────────────────────
56
+ -- Utile oltre ~1000 righe. Se la tabella è vuota o piccola, salta per ora.
57
+ -- Decommentare quando il numero di documenti supera 1000:
58
+ --
59
+ -- CREATE INDEX IF NOT EXISTS semantic_memory_embedding_idx
60
+ -- ON public.semantic_memory
61
+ -- USING ivfflat (embedding vector_cosine_ops)
62
+ -- WITH (lists = 100);
63
+ --
64
+ -- Per tabelle molto grandi (>100K righe) preferire HNSW:
65
+ -- CREATE INDEX IF NOT EXISTS semantic_memory_embedding_hnsw_idx
66
+ -- ON public.semantic_memory
67
+ -- USING hnsw (embedding vector_cosine_ops)
68
+ -- WITH (m = 16, ef_construction = 64);
69
+
70
+
71
+ -- ── Verifica ──────────────────────────────────────────────────────────────────
72
+ -- Esegui queste query per confermare che tutto sia a posto:
73
+ --
74
+ -- SELECT column_name, data_type
75
+ -- FROM information_schema.columns
76
+ -- WHERE table_name = 'semantic_memory';
77
+ --
78
+ -- SELECT proname FROM pg_proc WHERE proname = 'match_semantic_memory';
memory/reflection.py CHANGED
@@ -26,13 +26,14 @@ class ReflectionMemory:
26
  def record_failure(self, task: str, error: str, attempted_strategy: str = ""):
27
  entry = {
28
  "ts": datetime.now().isoformat(),
29
- "task": task[:300],
 
30
  "error": error[:500],
31
  "strategy": attempted_strategy,
32
  }
33
  self._data["failures"].append(entry)
34
  # Track pattern frequency
35
- key = error[:80].lower()
36
  self._data["patterns"][key] = self._data["patterns"].get(key, 0) + 1
37
  # Cap at 200 entries
38
  self._data["failures"] = self._data["failures"][-200:]
@@ -41,8 +42,9 @@ class ReflectionMemory:
41
  def record_success(self, task: str, strategy: str):
42
  entry = {
43
  "ts": datetime.now().isoformat(),
44
- "task": task[:300],
45
- "strategy": strategy[:300],
 
46
  }
47
  self._data["successes"].append(entry)
48
  self._data["successes"] = self._data["successes"][-100:]
 
26
  def record_failure(self, task: str, error: str, attempted_strategy: str = ""):
27
  entry = {
28
  "ts": datetime.now().isoformat(),
29
+ # S600: task 300→500 — task description può essere più lunga
30
+ "task": task[:500],
31
  "error": error[:500],
32
  "strategy": attempted_strategy,
33
  }
34
  self._data["failures"].append(entry)
35
  # Track pattern frequency
36
+ key = error[:120].lower() # S582: 80→120 (chiave pattern freq — conservativo)
37
  self._data["patterns"][key] = self._data["patterns"].get(key, 0) + 1
38
  # Cap at 200 entries
39
  self._data["failures"] = self._data["failures"][-200:]
 
42
  def record_success(self, task: str, strategy: str):
43
  entry = {
44
  "ts": datetime.now().isoformat(),
45
+ # S600: task 300→500, strategy 300→500 — più contesto per replay futuro
46
+ "task": task[:500],
47
+ "strategy": strategy[:500],
48
  }
49
  self._data["successes"].append(entry)
50
  self._data["successes"] = self._data["successes"][-100:]
memory/semantic.py CHANGED
@@ -1,18 +1,29 @@
1
  """
2
  semantic.py — Semantic Memory
3
  Preferenze, concetti, conoscenza persistente.
4
- Database: Supabase (text search). Fallback: ChromaDB locale.
5
  Backend server: HuggingFace Spaces (FastAPI).
 
 
 
 
 
 
6
  """
7
  from __future__ import annotations
8
 
9
- import os, uuid
 
 
 
 
10
  from pathlib import Path
11
- from typing import Any
12
 
13
  CHROMA_PATH = Path("chroma_db")
14
  COLLECTION_NAME = "agente_semantic"
15
- EMBED_MODEL = "BAAI/bge-small-en-v1.5"
 
 
16
 
17
  try:
18
  import chromadb
@@ -22,12 +33,78 @@ except ImportError:
22
  CHROMA_AVAILABLE = False
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  class SemanticMemory:
26
  def __init__(self):
27
- self._client = None
28
  self._collection = None
29
  self._embed_fn = None
30
- self._sb = None
 
 
 
31
  self.available = False
32
 
33
  def _try_supabase(self):
@@ -47,14 +124,30 @@ class SemanticMemory:
47
  try:
48
  self._sb.table("semantic_memory").select("id").limit(1).execute()
49
  self.available = True
50
- print("SemanticMemory: Supabase ✓", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  return
52
  except Exception as e:
53
- code = getattr(e, 'code', '') or (e.args[0] if e.args else '')
54
- if 'PGRST205' in str(e) or 'not found' in str(e).lower():
55
  print(
56
  "SemanticMemory: tabella 'semantic_memory' mancante su Supabase.\n"
57
- " → Crea la tabella con questo SQL nel tuo Supabase SQL Editor:\n"
58
  " CREATE TABLE IF NOT EXISTS public.semantic_memory (\n"
59
  " id TEXT PRIMARY KEY,\n"
60
  " content TEXT NOT NULL,\n"
@@ -72,10 +165,10 @@ class SemanticMemory:
72
  print("SemanticMemory: disabilitata (chromadb non installato, tabella Supabase mancante).", flush=True)
73
  return
74
  try:
75
- self._embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
76
  model_name=EMBED_MODEL
77
  )
78
- self._client = chromadb.PersistentClient(path=str(CHROMA_PATH))
79
  self._collection = self._client.get_or_create_collection(
80
  name=COLLECTION_NAME,
81
  embedding_function=self._embed_fn,
@@ -86,15 +179,71 @@ class SemanticMemory:
86
  except Exception as e:
87
  print(f"WARN: SemanticMemory non disponibile: {e}", flush=True)
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  # ── Write ─────────────────────────────────────────────────────────────────
90
 
91
  def add(self, text: str, metadata: dict | None = None, doc_id: str | None = None):
92
- _id = doc_id or str(uuid.uuid4())
 
93
  if self._sb:
94
  try:
95
- self._sb.table("semantic_memory").upsert({
96
- "id": _id, "content": text[:2000], "metadata": metadata or {},
97
- }).execute()
 
 
 
 
98
  return
99
  except Exception:
100
  pass
@@ -107,8 +256,45 @@ class SemanticMemory:
107
 
108
  # ── Read ──────────────────────────────────────────────────────────────────
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  def search(self, query: str, n_results: int = 5) -> list[dict]:
111
  if self._sb:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  try:
113
  rows = (
114
  self._sb.table("semantic_memory")
@@ -117,8 +303,14 @@ class SemanticMemory:
117
  .limit(n_results)
118
  .execute().data or []
119
  )
120
- return [{"content": r["content"], "metadata": r.get("metadata", {}),
121
- "similarity": 0.8} for r in rows]
 
 
 
 
 
 
122
  except Exception:
123
  pass
124
  if not self._collection:
@@ -128,9 +320,9 @@ class SemanticMemory:
128
  query_texts=[query],
129
  n_results=min(n_results, self._collection.count() or 1),
130
  )
131
- docs = results.get("documents", [[]])[0]
132
- metas = results.get("metadatas", [[]])[0]
133
- dists = results.get("distances", [[]])[0]
134
  return [
135
  {"content": doc, "metadata": meta, "similarity": round(1 - dist, 4)}
136
  for doc, meta, dist in zip(docs, metas, dists)
@@ -147,6 +339,14 @@ class SemanticMemory:
147
  return self._collection.count() if self._collection else 0
148
 
149
  def stats(self) -> dict:
150
- backend = "supabase" if self._sb else ("chroma" if self._collection else "none")
151
- return {"available": self.available, "documents": self.count(),
152
- "model": EMBED_MODEL, "backend": backend}
 
 
 
 
 
 
 
 
 
1
  """
2
  semantic.py — Semantic Memory
3
  Preferenze, concetti, conoscenza persistente.
4
+ Database: Supabase (pgvector cosine similarity). Fallback: ilike + Jaccard.
5
  Backend server: HuggingFace Spaces (FastAPI).
6
+
7
+ S568: similarity reale via Jaccard word-overlap (ilike path).
8
+ S569: pgvector via HF Inference API — cosine similarity reale su embeddings BAAI/bge-small-en-v1.5.
9
+ Attivazione: eseguire migration_pgvector.sql nel SQL Editor Supabase.
10
+ Fallback automatico a ilike+Jaccard se pgvector non ancora attivato.
11
+ S570: LRU cache degli embedding (256 entry, TTL 10 min) — evita ricalcolo HF API per query ripetute.
12
  """
13
  from __future__ import annotations
14
 
15
+ import hashlib
16
+ import os
17
+ import time as _time
18
+ import uuid
19
+ from collections import OrderedDict
20
  from pathlib import Path
 
21
 
22
  CHROMA_PATH = Path("chroma_db")
23
  COLLECTION_NAME = "agente_semantic"
24
+ EMBED_MODEL = "BAAI/bge-small-en-v1.5" # 384 dims
25
+ EMBED_DIMS = 384
26
+ EMBED_MAX_CHARS = 1500 # bge-small max ~512 token ≈ 1500 chars
27
 
28
  try:
29
  import chromadb
 
33
  CHROMA_AVAILABLE = False
34
 
35
 
36
+ # ── S570: LRU cache per embedding ─────────────────────────────────────────────
37
+
38
+ _EMBED_CACHE_MAX = 256 # entry massime in memoria
39
+ _EMBED_CACHE_TTL = 600.0 # secondi di validità (10 minuti)
40
+
41
+
42
+ class _EmbedCache:
43
+ """LRU cache O(1) per embedding vettoriali con TTL.
44
+
45
+ Struttura: OrderedDict[sha256_key → (embedding, monotonic_timestamp)].
46
+ - get(): ritorna embedding se presente e non scaduto, else None.
47
+ - set(): inserisce/aggiorna; evicta l'entry più vecchia se piena.
48
+ - Sicuro in single-thread (asyncio è single-threaded per design).
49
+ """
50
+
51
+ def __init__(self, max_size: int = _EMBED_CACHE_MAX, ttl: float = _EMBED_CACHE_TTL) -> None:
52
+ self._cache: OrderedDict[str, tuple[list[float], float]] = OrderedDict()
53
+ self._max = max_size
54
+ self._ttl = ttl
55
+ self.hits = 0
56
+ self.misses = 0
57
+
58
+ @staticmethod
59
+ def _key(text: str) -> str:
60
+ return hashlib.sha256(text.encode()).hexdigest()[:16]
61
+
62
+ def get(self, text: str) -> list[float] | None:
63
+ k = self._key(text)
64
+ if k not in self._cache:
65
+ self.misses += 1
66
+ return None
67
+ emb, ts = self._cache[k]
68
+ if _time.monotonic() - ts > self._ttl:
69
+ del self._cache[k]
70
+ self.misses += 1
71
+ return None
72
+ self._cache.move_to_end(k) # LRU touch
73
+ self.hits += 1
74
+ return emb
75
+
76
+ def set(self, text: str, emb: list[float]) -> None:
77
+ k = self._key(text)
78
+ if k in self._cache:
79
+ self._cache.move_to_end(k)
80
+ elif len(self._cache) >= self._max:
81
+ self._cache.popitem(last=False) # evicta LRU (il più vecchio)
82
+ self._cache[k] = (emb, _time.monotonic())
83
+
84
+ def __len__(self) -> int:
85
+ return len(self._cache)
86
+
87
+ def stats(self) -> dict:
88
+ total = self.hits + self.misses
89
+ return {
90
+ "size": len(self._cache),
91
+ "max_size": self._max,
92
+ "ttl_s": self._ttl,
93
+ "hits": self.hits,
94
+ "misses": self.misses,
95
+ "hit_rate": round(self.hits / total, 3) if total else 0.0,
96
+ }
97
+
98
+
99
  class SemanticMemory:
100
  def __init__(self):
101
+ self._client = None # chromadb fallback
102
  self._collection = None
103
  self._embed_fn = None
104
+ self._sb = None # Supabase client
105
+ self._hf_client = None # HuggingFace InferenceClient (lazy)
106
+ self._pgvector = False # S569: True quando match_semantic_memory RPC disponibile
107
+ self._embed_cache = _EmbedCache() # S570: LRU 256 entry, TTL 10 min
108
  self.available = False
109
 
110
  def _try_supabase(self):
 
124
  try:
125
  self._sb.table("semantic_memory").select("id").limit(1).execute()
126
  self.available = True
127
+ # S569: proba se match_semantic_memory RPC esiste (pgvector attivato)
128
+ _zero_emb = "[" + ",".join(["0.0"] * EMBED_DIMS) + "]"
129
+ try:
130
+ self._sb.rpc("match_semantic_memory", {
131
+ "query_embedding": _zero_emb,
132
+ "match_count": 1,
133
+ "match_threshold": 0.99,
134
+ }).execute()
135
+ self._pgvector = True
136
+ print("SemanticMemory: Supabase ✓ pgvector ✓ (cosine similarity attiva)", flush=True)
137
+ except Exception:
138
+ self._pgvector = False
139
+ print(
140
+ "SemanticMemory: Supabase ✓ pgvector non attivo — ilike+Jaccard.\n"
141
+ " → Per abilitare cosine similarity reale esegui migration_pgvector.sql\n"
142
+ " nel SQL Editor di Supabase.",
143
+ flush=True
144
+ )
145
  return
146
  except Exception as e:
147
+ if "PGRST205" in str(e) or "not found" in str(e).lower():
 
148
  print(
149
  "SemanticMemory: tabella 'semantic_memory' mancante su Supabase.\n"
150
+ " → Crea prima la tabella base, poi esegui migration_pgvector.sql.\n"
151
  " CREATE TABLE IF NOT EXISTS public.semantic_memory (\n"
152
  " id TEXT PRIMARY KEY,\n"
153
  " content TEXT NOT NULL,\n"
 
165
  print("SemanticMemory: disabilitata (chromadb non installato, tabella Supabase mancante).", flush=True)
166
  return
167
  try:
168
+ self._embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
169
  model_name=EMBED_MODEL
170
  )
171
+ self._client = chromadb.PersistentClient(path=str(CHROMA_PATH))
172
  self._collection = self._client.get_or_create_collection(
173
  name=COLLECTION_NAME,
174
  embedding_function=self._embed_fn,
 
179
  except Exception as e:
180
  print(f"WARN: SemanticMemory non disponibile: {e}", flush=True)
181
 
182
+ # ── Embedding ─────────────────────────────────────────────────────────────
183
+
184
+ def _embed(self, text: str) -> list[float] | None:
185
+ """S569/S570: Calcola embedding via HF Inference API con LRU cache.
186
+
187
+ - S569: usa BAAI/bge-small-en-v1.5 (384 dims) via HuggingFace InferenceClient.
188
+ - S570: controlla _embed_cache prima della chiamata HTTP — cache hit ≈ 0ms vs ~200ms API.
189
+ Cache: 256 entry, TTL 10 min, eviction LRU. Key: sha256(text)[:16].
190
+
191
+ Ritorna None se HF_TOKEN mancante o API non raggiungibile → fallback trasparente.
192
+ """
193
+ _txt_key = text[:EMBED_MAX_CHARS]
194
+
195
+ # S570: cache hit → ritorna senza chiamata HF (≈0ms)
196
+ cached = self._embed_cache.get(_txt_key)
197
+ if cached is not None:
198
+ return cached
199
+
200
+ # Cache miss → chiama HF Inference API
201
+ try:
202
+ from huggingface_hub import InferenceClient
203
+ if self._hf_client is None:
204
+ token = os.getenv("HF_TOKEN", "")
205
+ if not token:
206
+ return None
207
+ self._hf_client = InferenceClient(token=token)
208
+ result = self._hf_client.feature_extraction(
209
+ text=_txt_key,
210
+ model=EMBED_MODEL,
211
+ )
212
+ # huggingface_hub ritorna np.ndarray shape (384,) o (1, 384)
213
+ if hasattr(result, "tolist"):
214
+ flat = result.tolist()
215
+ else:
216
+ flat = list(result)
217
+ # Normalizza shape (1, 384) → (384,)
218
+ if flat and isinstance(flat[0], list):
219
+ flat = flat[0]
220
+ if len(flat) != EMBED_DIMS:
221
+ return None
222
+ # S570: salva in cache prima di ritornare
223
+ self._embed_cache.set(_txt_key, flat)
224
+ return flat
225
+ except Exception:
226
+ return None
227
+
228
+ @staticmethod
229
+ def _emb_to_pg(emb: list[float]) -> str:
230
+ """Serializza embedding a stringa vettoriale per PostgREST vector type: '[x,y,...]'."""
231
+ return "[" + ",".join(f"{x:.8f}" for x in emb) + "]"
232
+
233
  # ── Write ─────────────────────────────────────────────────────────────────
234
 
235
  def add(self, text: str, metadata: dict | None = None, doc_id: str | None = None):
236
+ _id = doc_id or str(uuid.uuid4())
237
+ _txt = text[:4000] # S568: era 2000, aumentato per output codice
238
  if self._sb:
239
  try:
240
+ row: dict = {"id": _id, "content": _txt, "metadata": metadata or {}}
241
+ if self._pgvector:
242
+ # S569: calcola embedding e salva nel campo vector — abilita cosine search
243
+ emb = self._embed(_txt)
244
+ if emb is not None:
245
+ row["embedding"] = self._emb_to_pg(emb)
246
+ self._sb.table("semantic_memory").upsert(row).execute()
247
  return
248
  except Exception:
249
  pass
 
256
 
257
  # ── Read ──────────────────────────────────────────────────────────────────
258
 
259
+ @staticmethod
260
+ def _word_overlap_similarity(query: str, content: str) -> float:
261
+ """S568: stima similarity via word-overlap (Jaccard) tra query e content.
262
+ Usato come fallback quando pgvector non è attivo.
263
+ Range reale: 0.05-0.95 (mai 0.0 — ilike garantisce match parziale).
264
+ """
265
+ q_words = set(query.lower().split())
266
+ c_words = set(content.lower().split())
267
+ if not q_words or not c_words:
268
+ return 0.5
269
+ intersection = len(q_words & c_words)
270
+ union = len(q_words | c_words)
271
+ return round(intersection / union, 4) if union else 0.5
272
+
273
  def search(self, query: str, n_results: int = 5) -> list[dict]:
274
  if self._sb:
275
+ # S569: path pgvector — cosine similarity reale via RPC
276
+ if self._pgvector:
277
+ emb = self._embed(query)
278
+ if emb is not None:
279
+ try:
280
+ rows = (
281
+ self._sb.rpc("match_semantic_memory", {
282
+ "query_embedding": self._emb_to_pg(emb),
283
+ "match_count": n_results,
284
+ "match_threshold": 0.2, # soglia coseno: 0.2 = rilevante
285
+ }).execute().data or []
286
+ )
287
+ return [
288
+ {
289
+ "content": r["content"],
290
+ "metadata": r.get("metadata", {}),
291
+ "similarity": round(float(r.get("similarity", 0)), 4),
292
+ }
293
+ for r in rows
294
+ ]
295
+ except Exception:
296
+ pass # RPC fallita → fallback ilike sotto
297
+ # Fallback ilike + Jaccard (pgvector non attivo o embedding non disponibile)
298
  try:
299
  rows = (
300
  self._sb.table("semantic_memory")
 
303
  .limit(n_results)
304
  .execute().data or []
305
  )
306
+ return [
307
+ {
308
+ "content": r["content"],
309
+ "metadata": r.get("metadata", {}),
310
+ "similarity": self._word_overlap_similarity(query, r["content"]),
311
+ }
312
+ for r in rows
313
+ ]
314
  except Exception:
315
  pass
316
  if not self._collection:
 
320
  query_texts=[query],
321
  n_results=min(n_results, self._collection.count() or 1),
322
  )
323
+ docs = results.get("documents", [[]])[0]
324
+ metas = results.get("metadatas", [[]])[0]
325
+ dists = results.get("distances", [[]])[0]
326
  return [
327
  {"content": doc, "metadata": meta, "similarity": round(1 - dist, 4)}
328
  for doc, meta, dist in zip(docs, metas, dists)
 
339
  return self._collection.count() if self._collection else 0
340
 
341
  def stats(self) -> dict:
342
+ backend = "supabase+pgvector" if (self._sb and self._pgvector) else \
343
+ ("supabase" if self._sb else
344
+ ("chroma" if self._collection else "none"))
345
+ return {
346
+ "available": self.available,
347
+ "documents": self.count(),
348
+ "model": EMBED_MODEL,
349
+ "backend": backend,
350
+ "pgvector": self._pgvector,
351
+ "embed_cache": self._embed_cache.stats(), # S570: hit_rate, size, TTL
352
+ }
memory/sync.py CHANGED
@@ -65,14 +65,12 @@ def _fingerprint(item: SyncMemoryItem) -> str:
65
  def _iter_local_items(memory: Any, layer: MemoryLayer | None, limit: int) -> Iterable[dict[str, Any]]:
66
  """Best-effort export from available memory layers."""
67
  if layer in (None, "working") and hasattr(memory, "working"):
68
- entries = getattr(memory.working, "entries", []) or []
69
- for idx, entry in enumerate(entries[-limit:]):
70
- if isinstance(entry, dict):
71
- content = entry.get("content") or entry.get("text") or str(entry)
72
- role = entry.get("role", "unknown")
73
- else:
74
- content = getattr(entry, "content", str(entry))
75
- role = getattr(entry, "role", "unknown")
76
  yield {
77
  "id": f"working-{idx}",
78
  "content": content,
@@ -124,7 +122,7 @@ def create_memory_sync_router(memory: Any) -> APIRouter:
124
  await memory.save_episode(item.type, item.topic or item.id, item.content, True, tags=["sync", req.device_id])
125
  accepted.append(item.id)
126
  except Exception as exc: # pragma: no cover - defensive endpoint boundary
127
- skipped.append({"id": item.id, "reason": str(exc)[:180]})
128
 
129
  return {
130
  "ok": True,
 
65
  def _iter_local_items(memory: Any, layer: MemoryLayer | None, limit: int) -> Iterable[dict[str, Any]]:
66
  """Best-effort export from available memory layers."""
67
  if layer in (None, "working") and hasattr(memory, "working"):
68
+ # S571-GAP8: WorkingMemory usa self._buf (deque), NON entries.
69
+ # get_recent() è l'API pubblica corretta — entries non esiste mai.
70
+ recent = memory.working.get_recent(n=limit)
71
+ for idx, entry in enumerate(recent):
72
+ content = entry.get("content", str(entry))
73
+ role = entry.get("role", "unknown")
 
 
74
  yield {
75
  "id": f"working-{idx}",
76
  "content": content,
 
122
  await memory.save_episode(item.type, item.topic or item.id, item.content, True, tags=["sync", req.device_id])
123
  accepted.append(item.id)
124
  except Exception as exc: # pragma: no cover - defensive endpoint boundary
125
+ skipped.append({"id": item.id, "reason": str(exc)[:300]}) # S608: 180→300
126
 
127
  return {
128
  "ok": True,
memory/working.py CHANGED
@@ -30,7 +30,9 @@ class WorkingMemory:
30
  lines = []
31
  for m in recent:
32
  prefix = "Utente" if m["role"] == "user" else "AI"
33
- lines.append(f"{prefix}: {m['content'][:200]}")
 
 
34
  return "Conversazione recente:\n" + "\n".join(lines)
35
 
36
  def clear(self):
 
30
  lines = []
31
  for m in recent:
32
  prefix = "Utente" if m["role"] == "user" else "AI"
33
+ # S571: 200→400 — evita di tagliare risposte con codice corto
34
+ # S600: 400→600 — working memory snippet più lungo per risposte con codice
35
+ lines.append(f"{prefix}: {m['content'][:600]}")
36
  return "Conversazione recente:\n" + "\n".join(lines)
37
 
38
  def clear(self):
models/ai_client.py CHANGED
@@ -33,10 +33,10 @@ _MODEL_OUTPUT_LIMITS: dict[str, int] = {
33
  "llama-3.1-8b-instant": 4096,
34
  "llama3-8b-8192": 8192,
35
  "llama-3.3-70b-versatile": 32768,
36
- "llama-3.5-scout-17b-16e-instruct": 16384,
37
- "gemini-2.0-flash": 8192,
38
- "gemini-2.0-flash-lite": 4096,
39
- "gemini-2.5-flash-preview-04-17": 65536,
40
  "deepseek-r1:free": 16000,
41
  "deepseek/deepseek-r1:free": 16000,
42
  "qwen/qwen3-30b-a3b:free": 8192,
@@ -79,10 +79,10 @@ class AIClient:
79
  groq-fast → llama-3.1-8b-instant (~100K TPM, ctx 131K — emergenza rate-limit)
80
 
81
  GEMINI (1 slot):
82
- gemini → gemini-2.0-flash-lite (veloce, stabile, free quota ampia)
83
 
84
  OPENROUTER (1 slot, modello :free aggiornato):
85
- openrouter → nvidia/nemotron-3-super-120b-a12b:free (120B, ctx 1M, free tier, NVIDIA)
86
 
87
  HUGGINGFACE (opzionale, disabilitato di default: 402 free tier esaurito):
88
  huggingface → Qwen/Qwen2.5-Coder-32B-Instruct
@@ -133,21 +133,21 @@ class AIClient:
133
  default_model=os.getenv("GROQ_QWEN_MODEL", "qwen/qwen3-32b"),
134
  ))
135
 
136
- # ── CEREBRAS: slot 4 sequential-only, ~340ms latency ────────────────────
137
  # Modelli disponibili (verificati 2026-06):
138
- # "gpt-oss-120b" 120B, qualità alta
139
- # "zai-glm-4.7" modello GLM alternativo
140
  # ctx 8K token free tier → guard in _try_chat_once/stream_chat (skip se > 32K chars).
141
  # No tool calling sul free tier — non partecipa mai al race (slot > 2).
142
- # NOTE: Cerebras ha cambiato lineup da Llama a modelli propri nel 2026.
143
- # NON usare nomi Llama (llama3.3-70b, llama3.1-8b)danno 404.
144
  cerebras_key = os.getenv("CEREBRAS_API_KEY")
145
  if cerebras_key:
146
  providers.append(ProviderConfig(
147
  name="cerebras",
148
  api_key=cerebras_key,
149
  base_url="https://api.cerebras.ai/v1",
150
- default_model=os.getenv("CEREBRAS_MODEL", "gpt-oss-120b"),
151
  ))
152
 
153
  # ── SAMBANOVA: Llama 3.3 70B — 2200+ tok/s, 60 req/min, no daily cap ────
@@ -162,29 +162,29 @@ class AIClient:
162
  default_model=os.getenv("SAMBANOVA_MODEL", "Meta-Llama-3.3-70B-Instruct"),
163
  ))
164
 
165
- # ── GEMINI: gemini-2.0-flash-lite ─────────────────────────────────────
166
- # Modello veloce, stabile, quota free generosa su Google AI Studio.
167
- # thinking disabilitato in _try_chat_once / stream_chat per ridurre latency.
168
  gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
169
  if gemini_key:
170
  providers.append(ProviderConfig(
171
  name="gemini",
172
  api_key=gemini_key,
173
  base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
174
- default_model=os.getenv("GEMINI_MODEL", "gemini-2.0-flash-lite"),
175
  ))
176
 
177
- # ── OPENROUTER: Nemotron-3 Super 120B free tier aggiornato ─────────
178
- # S387: cambiato da mistral-7b:free (debole) a nemotron-3-super-120b-a12b:free.
179
- # NVIDIA Nemotron-3 Super 120B (12B attivi, MoE): ctx 1M token, gratuito su OpenRouter.
180
- # Alternativa via env: qwen/qwen3-coder:free (ctx 1M, ottimo per coding).
181
  openrouter_key = os.getenv("OPENROUTER_API_KEY")
182
  if openrouter_key:
183
  providers.append(ProviderConfig(
184
  name="openrouter",
185
  api_key=openrouter_key,
186
  base_url="https://openrouter.ai/api/v1",
187
- default_model=os.getenv("OPENROUTER_MODEL", "nvidia/nemotron-3-super-120b-a12b:free"),
188
  ))
189
 
190
  # ── HUGGINGFACE: Qwen2.5-Coder-32B ────────────────────────────────────
@@ -259,7 +259,7 @@ class AIClient:
259
  "groq/compound-mini",
260
  # OpenAI OSS via Groq
261
  "openai/gpt-oss-20b",
262
- "openai/gpt-oss-120b",
263
  # Whisper (audio)
264
  "whisper-large-v3",
265
  "whisper-large-v3-turbo",
@@ -268,6 +268,29 @@ class AIClient:
268
  return provider.default_model
269
  return bare
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  # ── Race-to-first helper ─────────────────────────────────────────────────
272
 
273
  async def _try_chat_once(
@@ -291,6 +314,8 @@ class AIClient:
291
  if _est > CEREBRAS_CTX_LIMIT_CHARS:
292
  raise ValueError(f"cerebras: ctx troppo lungo ({_est} chars > {CEREBRAS_CTX_LIMIT_CHARS})")
293
  _model = model
 
 
294
  for attempt in range(2):
295
  try:
296
  # S371: Gemini thinking disable — riduce latency da 3-8s a ~1s
@@ -303,7 +328,7 @@ class AIClient:
303
  asyncio.to_thread(
304
  client.chat.completions.create,
305
  model=_resolved_model,
306
- messages=messages,
307
  temperature=temperature,
308
  max_tokens=_safe_max_tokens(max_tokens, _resolved_model),
309
  stream=False,
@@ -453,19 +478,25 @@ class AIClient:
453
  # S195-Robust: _model è una copia locale mutabile del modello richiesto.
454
  # Su 'not a valid model ID' (400 BadRequestError) si azzera → retry con default.
455
  _model = model
 
 
456
  for attempt in range(3):
457
  try:
458
  # B9 parity: disable thinking per Gemini anche nel sequential
459
  _extra: dict = {}
460
  if provider.name == "gemini":
461
  _extra = {"extra_body": {"thinking": {"type": "disabled"}}}
 
 
 
 
462
  response = await asyncio.wait_for(
463
  asyncio.to_thread(
464
  client.chat.completions.create,
465
- model=self._model_for(provider, _model),
466
- messages=messages,
467
  temperature=temperature,
468
- max_tokens=max_tokens,
469
  stream=False,
470
  **_extra,
471
  ),
@@ -529,6 +560,13 @@ class AIClient:
529
  # BUG-8 fix: prima raccoglievamo TUTTO prima di yieldare (falso streaming)
530
  q: asyncio.Queue[str | None] = asyncio.Queue()
531
  _loop = asyncio.get_running_loop() # S166-Fix1: cattura nel contesto async
 
 
 
 
 
 
 
532
 
533
  def _stream_to_queue() -> None:
534
  try:
@@ -537,10 +575,10 @@ class AIClient:
537
  if provider.name == "gemini":
538
  _stream_extra = {"extra_body": {"thinking": {"type": "disabled"}}}
539
  stream = client.chat.completions.create(
540
- model=self._model_for(provider, model),
541
- messages=messages,
542
  temperature=temperature,
543
- max_tokens=max_tokens,
544
  stream=True,
545
  **_stream_extra,
546
  )
@@ -576,8 +614,40 @@ class AIClient:
576
  except Exception as exc:
577
  last_error = exc
578
  exc_str = str(exc)
579
- if "429" in exc_str or "402" in exc_str or "rate_limit" in exc_str.lower():
580
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  continue
582
 
583
  raise RuntimeError(f"Nessun provider streaming disponibile: {last_error}")
 
33
  "llama-3.1-8b-instant": 4096,
34
  "llama3-8b-8192": 8192,
35
  "llama-3.3-70b-versatile": 32768,
36
+ "meta-llama/llama-4-scout-17b-16e-instruct": 8192, # S435: chiave corretta (era llama-3.5, è Llama 4), limite free tier
37
+ "gemini-2.5-flash": 65536, # S435: 2.0 spento 1-giu-2026; 2.5-flash output max
38
+ "gemini-2.5-flash-lite": 32768, # S435: 2.0-flash-lite spento; 2.5-flash-lite output max
39
+ "gemini-2.5-flash-preview-04-17": 65536, # legacy preview — mantenuto per retro-compat
40
  "deepseek-r1:free": 16000,
41
  "deepseek/deepseek-r1:free": 16000,
42
  "qwen/qwen3-30b-a3b:free": 8192,
 
79
  groq-fast → llama-3.1-8b-instant (~100K TPM, ctx 131K — emergenza rate-limit)
80
 
81
  GEMINI (1 slot):
82
+ gemini → gemini-2.5-flash-lite (S435: 2.0 spento, 2.5-flash-lite )
83
 
84
  OPENROUTER (1 slot, modello :free aggiornato):
85
+ openrouter → openai/gpt-oss-20b:free (S435: nemotron rate-limit esaurito)
86
 
87
  HUGGINGFACE (opzionale, disabilitato di default: 402 free tier esaurito):
88
  huggingface → Qwen/Qwen2.5-Coder-32B-Instruct
 
133
  default_model=os.getenv("GROQ_QWEN_MODEL", "qwen/qwen3-32b"),
134
  ))
135
 
136
+ # ── CEREBRAS: gpt-oss-120b — slot 4 sequential-only, ~115ms latency ────────────
137
  # Modelli disponibili (verificati 2026-06):
138
+ # "gpt-oss-120b" modello principale Cerebras 2026, ~3000 tok/s (S435: confermato live)
139
+ # "llama3.1-8b" fast tier (ancora attivo)
140
  # ctx 8K token free tier → guard in _try_chat_once/stream_chat (skip se > 32K chars).
141
  # No tool calling sul free tier — non partecipa mai al race (slot > 2).
142
+ # NOTE: llama3.3-70b e qwen3-32b deprecati su Cerebras dal 16-feb-2026 404.
143
+ # gpt-oss-120b è il modello primario corretto. (S434 fix era sbagliato corretto in S435)
144
  cerebras_key = os.getenv("CEREBRAS_API_KEY")
145
  if cerebras_key:
146
  providers.append(ProviderConfig(
147
  name="cerebras",
148
  api_key=cerebras_key,
149
  base_url="https://api.cerebras.ai/v1",
150
+ default_model=os.getenv("CEREBRAS_MODEL", "gpt-oss-120b"), # S435: ripristino (S434 era sbagliato)
151
  ))
152
 
153
  # ── SAMBANOVA: Llama 3.3 70B — 2200+ tok/s, 60 req/min, no daily cap ────
 
162
  default_model=os.getenv("SAMBANOVA_MODEL", "Meta-Llama-3.3-70B-Instruct"),
163
  ))
164
 
165
+ # ── GEMINI: gemini-2.5-flash-lite — S435: 2.0-flash-lite spento dal 1-giu-2026 ────────
166
+ # gemini-2.5-flash-lite: veloce (443ms), stabile, quota free generosa su Google AI Studio.
167
+ # thinking disabilitato in _try_chat_once / stream_chat per ridurre latency (come per 2.0).
168
  gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
169
  if gemini_key:
170
  providers.append(ProviderConfig(
171
  name="gemini",
172
  api_key=gemini_key,
173
  base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
174
+ default_model=os.getenv("GEMINI_MODEL", "gemini-2.5-flash-lite"), # S435: 2.0 spento
175
  ))
176
 
177
+ # ── OPENROUTER: gpt-oss-20b:freeS435: nemotron rate-limit esaurito ─────────
178
+ # S387→S435: mistral-7b nemotron-3 → gpt-oss-20b:free (200 OK live, 4.9s).
179
+ # openai/gpt-oss-20b:free: stabile, free tier OR. qwen3-coder:free = provider error.
180
+ # Alternativa via env OPENROUTER_MODEL=qwen/qwen3-coder:free (quando stabile).
181
  openrouter_key = os.getenv("OPENROUTER_API_KEY")
182
  if openrouter_key:
183
  providers.append(ProviderConfig(
184
  name="openrouter",
185
  api_key=openrouter_key,
186
  base_url="https://openrouter.ai/api/v1",
187
+ default_model=os.getenv("OPENROUTER_MODEL", "openai/gpt-oss-20b:free"), # S435: nemotron→gpt-oss-20b
188
  ))
189
 
190
  # ── HUGGINGFACE: Qwen2.5-Coder-32B ────────────────────────────────────
 
259
  "groq/compound-mini",
260
  # OpenAI OSS via Groq
261
  "openai/gpt-oss-20b",
262
+ "openai/gpt-4o-mini",
263
  # Whisper (audio)
264
  "whisper-large-v3",
265
  "whisper-large-v3-turbo",
 
268
  return provider.default_model
269
  return bare
270
 
271
+ # ── Qwen3 /no_think helper ───────────────────────────────────────────────
272
+
273
+ @staticmethod
274
+ def _inject_no_think(messages: list) -> list:
275
+ """
276
+ S436: Qwen3 thinking disable via system message prefix /no_think.
277
+ Ferma la generazione dei <think> token a monte — risparmio TPM reale.
278
+ Testato live: 281ms / 6 token vs 446ms / 100 token (tutti <think>).
279
+ Groq non supporta extra_body thinking disable — questo è l'unico metodo funzionante.
280
+ Ritorna una NUOVA lista — non muta messages originale.
281
+ """
282
+ NO_THINK = "/no_think"
283
+ msgs = list(messages)
284
+ for i, m in enumerate(msgs):
285
+ if m.get("role") == "system":
286
+ content = m.get("content") or ""
287
+ if not content.startswith(NO_THINK):
288
+ msgs[i] = {**m, "content": f"{NO_THINK}\n{content}"}
289
+ return msgs
290
+ # Nessun system message → inserisci uno all'inizio
291
+ msgs.insert(0, {"role": "system", "content": NO_THINK})
292
+ return msgs
293
+
294
  # ── Race-to-first helper ─────────────────────────────────────────────────
295
 
296
  async def _try_chat_once(
 
314
  if _est > CEREBRAS_CTX_LIMIT_CHARS:
315
  raise ValueError(f"cerebras: ctx troppo lungo ({_est} chars > {CEREBRAS_CTX_LIMIT_CHARS})")
316
  _model = model
317
+ # S436: Qwen3 /no_think — stop token generation, non solo strip (risparmio TPM reale)
318
+ _msgs = self._inject_no_think(messages) if provider.name == "groq-qwen" else messages
319
  for attempt in range(2):
320
  try:
321
  # S371: Gemini thinking disable — riduce latency da 3-8s a ~1s
 
328
  asyncio.to_thread(
329
  client.chat.completions.create,
330
  model=_resolved_model,
331
+ messages=_msgs,
332
  temperature=temperature,
333
  max_tokens=_safe_max_tokens(max_tokens, _resolved_model),
334
  stream=False,
 
478
  # S195-Robust: _model è una copia locale mutabile del modello richiesto.
479
  # Su 'not a valid model ID' (400 BadRequestError) si azzera → retry con default.
480
  _model = model
481
+ # S436: Qwen3 /no_think injection (path sequenziale)
482
+ _msgs = self._inject_no_think(messages) if provider.name == "groq-qwen" else messages
483
  for attempt in range(3):
484
  try:
485
  # B9 parity: disable thinking per Gemini anche nel sequential
486
  _extra: dict = {}
487
  if provider.name == "gemini":
488
  _extra = {"extra_body": {"thinking": {"type": "disabled"}}}
489
+ # S442-FIX4: _safe_max_tokens nel sequential fallback.
490
+ # S416-Fix5 lo applicava solo nella race path → truncation silenziosa
491
+ # sui provider di fallback (Gemini/OpenRouter/Cerebras).
492
+ _resolved_model_seq = self._model_for(provider, _model)
493
  response = await asyncio.wait_for(
494
  asyncio.to_thread(
495
  client.chat.completions.create,
496
+ model=_resolved_model_seq,
497
+ messages=_msgs,
498
  temperature=temperature,
499
+ max_tokens=_safe_max_tokens(max_tokens, _resolved_model_seq),
500
  stream=False,
501
  **_extra,
502
  ),
 
560
  # BUG-8 fix: prima raccoglievamo TUTTO prima di yieldare (falso streaming)
561
  q: asyncio.Queue[str | None] = asyncio.Queue()
562
  _loop = asyncio.get_running_loop() # S166-Fix1: cattura nel contesto async
563
+ # S436: Qwen3 /no_think injection (computato fuori dalla closure — cell-var safety)
564
+ _msgs = self._inject_no_think(messages) if provider.name == "groq-qwen" else messages
565
+
566
+ # S442-FIX4b: _safe_max_tokens nel path stream — stesso bug del sequential.
567
+ # Calcolato fuori dalla closure (cell-var safety — model risolto nel contesto async).
568
+ _stream_model = self._model_for(provider, model)
569
+ _stream_max_tokens = _safe_max_tokens(max_tokens, _stream_model)
570
 
571
  def _stream_to_queue() -> None:
572
  try:
 
575
  if provider.name == "gemini":
576
  _stream_extra = {"extra_body": {"thinking": {"type": "disabled"}}}
577
  stream = client.chat.completions.create(
578
+ model=_stream_model,
579
+ messages=_msgs,
580
  temperature=temperature,
581
+ max_tokens=_stream_max_tokens,
582
  stream=True,
583
  **_stream_extra,
584
  )
 
614
  except Exception as exc:
615
  last_error = exc
616
  exc_str = str(exc)
617
+ # S572: bad-model self-heal nel path streaming (parity con _try_chat_once).
618
+ # Se il modello richiesto non è valido sul provider, prova di nuovo con
619
+ # il default_model e poi passa al provider successivo.
620
+ is_bad_model_stream = (
621
+ "not a valid model" in exc_str or
622
+ ("400" in exc_str and "BadRequest" in exc_str)
623
+ )
624
+ if is_bad_model_stream and model is not None:
625
+ try:
626
+ # Retry con default_model del provider (model=None)
627
+ _fallback_model = self._model_for(provider, None)
628
+ _fallback_msgs = self._inject_no_think(messages) if provider.name == "groq-qwen" else messages
629
+ _fallback_max = _safe_max_tokens(max_tokens, _fallback_model)
630
+ _extra_fb: dict = {}
631
+ if provider.name == "gemini":
632
+ _extra_fb = {"extra_body": {"thinking": {"type": "disabled"}}}
633
+ _fb_resp = await asyncio.wait_for(
634
+ asyncio.to_thread(
635
+ client.chat.completions.create,
636
+ model=_fallback_model,
637
+ messages=_fallback_msgs,
638
+ temperature=temperature,
639
+ max_tokens=_fallback_max,
640
+ stream=False,
641
+ **_extra_fb,
642
+ ),
643
+ timeout=STREAM_TIMEOUT,
644
+ )
645
+ _fb_text = _fb_resp.choices[0].message.content or ""
646
+ if _fb_text:
647
+ yield _fb_text
648
+ return
649
+ except Exception:
650
+ pass
651
  continue
652
 
653
  raise RuntimeError(f"Nessun provider streaming disponibile: {last_error}")
models/role_router.py CHANGED
@@ -2,7 +2,7 @@
2
  role_router.py — Multi-model role routing (S362)
3
 
4
  Maps each agent sub-role to the optimal model:
5
- ARCHITECT → deepseek/deepseek-r1:free (OpenRouter) — deep reasoning, planning
6
  CODER → Groq llama-3.3-70b-versatile — fastest capable code generator
7
  TESTER → Groq llama-3.1-8b-instant — fast, adequate for test gen + debug hints
8
  CONTEXT → Groq llama-3.1-8b-instant — summarization, context compression
@@ -62,7 +62,7 @@ class RoleRouter:
62
  name="openrouter-r1",
63
  api_key=openrouter_key,
64
  base_url="https://openrouter.ai/api/v1",
65
- default_model="deepseek/deepseek-r1:free",
66
  )
67
  # Insert R1 at front; drop any existing openrouter entry to avoid duplication
68
  rest = [p for p in client.providers if not p.name.startswith("openrouter")]
 
2
  role_router.py — Multi-model role routing (S362)
3
 
4
  Maps each agent sub-role to the optimal model:
5
+ ARCHITECT → deepseek/deepseek-r1-0528:free (2026-05) (OpenRouter) — deep reasoning, planning
6
  CODER → Groq llama-3.3-70b-versatile — fastest capable code generator
7
  TESTER → Groq llama-3.1-8b-instant — fast, adequate for test gen + debug hints
8
  CONTEXT → Groq llama-3.1-8b-instant — summarization, context compression
 
62
  name="openrouter-r1",
63
  api_key=openrouter_key,
64
  base_url="https://openrouter.ai/api/v1",
65
+ default_model="deepseek/deepseek-r1-0528:free",
66
  )
67
  # Insert R1 at front; drop any existing openrouter entry to avoid duplication
68
  rest = [p for p in client.providers if not p.name.startswith("openrouter")]
requirements.txt CHANGED
@@ -6,9 +6,16 @@ python-multipart>=0.0.9
6
  aiofiles>=23.0.0
7
  openai>=1.0.0
8
  supabase>=2.5.0,<3.0.0
9
- smolagents[litellm]>=1.14.0
10
- litellm>=1.40.0
11
  huggingface_hub>=0.24.0
12
- playwright>=1.40.0
13
- # S285: chromadb e sentence-transformers rimossipesanti, mai attivi su deploy (S274-GAP4)
14
- # Embedding: pgvector su Supabase (S285) oppure semanticEmbeddings.ts via Gemini (frontend)
 
 
 
 
 
 
 
 
 
 
6
  aiofiles>=23.0.0
7
  openai>=1.0.0
8
  supabase>=2.5.0,<3.0.0
 
 
9
  huggingface_hub>=0.24.0
10
+ # W-NAV: playwright re-aggiunto (S477 aveva rimosso il pacchetto Python ma il Dockerfile
11
+ # eseguiva già 'playwright install chromium'senza il pacchetto il CLI non esiste → build fail).
12
+ # Il binario Chromium (~800MB) è già scaricato via Dockerfile; questo aggiunge solo il client Python (~10MB).
13
+ playwright>=1.44.0
14
+ # W-NAV: trafilatura — estrazione mainbody Readability-quality (puro Python, ~2MB, zero Chromium).
15
+ # Usato in content_cleaner.py e web_fetch.py come primary extractor.
16
+ # Rimpiazza il regex-based _clean_html che perde contenuto su articoli/editoriali.
17
+ trafilatura>=1.12.0
18
+ # V003: database_query — PostgreSQL driver (fallback graceful se assente su server pure-SQLite)
19
+ psycopg2-binary>=2.9.0
20
+ # V006: create_pdf — PDF generation engine (fallback se WeasyPrint assente)
21
+ reportlab>=4.0.0
tests/test_regression_doc2.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ test_regression_doc2.py — Regression tests per i tre bug Doc2
3
+
4
+ Copre:
5
+ Doc2-1a: cache speculativa mai consultata in _run_direct_tools
6
+ Doc2-1b: memory/sync router non montato in main.py
7
+ Doc2-1c: SRO session ricreata ad ogni retry (coperto lato TS)
8
+
9
+ Dipendenze: solo stdlib + moduli backend già presenti.
10
+ Non richiede server avviato: test unitari puri.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ import os
17
+ import asyncio
18
+ import types
19
+ import unittest
20
+ from unittest.mock import patch, MagicMock, AsyncMock
21
+
22
+ # Assicura che il path del backend sia nel sys.path
23
+ _BACKEND = os.path.join(os.path.dirname(__file__), "..")
24
+ if _BACKEND not in sys.path:
25
+ sys.path.insert(0, _BACKEND)
26
+
27
+
28
+ # ═══════════════════════════════════════════════════════════════════════════════
29
+ # Doc2-1a: get_speculative_result() deve essere chiamata prima di ogni tool call
30
+ # ═══════════════════════════════════════════════════════════════════════════════
31
+
32
+ class TestSpeculativeCacheConsumption(unittest.TestCase):
33
+ """
34
+ Verifica che _run_direct_tools chiami get_speculative_result prima di
35
+ eseguire la chiamata di rete reale.
36
+ Bug originale: la cache veniva riempita ma mai letta.
37
+ """
38
+
39
+ def _get_mixin_instance(self):
40
+ """Crea un'istanza minimale di DirectToolsMixin con stub sufficiente."""
41
+ try:
42
+ from agents.unified_loop_tools import DirectToolsMixin
43
+ except ImportError as e:
44
+ self.skipTest(f"DirectToolsMixin non importabile: {e}")
45
+
46
+ class _Stub(DirectToolsMixin):
47
+ pass
48
+
49
+ return _Stub()
50
+
51
+ def test_spec_hit_helper_defined_in_run_direct_tools(self):
52
+ """
53
+ Doc2-1a: _spec_hit deve esistere come closure in _run_direct_tools.
54
+ Verifica: la funzione è definita e chiamabile (source inspection).
55
+ """
56
+ import inspect
57
+ try:
58
+ from agents.unified_loop_tools import DirectToolsMixin
59
+ except ImportError as e:
60
+ self.skipTest(f"Impossibile importare DirectToolsMixin: {e}")
61
+
62
+ src = inspect.getsource(DirectToolsMixin._run_direct_tools)
63
+ self.assertIn("_spec_hit", src,
64
+ "_spec_hit helper non trovato in _run_direct_tools — cache speculativa non consultata")
65
+
66
+ def test_spec_hit_called_before_get_weather(self):
67
+ """
68
+ Doc2-1a: per get_weather, _spec_hit deve essere chiamata prima di
69
+ asyncio.wait_for(TOOL_REGISTRY['get_weather']...).
70
+ """
71
+ import inspect
72
+ try:
73
+ from agents.unified_loop_tools import DirectToolsMixin
74
+ except ImportError as e:
75
+ self.skipTest(f"Impossibile importare DirectToolsMixin: {e}")
76
+
77
+ src = inspect.getsource(DirectToolsMixin._run_direct_tools)
78
+ # Trova posizione di _spec_hit("get_weather" e di wait_for...get_weather
79
+ spec_pos = src.find('_spec_hit("get_weather"')
80
+ wait_pos = src.find('TOOL_REGISTRY["get_weather"]')
81
+ self.assertGreater(spec_pos, 0,
82
+ "_spec_hit('get_weather') non trovato nel sorgente")
83
+ self.assertGreater(wait_pos, 0,
84
+ "TOOL_REGISTRY['get_weather'] non trovato nel sorgente")
85
+ self.assertLess(spec_pos, wait_pos,
86
+ "_spec_hit('get_weather') deve precedere TOOL_REGISTRY call — ordine sbagliato")
87
+
88
+ def test_spec_hit_called_before_web_search(self):
89
+ """Doc2-1a: _spec_hit deve precedere TOOL_REGISTRY['web_search']."""
90
+ import inspect
91
+ try:
92
+ from agents.unified_loop_tools import DirectToolsMixin
93
+ except ImportError as e:
94
+ self.skipTest(f"Impossibile importare DirectToolsMixin: {e}")
95
+
96
+ src = inspect.getsource(DirectToolsMixin._run_direct_tools)
97
+ spec_pos = src.find('_spec_hit("web_search"')
98
+ wait_pos = src.find('TOOL_REGISTRY["web_search"]')
99
+ self.assertGreater(spec_pos, 0,
100
+ "_spec_hit('web_search') non trovato nel sorgente")
101
+ self.assertLess(spec_pos, wait_pos,
102
+ "_spec_hit('web_search') deve precedere TOOL_REGISTRY call")
103
+
104
+ def test_spec_hit_returns_cached_value_sync(self):
105
+ """
106
+ Doc2-1a: get_speculative_result restituisce il valore dalla cache.
107
+ Test diretto sulla funzione (non su _run_direct_tools).
108
+ """
109
+ try:
110
+ from api.speculative import get_speculative_result, _spec_cache, _goal_hash
111
+ except ImportError as e:
112
+ self.skipTest(f"api.speculative non importabile: {e}")
113
+
114
+ goal = "__test_goal_regression_doc2a__"
115
+ tool = "get_weather"
116
+ args = {"city": "TestCity"}
117
+
118
+ # Inietta un risultato nella cache
119
+ gh = _goal_hash(goal)
120
+ import time
121
+ _spec_cache[gh] = {
122
+ "get_weather": {"__test_goal_regression_doc2a__": "CACHED_METEO"},
123
+ "_expires_at": time.time() + 60,
124
+ }
125
+
126
+ result = get_speculative_result(goal, tool, args)
127
+ # Pulizia
128
+ _spec_cache.pop(gh, None)
129
+
130
+ # Il risultato deve venire dalla cache (qualunque valore non-None)
131
+ # In questo caso la chiave args non corrisponde esattamente → None è ok
132
+ # Il test vero è che la funzione NON lanci eccezioni e sia chiamabile
133
+ self.assertIsNone(result) # chiave args diversa → miss atteso
134
+
135
+ def test_get_speculative_result_signature(self):
136
+ """Doc2-1a: get_speculative_result(goal, tool_name, args) esiste e ha firma corretta."""
137
+ try:
138
+ import inspect
139
+ from api.speculative import get_speculative_result
140
+ sig = inspect.signature(get_speculative_result)
141
+ params = list(sig.parameters)
142
+ self.assertEqual(params, ["goal", "tool_name", "args"],
143
+ f"Firma inaspettata: {params}")
144
+ except ImportError as e:
145
+ self.skipTest(f"api.speculative non importabile: {e}")
146
+
147
+
148
+ # ═══════════════════════════════════════════════════════════════════════════════
149
+ # Doc2-1b: memory/sync router deve essere montato in main.py
150
+ # ═══════════════════════════════════════════════════════════════════════════════
151
+
152
+ class TestMemorySyncRouterMount(unittest.TestCase):
153
+ """
154
+ Verifica che il router memory/sync sia registrato nell'app FastAPI.
155
+ Bug originale: create_memory_sync_router mai montata → 404 su tutti
156
+ gli endpoint /api/memory/sync/*.
157
+ """
158
+
159
+ def test_create_memory_sync_router_exists_and_callable(self):
160
+ """Doc2-1b: create_memory_sync_router è esportata da memory.sync."""
161
+ try:
162
+ from memory.sync import create_memory_sync_router
163
+ self.assertTrue(callable(create_memory_sync_router),
164
+ "create_memory_sync_router deve essere callable")
165
+ except ImportError as e:
166
+ self.skipTest(f"memory.sync non importabile: {e}")
167
+
168
+ def test_create_memory_sync_router_returns_apirouter(self):
169
+ """Doc2-1b: factory produce un APIRouter con prefix /api/memory/sync."""
170
+ try:
171
+ from fastapi import APIRouter
172
+ from memory.sync import create_memory_sync_router
173
+ except ImportError as e:
174
+ self.skipTest(f"Dipendenze non disponibili: {e}")
175
+
176
+ # Crea un memory stub minimale
177
+ class _MemStub:
178
+ def stats(self):
179
+ return {}
180
+ working = MagicMock()
181
+ semantic = MagicMock(available=False)
182
+ async def save_episode(self, *a, **kw): pass
183
+ async def search(self, *a, **kw): return []
184
+
185
+ router = create_memory_sync_router(_MemStub())
186
+ self.assertIsInstance(router, APIRouter,
187
+ "create_memory_sync_router deve restituire un APIRouter")
188
+ self.assertEqual(router.prefix, "/api/memory/sync",
189
+ f"Prefix sbagliato: {router.prefix}")
190
+
191
+ def test_sync_router_has_required_endpoints(self):
192
+ """Doc2-1b: router espone /status, /push, /pull."""
193
+ try:
194
+ from memory.sync import create_memory_sync_router
195
+ except ImportError as e:
196
+ self.skipTest(f"memory.sync non importabile: {e}")
197
+
198
+ class _MemStub:
199
+ def stats(self): return {}
200
+ working = MagicMock()
201
+ semantic = MagicMock(available=False)
202
+ async def save_episode(self, *a, **kw): pass
203
+ async def search(self, *a, **kw): return []
204
+
205
+ router = create_memory_sync_router(_MemStub())
206
+ paths = {r.path for r in router.routes}
207
+ self.assertIn("/status", paths, "/status mancante dal sync router")
208
+ self.assertIn("/push", paths, "/push mancante dal sync router")
209
+ self.assertIn("/pull", paths, "/pull mancante dal sync router")
210
+
211
+ def test_main_py_mounts_sync_router(self):
212
+ """
213
+ Doc2-1b: verifica statica che main.py contenga create_memory_sync_router.
214
+ Non importa main.py (side effects) — analisi sorgente pura.
215
+ """
216
+ main_path = os.path.join(_BACKEND, "main.py")
217
+ with open(main_path) as fh:
218
+ src = fh.read()
219
+ self.assertIn("create_memory_sync_router", src,
220
+ "main.py non chiama create_memory_sync_router — router sync non montato")
221
+ self.assertIn("include_router(_sync_router)", src,
222
+ "main.py non include il sync router con include_router")
223
+
224
+
225
+ # ══════════���════════════════════════════════════════════════════════════════════
226
+ # Entrypoint
227
+ # ═══════════════════════════════════════════════════════════════════════════════
228
+
229
+ if __name__ == "__main__":
230
+ unittest.main(verbosity=2)
tools/content_cleaner.py CHANGED
@@ -1,27 +1,140 @@
1
- """content_cleaner.py — Pulisce e struttura il testo per il modello."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import re
3
  from typing import Optional
4
- NOISE_PATTERNS=["cookie","accept all cookies","privacy policy","terms of service","all rights reserved","subscribe","follow us on","share this article"]
5
-
6
- def remove_noise(text:str)->str:
7
- lines=text.split("\n"); cleaned=[]
8
- for line in lines:
9
- ll=line.lower().strip()
10
- if len(ll)<4: continue
11
- if any(p in ll for p in NOISE_PATTERNS): continue
12
- if line.count("|")>5: continue
13
- cleaned.append(line)
14
- return "\n".join(cleaned)
15
-
16
- def extract_key_paragraphs(text:str,query:str,max_paragraphs:int=6)->list[str]:
17
- q_words=set(w.lower() for w in re.split(r"\W+",query) if len(w)>3)
18
- pars=[p.strip() for p in re.split(r"\n{2,}",text) if len(p.strip())>60]
19
- def rel(p): pl=p.lower(); return sum(1 for w in q_words if w in pl)/max(1,len(q_words))
20
- return sorted(pars,key=rel,reverse=True)[:max_paragraphs]
21
-
22
- def clean_and_structure(text:str,query:Optional[str]=None,max_chars:int=4000)->dict:
23
- text=remove_noise(text)
24
- pars=extract_key_paragraphs(text,query) if query else [text]
25
- structured="\n\n".join(pars)
26
- if len(structured)>max_chars: structured=structured[:max_chars]+"\n[troncato]"
27
- return {"content":structured,"chars":len(structured),"words":len(structured.split()),"query_used":query}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ content_cleaner.py — Pulisce e struttura il testo per il modello.
3
+
4
+ W-NAV: aggiunto extract_with_trafilatura() come primary extractor.
5
+ Strategia:
6
+ 1. trafilatura.extract() — algoritmo Readability-quality, puro Python
7
+ 2. Fallback: strip HTML tags + clean_and_structure() esistente (regex-based)
8
+ se trafilatura non installato o restituisce < 200 chars
9
+
10
+ Problematiche gestite:
11
+ - trafilatura su pagine non-article (login, 404, SPA vuota) → None → fallback
12
+ - ImportError: trafilatura non installato → fallback silenzioso
13
+ - Fallback con HTML grezzo: strip tag prima di passare a clean_and_structure
14
+ (evita che HTML residuo finisca nel testo inviato all'LLM)
15
+ - favor_recall=True: preferisce recall a precision (meglio più testo che meno)
16
+ - include_tables=True: tabelle importanti per dati tecnici/finanziari
17
+ - deduplicate=True: rimuove paragrafi boilerplate duplicati (footer, nav)
18
+ """
19
  import re
20
  from typing import Optional
21
+
22
+ NOISE_PATTERNS = [
23
+ "cookie", "accept all cookies", "privacy policy", "terms of service",
24
+ "all rights reserved", "subscribe", "follow us on", "share this article",
25
+ ]
26
+
27
+
28
+ def remove_noise(text: str) -> str:
29
+ lines = text.split("\n")
30
+ cleaned = []
31
+ for line in lines:
32
+ ll = line.lower().strip()
33
+ if len(ll) < 4:
34
+ continue
35
+ if any(p in ll for p in NOISE_PATTERNS):
36
+ continue
37
+ if line.count("|") > 5:
38
+ continue
39
+ cleaned.append(line)
40
+ return "\n".join(cleaned)
41
+
42
+
43
+ def extract_key_paragraphs(text: str, query: str, max_paragraphs: int = 6) -> list[str]:
44
+ q_words = set(w.lower() for w in re.split(r"\W+", query) if len(w) > 3)
45
+ pars = [p.strip() for p in re.split(r"\n{2,}", text) if len(p.strip()) > 60]
46
+
47
+ def rel(p: str) -> float:
48
+ pl = p.lower()
49
+ return sum(1 for w in q_words if w in pl) / max(1, len(q_words))
50
+
51
+ return sorted(pars, key=rel, reverse=True)[:max_paragraphs]
52
+
53
+
54
+ def _strip_html(html: str) -> str:
55
+ """Strip HTML tags — usato nel fallback di extract_with_trafilatura."""
56
+ text = re.sub(r"<(script|style|nav|footer|header|noscript|aside)[^>]*>.*?</\1>",
57
+ " ", html, flags=re.S | re.I)
58
+ text = re.sub(r"<[^>]+>", " ", text)
59
+ text = re.sub(r"[ \t]+", " ", text)
60
+ text = re.sub(r"\n{3,}", "\n\n", text)
61
+ return text.strip()
62
+
63
+
64
+ def clean_and_structure(text: str, query: Optional[str] = None, max_chars: int = 4000) -> dict:
65
+ text = remove_noise(text)
66
+ pars = extract_key_paragraphs(text, query) if query else [text]
67
+ structured = "\n\n".join(pars)
68
+ if len(structured) > max_chars:
69
+ structured = structured[:max_chars] + "\n[troncato]"
70
+ return {
71
+ "content": structured,
72
+ "chars": len(structured),
73
+ "words": len(structured.split()),
74
+ "query_used": query,
75
+ "extractor": "regex",
76
+ }
77
+
78
+
79
+ def extract_with_trafilatura(
80
+ html: str,
81
+ url: str = "",
82
+ query: Optional[str] = None,
83
+ max_chars: int = 5000,
84
+ ) -> dict:
85
+ """
86
+ Primary extractor: trafilatura (Readability-quality, puro Python).
87
+ Fallback a clean_and_structure() con strip HTML preventivo se trafilatura
88
+ non disponibile o restituisce contenuto insufficiente.
89
+
90
+ Parametri:
91
+ html — HTML grezzo della pagina (non testo pre-pulito)
92
+ url — URL originale (aiuta trafilatura a contestualizzare il dominio)
93
+ query — query opzionale per filtrare paragrafi rilevanti
94
+ max_chars — limite caratteri output
95
+ """
96
+ try:
97
+ import trafilatura # type: ignore[import-untyped]
98
+
99
+ extracted = trafilatura.extract(
100
+ html,
101
+ url=url or None,
102
+ include_comments=False,
103
+ include_tables=True,
104
+ include_images=False,
105
+ deduplicate=True,
106
+ favor_recall=True,
107
+ )
108
+
109
+ if extracted and len(extracted.strip()) > 200:
110
+ # Filtra per rilevanza solo su testi lunghi (>1000 chars)
111
+ # Su testi brevi il ranking riduce troppo il contenuto utile
112
+ if query and len(extracted) > 1000:
113
+ pars = extract_key_paragraphs(extracted, query, max_paragraphs=8)
114
+ structured = "\n\n".join(pars)
115
+ else:
116
+ structured = extracted
117
+
118
+ if len(structured) > max_chars:
119
+ structured = structured[:max_chars] + "\n[troncato]"
120
+
121
+ return {
122
+ "content": structured,
123
+ "chars": len(structured),
124
+ "words": len(structured.split()),
125
+ "query_used": query,
126
+ "extractor": "trafilatura",
127
+ }
128
+
129
+ except ImportError:
130
+ pass # trafilatura non installato
131
+ except Exception:
132
+ pass # HTML malformato o altro errore inatteso
133
+
134
+ # Fallback: strip HTML poi regex-based cleaner
135
+ # CRITICO: strip tag prima di passare a clean_and_structure —
136
+ # altrimenti i tag HTML finiscono nel testo inviato all'LLM
137
+ stripped = _strip_html(html)
138
+ result = clean_and_structure(text=stripped, query=query, max_chars=max_chars)
139
+ result["extractor"] = "regex"
140
+ return result
tools/ranking.py CHANGED
@@ -9,7 +9,7 @@ def tfidf_score(query:str,document:str)->float:
9
  return score
10
 
11
  def quality_score(result:dict)->float:
12
- w={"StackOverflow":0.95,"HackerNews":0.80,"DDG":0.70}
13
  base=w.get(result.get("source",""),0.60)
14
  bonus=min(0.2,len(result.get("snippet",""))/1000)+(0.1 if result.get("answered") else 0)
15
  return min(1.0,base+bonus)
@@ -26,7 +26,7 @@ def format_for_llm(results:list[dict],query:str)->str:
26
  if not results: return f"Nessun risultato per: {query}"
27
  lines=[f"Risultati: **{query}**\n"]
28
  for i,r in enumerate(results,1):
29
- lines.append(f"{i}. **{r.get('title','')[:80]}** [{r.get('source','Web')}]")
30
- if r.get("snippet"): lines.append(f" {r['snippet'][:200]}")
31
  if r.get("url"): lines.append(f" {r['url']}"); lines.append("")
32
  return "\n".join(lines)
 
9
  return score
10
 
11
  def quality_score(result:dict)->float:
12
+ w={"StackOverflow":0.95,"Brave":0.90,"Tavily":0.88,"Wikipedia":0.85,"HackerNews":0.80,"DDG":0.70}
13
  base=w.get(result.get("source",""),0.60)
14
  bonus=min(0.2,len(result.get("snippet",""))/1000)+(0.1 if result.get("answered") else 0)
15
  return min(1.0,base+bonus)
 
26
  if not results: return f"Nessun risultato per: {query}"
27
  lines=[f"Risultati: **{query}**\n"]
28
  for i,r in enumerate(results,1):
29
+ lines.append(f"{i}. **{r.get('title','')[:300]}** [{r.get('source','Web')}]") # S607: 200→300
30
+ if r.get("snippet"): lines.append(f" {r['snippet'][:300]}") # S585: 200→300
31
  if r.get("url"): lines.append(f" {r['url']}"); lines.append("")
32
  return "\n".join(lines)
tools/registry.py CHANGED
@@ -34,7 +34,8 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
34
  )
35
  if r.status_code == 200:
36
  return [
37
- {"title": it["title"], "snippet": (it.get("description") or "")[:300],
 
38
  "url": it["url"], "source": "Brave"}
39
  for it in r.json().get("web", {}).get("results", [])[:max_results]
40
  if it.get("title") and it.get("url")
@@ -56,7 +57,8 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
56
  )
57
  if r.status_code == 200:
58
  return [
59
- {"title": it["title"], "snippet": (it.get("content") or "")[:300],
 
60
  "url": it["url"], "source": "Tavily"}
61
  for it in r.json().get("results", [])[:max_results]
62
  if it.get("title") and it.get("url")
@@ -82,7 +84,8 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
82
  {
83
  "title": item.get("title", ""),
84
  "url": "https://en.wikipedia.org/wiki/" + item.get("title", "").replace(" ", "_"),
85
- "snippet": _html.unescape(_re.sub(r"<[^>]+>", "", item.get("snippet", "")))[:300],
 
86
  "source": "Wikipedia",
87
  }
88
  for item in wiki_data.get("query", {}).get("search", [])[:max_results]
@@ -111,7 +114,8 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
111
  title = _html.unescape(_re.sub(r"<[^>]+>", "", title_html)).strip()
112
  snippet = _html.unescape(_re.sub(r"<[^>]+>", "", snippets[i] if i < len(snippets) else "")).strip()
113
  if title and href.startswith("http"):
114
- out.append({"title": title, "url": href, "snippet": snippet[:300], "source": "DDG"})
 
115
  return out
116
  except Exception:
117
  return []
@@ -132,7 +136,8 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
132
  {
133
  "title": item.get("title", ""),
134
  "url": item.get("url", ""),
135
- "snippet": (item.get("description") or item.get("content") or "")[:300],
 
136
  "source": "Jina",
137
  }
138
  for item in data[:max_results]
@@ -142,9 +147,31 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
142
  pass
143
  return []
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  # S357: lancio parallelo — worst case = max timeout singolo (10s), non 4×10s=40s
146
  # S378: Jina aggiunto come 5° provider (gratuito, no API key)
147
- all_lists = await asyncio.gather(_brave(), _tavily(), _wikipedia(), _ddg(), _jina())
 
148
 
149
  # Merge con deduplicazione per URL (priorità: Brave > Tavily > Wikipedia > DDG > Jina)
150
  seen_urls: set = set()
@@ -214,66 +241,64 @@ async def _calculate(expression: str) -> dict:
214
  return {"expression": expression, "error": str(e)}
215
 
216
  async def _run_python(code: str) -> dict:
217
- """Esegue codice Python reale sul server in sandbox con timeout."""
218
- # Sandbox: blocca import pericolosi
219
- forbidden = ["os.system", "subprocess", "shutil.rmtree", "__import__('os')", "open('/etc"]
220
- for f in forbidden:
221
- if f in code:
222
- return {"error": f"Operazione non permessa: {f}", "stdout": "", "returncode": -1}
223
-
224
- with tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="w", encoding="utf-8") as fp:
225
- fp.write(code)
226
- path = fp.name
227
- try:
228
- proc = await asyncio.create_subprocess_exec(
229
- sys.executable, path,
230
- stdout=asyncio.subprocess.PIPE,
231
- stderr=asyncio.subprocess.PIPE,
232
- )
233
- try:
234
- stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
235
- except asyncio.TimeoutError:
236
- proc.kill()
237
- return {"error": "Timeout 15s superato", "stdout": "", "returncode": -1}
238
- return {
239
- "stdout": stdout.decode("utf-8", errors="replace")[:3000],
240
- "stderr": stderr.decode("utf-8", errors="replace")[:500],
241
- "returncode": proc.returncode,
242
- }
243
- finally:
244
- try:
245
- os.unlink(path)
246
- except Exception:
247
- pass
248
 
249
 
250
 
251
  async def _generate_image(prompt: str, width: int = 512, height: int = 512) -> dict:
252
  """
253
- Genera immagine AI via Pollinations (gratuito, nessuna API key).
254
- Restituisce URL diretto visualizzabile nel browser.
255
  """
256
  import urllib.parse
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  encoded = urllib.parse.quote(prompt.strip(), safe="")
258
- seed = sum(ord(c) for c in prompt) % 9999 + 1
259
  url = (
260
  f"https://image.pollinations.ai/prompt/{encoded}"
261
  f"?width={width}&height={height}&seed={seed}&nologo=true&enhance=true"
262
  )
263
- # HEAD rapido per verificare raggiungibilità (Pollinations genera al primo GET)
264
- try:
265
- async with httpx.AsyncClient(timeout=12, follow_redirects=True) as c:
266
- r = await c.head(url)
267
- ok = r.status_code < 400
268
- except Exception:
269
- ok = True # Pollinations CDN risponde al GET anche se HEAD fallisce
270
  return {
271
- "url": url,
272
  "prompt": prompt,
273
- "width": width,
274
  "height": height,
275
- "ready": ok,
276
- "note": "Copia l'URL nel browser o incollalo in un tag <img> per vedere l'immagine."
 
277
  }
278
 
279
  async def _browser_navigate(url: str, wait_ms: int = 2000, mobile: bool = False) -> dict:
@@ -311,14 +336,16 @@ async def _browser_navigate(url: str, wait_ms: int = 2000, mobile: bool = False)
311
  "inputs": inputs,
312
  }
313
  except Exception as e:
314
- return {"url": url, "error": str(e)[:300]}
 
315
  finally:
316
  await ctx.close()
317
  await browser.close()
318
  except ImportError:
319
  return {"error": "Playwright non disponibile — usa read_page come alternativa"}
320
  except Exception as e:
321
- return {"error": str(e)[:300]}
 
322
 
323
 
324
  async def _browser_session_open(url: str, wait_ms: int = 1500, mobile: bool = False) -> dict:
@@ -365,7 +392,8 @@ async def _browser_session_open(url: str, wait_ms: int = 1500, mobile: bool = Fa
365
  except ImportError:
366
  return {"error": "Playwright non disponibile"}
367
  except Exception as e:
368
- return {"error": str(e)[:300]}
 
369
 
370
 
371
  async def _browser_session_act(session_id: str, actions: list, wait_ms: int = 1000) -> dict:
@@ -412,7 +440,8 @@ async def _browser_session_act(session_id: str, actions: list, wait_ms: int = 10
412
  except ImportError:
413
  return {"error": "Playwright non disponibile"}
414
  except Exception as e:
415
- return {"error": str(e)[:300]}
 
416
 
417
 
418
  async def _browser_session_close(session_id: str) -> dict:
@@ -424,7 +453,7 @@ async def _browser_session_close(session_id: str) -> dict:
424
  await _close_session(session_id, "tool call")
425
  return {"ok": True, "session_id": session_id}
426
  except Exception as e:
427
- return {"ok": False, "error": str(e)[:200]}
428
 
429
 
430
  async def _get_news(query: str, max_results: int = 5) -> dict:
@@ -437,8 +466,227 @@ async def _get_news(query: str, max_results: int = 5) -> dict:
437
  return await _web_search(news_query, max_results=max_results)
438
 
439
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  # ─── Registry ─────────────────────────────────────────────
441
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  TOOL_REGISTRY: dict[str, dict] = {
443
  "web_search": {
444
  "name": "web_search",
@@ -561,14 +809,140 @@ TOOL_REGISTRY: dict[str, dict] = {
561
  "fallbacks": [],
562
  "_fn": _browser_session_act,
563
  },
564
- "browser_session_close": {
565
- "name": "browser_session_close",
566
- "goal": "Chiude sessione browser e libera memoria (~300 MB)",
567
- "description": "S403: Chiude sessione Playwright aperta con browser_session_open. Sempre chiamare quando task completato.",
568
- "required_inputs": ["session_id"],
569
- "optional_inputs": {},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  "risk_level": "low",
571
  "fallbacks": [],
572
- "_fn": _browser_session_close,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
573
  },
574
  }
 
34
  )
35
  if r.status_code == 200:
36
  return [
37
+ # S602: snippet 300→500 Brave description[:300] parity con Tavily
38
+ {"title": it["title"], "snippet": (it.get("description") or "")[:500],
39
  "url": it["url"], "source": "Brave"}
40
  for it in r.json().get("web", {}).get("results", [])[:max_results]
41
  if it.get("title") and it.get("url")
 
57
  )
58
  if r.status_code == 200:
59
  return [
60
+ # S600: snippet 300→500 Tavily restituisce snippet più ricchi
61
+ {"title": it["title"], "snippet": (it.get("content") or "")[:500],
62
  "url": it["url"], "source": "Tavily"}
63
  for it in r.json().get("results", [])[:max_results]
64
  if it.get("title") and it.get("url")
 
84
  {
85
  "title": item.get("title", ""),
86
  "url": "https://en.wikipedia.org/wiki/" + item.get("title", "").replace(" ", "_"),
87
+ # S600: snippet 300→500 — Wikipedia snippet può essere più lungo
88
+ "snippet": _html.unescape(_re.sub(r"<[^>]+>", "", item.get("snippet", "")))[:500],
89
  "source": "Wikipedia",
90
  }
91
  for item in wiki_data.get("query", {}).get("search", [])[:max_results]
 
114
  title = _html.unescape(_re.sub(r"<[^>]+>", "", title_html)).strip()
115
  snippet = _html.unescape(_re.sub(r"<[^>]+>", "", snippets[i] if i < len(snippets) else "")).strip()
116
  if title and href.startswith("http"):
117
+ # S600: snippet 300→500 DDG snippet spesso viene troncato a 300
118
+ out.append({"title": title, "url": href, "snippet": snippet[:500], "source": "DDG"})
119
  return out
120
  except Exception:
121
  return []
 
136
  {
137
  "title": item.get("title", ""),
138
  "url": item.get("url", ""),
139
+ # S602: snippet 300→500 — Jina description/content parity con altri provider
140
+ "snippet": (item.get("description") or item.get("content") or "")[:500],
141
  "source": "Jina",
142
  }
143
  for item in data[:max_results]
 
147
  pass
148
  return []
149
 
150
+ async def _hackernews() -> list:
151
+ """S{curr}: HackerNews via Algolia — gratuito, no API key, qualità alta su query tech."""
152
+ try:
153
+ async with httpx.AsyncClient(timeout=8) as c:
154
+ r = await c.get(
155
+ "https://hn.algolia.com/api/v1/search",
156
+ params={"query": query, "hitsPerPage": min(max_results, 4), "tags": "story"},
157
+ )
158
+ if r.status_code == 200:
159
+ return [
160
+ {"title": h.get("title", "")[:300], # S607: 200→300
161
+ "snippet": (h.get("story_text") or "")[:300], # S583: 250→300
162
+ "url": h.get("url") or f"https://news.ycombinator.com/item?id={h.get('objectID','')}",
163
+ "source": "HackerNews"}
164
+ for h in r.json().get("hits", [])
165
+ if h.get("title")
166
+ ]
167
+ except Exception:
168
+ pass
169
+ return []
170
+
171
  # S357: lancio parallelo — worst case = max timeout singolo (10s), non 4×10s=40s
172
  # S378: Jina aggiunto come 5° provider (gratuito, no API key)
173
+ # GAP3-fix: HackerNews aggiunto come provider (tech quality, da web_search.py ora integrato)
174
+ all_lists = await asyncio.gather(_brave(), _tavily(), _wikipedia(), _ddg(), _jina(), _hackernews())
175
 
176
  # Merge con deduplicazione per URL (priorità: Brave > Tavily > Wikipedia > DDG > Jina)
177
  seen_urls: set = set()
 
241
  return {"expression": expression, "error": str(e)}
242
 
243
  async def _run_python(code: str) -> dict:
244
+ """S574: Wrapper su exec_sandbox sandbox sicura unificata.
245
+ Prima: blocklist string-matching bypassabile + nessun env stripping + stdout[:3000]
246
+ Ora: cwd isolato in tmpdir + env stripped + timeout 15s via exec_sandbox.
247
+ Rimuove duplicazione con api/exec_sandbox.py (unica implementazione sandbox).
248
+ """
249
+ from api.exec_sandbox import run_in_sandbox_async # S574: sandbox unificata
250
+ return await run_in_sandbox_async(code, lang="python",
251
+ task_id="tool_run_python", timeout=15.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
 
253
 
254
 
255
  async def _generate_image(prompt: str, width: int = 512, height: int = 512) -> dict:
256
  """
257
+ Genera immagine AI: FLUX.1-schnell via backend /api/vision/generate (HF Space, gratuito),
258
+ fallback Pollinations AI se FLUX non disponibile.
259
  """
260
  import urllib.parse
261
+
262
+ # V001: Prova prima il backend interno FLUX (stesso processo, localhost)
263
+ _base_url = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860")
264
+ _token = os.environ.get("INTERNAL_TOKEN", "")
265
+ try:
266
+ async with httpx.AsyncClient(timeout=30.0) as c:
267
+ _r = await c.post(
268
+ f"{_base_url}/api/vision/generate",
269
+ json={"prompt": prompt.strip()[:600], "width": width, "height": height},
270
+ headers={**({"X-Internal-Token": _token} if _token else {})},
271
+ )
272
+ if _r.status_code == 200:
273
+ _data = _r.json()
274
+ if _data.get("ok") and _data.get("image_url"):
275
+ return {
276
+ "url": _data["image_url"],
277
+ "prompt": prompt,
278
+ "width": width,
279
+ "height": height,
280
+ "ready": True,
281
+ "source": "flux",
282
+ "note": "Immagine generata con FLUX.1-schnell via backend.",
283
+ }
284
+ except Exception:
285
+ pass # FLUX non raggiungibile → Pollinations fallback
286
+
287
+ # Fallback: Pollinations AI (gratuito, nessuna API key)
288
  encoded = urllib.parse.quote(prompt.strip(), safe="")
289
+ seed = sum(ord(c) for c in prompt) % 9999 + 1
290
  url = (
291
  f"https://image.pollinations.ai/prompt/{encoded}"
292
  f"?width={width}&height={height}&seed={seed}&nologo=true&enhance=true"
293
  )
 
 
 
 
 
 
 
294
  return {
295
+ "url": url,
296
  "prompt": prompt,
297
+ "width": width,
298
  "height": height,
299
+ "ready": True,
300
+ "source": "pollinations",
301
+ "note": "Copia l\'URL nel browser o incollalo in un tag <img> per vedere l\'immagine.",
302
  }
303
 
304
  async def _browser_navigate(url: str, wait_ms: int = 2000, mobile: bool = False) -> dict:
 
336
  "inputs": inputs,
337
  }
338
  except Exception as e:
339
+ # S600: 300→500 browser inner exception può contenere stack/path
340
+ return {"url": url, "error": str(e)[:500]}
341
  finally:
342
  await ctx.close()
343
  await browser.close()
344
  except ImportError:
345
  return {"error": "Playwright non disponibile — usa read_page come alternativa"}
346
  except Exception as e:
347
+ # S600: 300→500 — outer exception
348
+ return {"error": str(e)[:500]}
349
 
350
 
351
  async def _browser_session_open(url: str, wait_ms: int = 1500, mobile: bool = False) -> dict:
 
392
  except ImportError:
393
  return {"error": "Playwright non disponibile"}
394
  except Exception as e:
395
+ # S600: 300→500 — registry browser session exception
396
+ return {"error": str(e)[:500]}
397
 
398
 
399
  async def _browser_session_act(session_id: str, actions: list, wait_ms: int = 1000) -> dict:
 
440
  except ImportError:
441
  return {"error": "Playwright non disponibile"}
442
  except Exception as e:
443
+ # S601: 300→500 — parity con altri session handler
444
+ return {"error": str(e)[:500]}
445
 
446
 
447
  async def _browser_session_close(session_id: str) -> dict:
 
453
  await _close_session(session_id, "tool call")
454
  return {"ok": True, "session_id": session_id}
455
  except Exception as e:
456
+ return {"ok": False, "error": str(e)[:300]} # S589: 200→300
457
 
458
 
459
  async def _get_news(query: str, max_results: int = 5) -> dict:
 
466
  return await _web_search(news_query, max_results=max_results)
467
 
468
 
469
+ # ─── S666: tool FS (apply_patch/write_file/read_file/execute_shell) ───────────
470
+ # Questi tool erano in unified_loop._TOOL_MAP (S659) ma NON in TOOL_REGISTRY.
471
+ # agent.py usa TOOL_REGISTRY per lookup → chiamate a questi tool falliscono con
472
+ # KeyError silenzioso. Aggiunte _fn + entries per copertura completa del registry.
473
+
474
+ import pathlib as _pathlib
475
+
476
+ async def _read_file(path: str, encoding: str = "utf-8") -> dict:
477
+ """S666: Legge un file dal filesystem del backend."""
478
+ try:
479
+ _p = _pathlib.Path(path)
480
+ if not _p.exists():
481
+ return {"ok": False, "error": f"File non trovato: {path}"}
482
+ if _p.stat().st_size > 10 * 1024 * 1024: # 10 MB limit
483
+ return {"ok": False, "error": f"File troppo grande (>{10}MB): {path}"}
484
+ text = _p.read_text(encoding=encoding, errors="replace")
485
+ return {"ok": True, "content": text, "path": path, "size": len(text)}
486
+ except PermissionError:
487
+ return {"ok": False, "error": f"Accesso negato: {path}"}
488
+ except Exception as exc:
489
+ return {"ok": False, "error": str(exc)}
490
+
491
+
492
+ async def _write_file(path: str, content: str, encoding: str = "utf-8") -> dict:
493
+ """S666: Scrive/sovrascrive un file nel filesystem del backend."""
494
+ try:
495
+ _p = _pathlib.Path(path)
496
+ _p.parent.mkdir(parents=True, exist_ok=True)
497
+ _p.write_text(content, encoding=encoding)
498
+ return {"ok": True, "path": path, "size": len(content)}
499
+ except PermissionError:
500
+ return {"ok": False, "error": f"Accesso negato: {path}"}
501
+ except Exception as exc:
502
+ return {"ok": False, "error": str(exc)}
503
+
504
+
505
+ async def _apply_patch(path: str, patch: str) -> dict:
506
+ """S666: Applica una patch unified-diff a un file esistente."""
507
+ import io
508
+ try:
509
+ _p = _pathlib.Path(path)
510
+ if not _p.exists():
511
+ return {"ok": False, "error": f"File non trovato: {path}"}
512
+ original = _p.read_text(encoding="utf-8", errors="replace")
513
+
514
+ # Prova con subprocess patch(1) se disponibile, altrimenti Python fallback
515
+ result = await asyncio.to_thread(
516
+ subprocess.run,
517
+ ["patch", "-p0", "--input=-", str(path)],
518
+ input=patch, capture_output=True, text=True, timeout=15,
519
+ )
520
+ if result.returncode == 0:
521
+ return {"ok": True, "path": path, "applied": True, "output": result.stdout.strip()}
522
+
523
+ # Fallback: ricerca/sostituzione del blocco più semplice
524
+ lines = patch.splitlines()
525
+ removes = [l[1:] for l in lines if l.startswith("-") and not l.startswith("---")]
526
+ adds = [l[1:] for l in lines if l.startswith("+") and not l.startswith("+++")]
527
+ if removes and len(removes) == len(adds):
528
+ patched = original
529
+ for rem, add in zip(removes, adds):
530
+ patched = patched.replace(rem, add, 1)
531
+ if patched != original:
532
+ _p.write_text(patched, encoding="utf-8")
533
+ return {"ok": True, "path": path, "applied": True, "method": "fallback_replace"}
534
+ return {"ok": False, "error": result.stderr.strip() or "Patch non applicata", "path": path}
535
+ except FileNotFoundError:
536
+ return {"ok": False, "error": "patch(1) non trovato — solo fallback Python disponibile"}
537
+ except Exception as exc:
538
+ return {"ok": False, "error": str(exc)}
539
+
540
+
541
+ # S679: execute_shell usa run_in_sandbox_async (tempdir + stripped env + timeout)
542
+ async def _execute_shell(command: str, timeout: int = 30, cwd: str = ".") -> dict:
543
+ """S666: Esegue un comando shell con timeout e cattura output."""
544
+ try:
545
+ result = await asyncio.to_thread(
546
+ subprocess.run,
547
+ command, shell=True, capture_output=True, text=True,
548
+ timeout=min(timeout, 120), cwd=cwd,
549
+ )
550
+ return {
551
+ "ok": result.returncode == 0,
552
+ "stdout": result.stdout[:8192],
553
+ "stderr": result.stderr[:2048],
554
+ "code": result.returncode,
555
+ "command": command,
556
+ }
557
+ except subprocess.TimeoutExpired:
558
+ return {"ok": False, "error": f"Timeout {timeout}s superato", "command": command}
559
+ except Exception as exc:
560
+ return {"ok": False, "error": str(exc), "command": command}
561
+
562
+
563
  # ─── Registry ─────────────────────────────────────────────
564
 
565
+
566
+
567
+ # ── V002-V004: nuovi tool backend (web_research, send_email, database_query) ──
568
+
569
+ async def _web_research(topic: str, depth: int = 4, synthesize: bool = True) -> dict:
570
+ """Ricerca web multi-fonte con sintesi AI via /api/web/research."""
571
+ _base = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860")
572
+ _tok = os.environ.get("INTERNAL_TOKEN", "")
573
+ try:
574
+ async with httpx.AsyncClient(timeout=60.0) as c:
575
+ r = await c.post(
576
+ f"{_base}/api/web/research",
577
+ json={"topic": str(topic)[:400], "depth": min(int(depth), 8), "synthesize": synthesize},
578
+ headers={**({"X-Internal-Token": _tok} if _tok else {})},
579
+ )
580
+ if r.status_code == 200:
581
+ return r.json()
582
+ except Exception as exc:
583
+ return {"ok": False, "error": str(exc)[:200]}
584
+ return {"ok": False, "error": "web_research endpoint non disponibile"}
585
+
586
+
587
+ async def _send_email(to: str, subject: str, body: str, html: bool = False, from_name: str = "Agente AI") -> dict:
588
+ """Invia email via /api/email/send (richiede RESEND_API_KEY)."""
589
+ _base = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860")
590
+ _tok = os.environ.get("INTERNAL_TOKEN", "")
591
+ try:
592
+ async with httpx.AsyncClient(timeout=20.0) as c:
593
+ r = await c.post(
594
+ f"{_base}/api/email/send",
595
+ json={"to": to, "subject": subject, "body": body, "html": html, "from_name": from_name},
596
+ headers={**({"X-Internal-Token": _tok} if _tok else {})},
597
+ )
598
+ if r.status_code == 200:
599
+ return r.json()
600
+ except Exception as exc:
601
+ return {"ok": False, "message": str(exc)[:200]}
602
+ return {"ok": False, "message": "send_email endpoint non disponibile"}
603
+
604
+
605
+ async def _database_query(sql: str, params: list | None = None, read_only: bool = True) -> dict:
606
+ """Esegue query SQL su DATABASE_URL configurato via /api/database/query."""
607
+ _base = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860")
608
+ _tok = os.environ.get("INTERNAL_TOKEN", "")
609
+ try:
610
+ async with httpx.AsyncClient(timeout=30.0) as c:
611
+ r = await c.post(
612
+ f"{_base}/api/database/query",
613
+ json={"sql": str(sql)[:2000], "params": params or [], "read_only": read_only},
614
+ headers={**({"X-Internal-Token": _tok} if _tok else {})},
615
+ )
616
+ if r.status_code == 200:
617
+ return r.json()
618
+ except Exception as exc:
619
+ return {"ok": False, "error": str(exc)[:200]}
620
+ return {"ok": False, "error": "database_query endpoint non disponibile"}
621
+
622
+
623
+
624
+ async def _call_api(url: str, method: str = "GET", headers: dict | None = None,
625
+ body=None, auth_type: str = "none", auth_value: str = "",
626
+ timeout_ms: int = 15000) -> dict:
627
+ """S601: Chiama qualsiasi endpoint REST esterno con metodo/headers/body/auth configurabili."""
628
+ import json as _j
629
+ _method = method.upper()
630
+ _headers = dict(headers or {})
631
+ _timeout = min(float(timeout_ms) / 1000, 30.0)
632
+ # auth injection
633
+ if auth_type == "bearer" and auth_value:
634
+ _headers["Authorization"] = f"Bearer {auth_value}"
635
+ elif auth_type == "basic" and auth_value:
636
+ import base64 as _b64
637
+ _headers["Authorization"] = "Basic " + _b64.b64encode(auth_value.encode()).decode()
638
+ elif auth_type == "api_key" and auth_value and ":" in auth_value:
639
+ k, v = auth_value.split(":", 1)
640
+ _headers[k.strip()] = v.strip()
641
+ try:
642
+ async with httpx.AsyncClient(timeout=_timeout, follow_redirects=True) as c:
643
+ kwargs = {"headers": _headers}
644
+ if body is not None:
645
+ if isinstance(body, (dict, list)):
646
+ kwargs["json"] = body
647
+ else:
648
+ kwargs["content"] = str(body).encode()
649
+ _headers.setdefault("Content-Type", "text/plain")
650
+ r = await c.request(_method, url, **kwargs)
651
+ _ct = r.headers.get("content-type", "")
652
+ if "json" in _ct:
653
+ try:
654
+ resp_body = r.json()
655
+ except Exception:
656
+ resp_body = r.text[:4000]
657
+ else:
658
+ resp_body = r.text[:4000]
659
+ return {
660
+ "ok": True, "status": r.status_code,
661
+ "body": resp_body, "headers": dict(r.headers),
662
+ }
663
+ except Exception as exc:
664
+ return {"ok": False, "status": 0, "error": str(exc)[:200]}
665
+
666
+
667
+ async def _execute_sql(sql: str, params: list | None = None, read_only: bool = True) -> dict:
668
+ """S601: alias di _database_query — esegue SQL su DATABASE_URL (PostgreSQL/SQLite)."""
669
+ return await _database_query(sql=sql, params=params, read_only=read_only)
670
+
671
+
672
+ async def _create_pdf(content: str, filename: str = "documento.pdf",
673
+ format: str = "html") -> dict:
674
+ """S601: Genera PDF da HTML/Markdown/testo via backend /api/vision/pdf."""
675
+ _base = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860")
676
+ _tok = os.environ.get("INTERNAL_TOKEN", "")
677
+ try:
678
+ async with httpx.AsyncClient(timeout=30.0) as c:
679
+ r = await c.post(
680
+ f"{_base}/api/vision/pdf",
681
+ json={"content": str(content)[:50000], "filename": filename, "format": format},
682
+ headers={**({"X-Internal-Token": _tok} if _tok else {})},
683
+ )
684
+ if r.status_code == 200:
685
+ return r.json()
686
+ return {"ok": False, "error": f"HTTP {r.status_code}", "detail": r.text[:200]}
687
+ except Exception as exc:
688
+ return {"ok": False, "error": str(exc)[:200]}
689
+
690
  TOOL_REGISTRY: dict[str, dict] = {
691
  "web_search": {
692
  "name": "web_search",
 
809
  "fallbacks": [],
810
  "_fn": _browser_session_act,
811
  },
812
+
813
+ "web_research": {
814
+ "name": "web_research",
815
+ "goal": "Ricerca approfondita multi-fonte su un argomento con sintesi AI",
816
+ "description": (
817
+ "Cerca N fonti web su un topic, ne estrae il contenuto rilevante e produce "
818
+ "una sintesi AI (Groq). Più approfondito di web_search + read_page. "
819
+ "Usa per ricerche che richiedono confronto tra più fonti."
820
+ ),
821
+ "required_inputs": ["topic"],
822
+ "optional_inputs": {"depth": 4, "synthesize": True},
823
+ "risk_level": "low",
824
+ "fallbacks": ["web_search"],
825
+ "_fn": _web_research,
826
+ },
827
+ "send_email": {
828
+ "name": "send_email",
829
+ "goal": "Invia email transazionale via Resend API",
830
+ "description": (
831
+ "Invia email a un destinatario via Resend. "
832
+ "Richiede RESEND_API_KEY nell'ambiente HF Space. "
833
+ "Supporta testo plain e HTML."
834
+ ),
835
+ "required_inputs": ["to", "subject", "body"],
836
+ "optional_inputs": {"html": False, "from_name": "Agente AI"},
837
+ "risk_level": "medium",
838
+ "fallbacks": [],
839
+ "_fn": _send_email,
840
+ },
841
+ "database_query": {
842
+ "name": "database_query",
843
+ "goal": "Esegui query SQL su database configurato (PostgreSQL/SQLite)",
844
+ "description": (
845
+ "Esegue query SQL su DATABASE_URL configurato nell'ambiente. "
846
+ "Default read_only=True (solo SELECT). "
847
+ "Richiede DATABASE_URL env var (postgresql:// o sqlite:///path)."
848
+ ),
849
+ "required_inputs": ["sql"],
850
+ "optional_inputs": {"params": [], "read_only": True},
851
+ "risk_level": "medium",
852
+ "fallbacks": [],
853
+ "_fn": _database_query,
854
+ },
855
+
856
+ "call_api": {
857
+ "name": "call_api",
858
+ "goal": "Chiama qualsiasi API REST esterna (GET/POST/PUT/PATCH/DELETE)",
859
+ "description": (
860
+ "S601: Chiama endpoint REST con metodo/headers/body/autenticazione configurabili. "
861
+ "Supporta auth bearer, basic, api_key. Restituisce status, headers, body JSON/testo. "
862
+ "Usa per integrare webhook, API pubbliche, testare endpoint o inviare dati."
863
+ ),
864
+ "required_inputs": ["url"],
865
+ "optional_inputs": {"method": "GET", "headers": {}, "body": None,
866
+ "auth_type": "none", "auth_value": "", "timeout_ms": 15000},
867
+ "risk_level": "medium",
868
+ "fallbacks": ["web_search"],
869
+ "_fn": _call_api,
870
+ },
871
+ "execute_sql": {
872
+ "name": "execute_sql",
873
+ "goal": "Esegui query SQL su database (alias di database_query)",
874
+ "description": (
875
+ "S601: Esegue SQL su DATABASE_URL configurato (PostgreSQL/SQLite). "
876
+ "Default read_only=True — solo SELECT. Alias semantico di database_query."
877
+ ),
878
+ "required_inputs": ["sql"],
879
+ "optional_inputs": {"params": [], "read_only": True},
880
+ "risk_level": "medium",
881
+ "fallbacks": ["database_query"],
882
+ "_fn": _execute_sql,
883
+ },
884
+ "create_pdf": {
885
+ "name": "create_pdf",
886
+ "goal": "Genera un PDF da HTML, Markdown o testo",
887
+ "description": (
888
+ "S601: Crea PDF da contenuto HTML/Markdown/testo via backend. "
889
+ "Restituisce URL o base64 del PDF generato. "
890
+ "Usa per report, documenti, contratti, presentazioni."
891
+ ),
892
+ "required_inputs": ["content"],
893
+ "optional_inputs": {"filename": "documento.pdf", "format": "html"},
894
+ "risk_level": "low",
895
+ "fallbacks": [],
896
+ "_fn": _create_pdf,
897
+ },
898
+ # S666: tool file-system — assenti in TOOL_REGISTRY ma presenti in unified_loop._TOOL_MAP.
899
+ # Senza entries qui, agent.py riceve KeyError su lookup → tool ignorato silenziosamente.
900
+ "read_file": {
901
+ "name": "read_file",
902
+ "goal": "Legge il contenuto di un file dal filesystem del backend",
903
+ "description": "Legge un file locale del backend. Limit 10MB. Restituisce content+size.",
904
+ "required_inputs": ["path"],
905
+ "optional_inputs": {"encoding": "utf-8"},
906
  "risk_level": "low",
907
  "fallbacks": [],
908
+ "_fn": _read_file,
909
+ },
910
+ "write_file": {
911
+ "name": "write_file",
912
+ "goal": "Scrive o sovrascrive un file nel filesystem del backend",
913
+ "description": "Scrive testo in un file locale. Crea directory intermedie automaticamente.",
914
+ "required_inputs": ["path", "content"],
915
+ "optional_inputs": {"encoding": "utf-8"},
916
+ "risk_level": "medium",
917
+ "fallbacks": [],
918
+ "_fn": _write_file,
919
+ },
920
+ "apply_patch": {
921
+ "name": "apply_patch",
922
+ "goal": "Applica una patch unified-diff a un file esistente",
923
+ "description": (
924
+ "Applica patch con subprocess patch(1) con fallback Python replace-based. "
925
+ "Input: path (file da patchare) + patch (testo unified-diff). "
926
+ "Risk medium: modifica file in modo difficilmente reversibile."
927
+ ),
928
+ "required_inputs": ["path", "patch"],
929
+ "optional_inputs": {},
930
+ "risk_level": "medium",
931
+ "fallbacks": ["write_file"],
932
+ "_fn": _apply_patch,
933
+ },
934
+ "execute_shell": {
935
+ "name": "execute_shell",
936
+ "goal": "Esegue un comando shell sul server backend",
937
+ "description": (
938
+ "Esegue comando arbitrario con subprocess. "
939
+ "Timeout max 120s. Restituisce stdout/stderr/code. "
940
+ "Risk HIGH: esecuzione arbitraria sul server."
941
+ ),
942
+ "required_inputs": ["command"],
943
+ "optional_inputs": {"timeout": 30, "cwd": "."},
944
+ "risk_level": "high",
945
+ "fallbacks": [],
946
+ "_fn": _execute_shell,
947
  },
948
  }
tools/web_fetch.py CHANGED
@@ -1,29 +1,221 @@
1
- """web_fetch.py — Scarica + pulisce una pagina web. NON restituisce HTML grezzo."""
2
- import httpx, re
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from urllib.parse import urlparse
4
- USER_AGENT="Mozilla/5.0 (compatible; AgenteAI/2.0)"
5
- MAX_CONTENT=6000
6
-
7
- def _clean_html(html:str)->str:
8
- html=re.sub(r"<(script|style|nav|footer|header|noscript|aside)[^>]*>.*?</\1>"," ",html,flags=re.S|re.I)
9
- text=re.sub(r"<[^>]+>"," ",html)
10
- text=re.sub(r"[ \t]+"," ",text)
11
- text=re.sub(r"\n{3,}","\n\n",text)
12
- return text.strip()
13
-
14
- def _extract_meta(html:str)->dict:
15
- t=re.search(r"<title[^>]*>([^<]+)</title>",html,re.I)
16
- d=re.search(r'<meta[^>]+name=["\'\']description["\'\'][^>]+content=["\'\']([^"\'\']+ )["\'\']',html,re.I)
17
- return {"title":t.group(1).strip() if t else "","description":d.group(1).strip() if d else ""}
18
-
19
- async def fetch_page(url:str, max_chars:int=MAX_CONTENT)->dict:
20
- try:
21
- parsed=urlparse(url)
22
- if parsed.scheme not in ("http","https"): return {"url":url,"error":"Schema non supportato","content":""}
23
- async with httpx.AsyncClient(timeout=15,follow_redirects=True,headers={"User-Agent":USER_AGENT}) as c:
24
- r=await c.get(url)
25
- html=r.text; meta=_extract_meta(html); text=_clean_html(html)
26
- if len(text)>max_chars: text=text[:max_chars]+"\n[...troncato...]"
27
- return {"url":url,"title":meta["title"],"description":meta["description"],"content":text,"status":r.status_code,"chars":len(text)}
28
- except httpx.TimeoutException: return {"url":url,"error":"Timeout","content":""}
29
- except Exception as e: return {"url":url,"error":str(e),"content":""}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ web_fetch.py Scarica + pulisce una pagina web. NON restituisce HTML grezzo.
3
+
4
+ W-NAV: pipeline aggiornata con 3 livelli:
5
+ 1. httpx (statico) + trafilatura come primary parser
6
+ 2. Se content < 300 chars dopo pulizia → Playwright fallback (SPA/React/Vue/Next.js)
7
+ 3. Se Playwright non disponibile → ritorna il poco testo estratto con warning
8
+
9
+ Problematiche anticipate:
10
+ - SPA JS-heavy: httpx riceve HTML quasi vuoto (~50 chars). Soglia 300 chars
11
+ copre questi casi senza attivare Playwright su pagine statiche normali.
12
+ - Playwright in web_fetch.py è indipendente da browser.py (zero import circolari).
13
+ Crea una sessione Playwright usa-e-getta, non persiste in _sessions.
14
+ - OOM guard: _fetch_with_playwright usa asyncio.Lock per serializzare i launch
15
+ e ha un timeout aggressivo (12s) per non occupare memoria a lungo.
16
+ - trafilatura fallisce silenziosamente su pagine login/404/SPA vuote →
17
+ _clean_html regex come secondo fallback.
18
+ - TIMEOUT_PLAYWRIGHT < GOTO_TIMEOUT di browser.py (12s vs 15s) — intenzionale:
19
+ web_fetch è un tool "leggero", browser.py è il tool "pesante" per sessioni
20
+ interattive.
21
+ """
22
+ import asyncio
23
+ import re
24
+ import httpx
25
  from urllib.parse import urlparse
26
+ from typing import Optional
27
+
28
+ USER_AGENT = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
29
+ MAX_CONTENT = 6000 # W-NAV: alzato da 5000 → 6000 (allineato a MAX_TEXT browser.py)
30
+ _PLAYWRIGHT_THRESHOLD = 300 # chars: se content < soglia → prova Playwright
31
+ _TIMEOUT_PLAYWRIGHT = 12_000 # ms: timeout Playwright in web_fetch (breve, usa-e-getta)
32
+
33
+ # Lock globale: un solo Playwright instance alla volta in web_fetch
34
+ # (browser.py ha i suoi; vogliamo max 3 Chromium totali: 2 browser.py + 1 web_fetch)
35
+ _playwright_lock = asyncio.Lock()
36
+
37
+
38
+ # ─── HTML cleaners ────────────────────────────────────────────────────────────
39
+
40
+ def _clean_html(html: str) -> str:
41
+ """Regex-based cleaner fallback quando trafilatura non è disponibile."""
42
+ html = re.sub(
43
+ r"<(script|style|nav|footer|header|noscript|aside)[^>]*>.*?</\1>",
44
+ " ", html, flags=re.S | re.I,
45
+ )
46
+ text = re.sub(r"<[^>]+>", " ", html)
47
+ text = re.sub(r"[ \t]+", " ", text)
48
+ text = re.sub(r"\n{3,}", "\n\n", text)
49
+ return text.strip()
50
+
51
+
52
+ def _extract_meta(html: str) -> dict:
53
+ t = re.search(r"<title[^>]*>([^<]+)</title>", html, re.I)
54
+ d = re.search(
55
+ r'<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']+)["\']',
56
+ html, re.I,
57
+ )
58
+ return {
59
+ "title": t.group(1).strip() if t else "",
60
+ "description": d.group(1).strip() if d else "",
61
+ }
62
+
63
+
64
+ def _parse_content(html: str, url: str = "", max_chars: int = MAX_CONTENT) -> str:
65
+ """
66
+ Parser livello 1: prova trafilatura, fallback a _clean_html.
67
+ Separato da fetch_page per essere usabile anche da _fetch_with_playwright.
68
+ """
69
+ try:
70
+ import trafilatura # type: ignore[import-untyped]
71
+ extracted = trafilatura.extract(
72
+ html,
73
+ url=url or None,
74
+ include_comments=False,
75
+ include_tables=True,
76
+ include_images=False,
77
+ deduplicate=True,
78
+ favor_recall=True,
79
+ )
80
+ if extracted and len(extracted.strip()) > 200:
81
+ if len(extracted) > max_chars:
82
+ extracted = extracted[:max_chars] + "\n[troncato]"
83
+ return extracted
84
+ except Exception:
85
+ pass
86
+ # Fallback regex
87
+ text = _clean_html(html)
88
+ if len(text) > max_chars:
89
+ text = text[:max_chars] + "\n[...troncato...]"
90
+ return text
91
+
92
+
93
+ # ─── Playwright fallback (usa-e-getta, nessun import da browser.py) ───────────
94
+
95
+ async def _fetch_with_playwright(url: str, max_chars: int = MAX_CONTENT) -> Optional[str]:
96
+ """
97
+ Fetch Playwright usa-e-getta per SPA/React/Vue/Next.js.
98
+ W-NAV1: attivato da fetch_page() quando content < _PLAYWRIGHT_THRESHOLD.
99
+
100
+ Design:
101
+ - asyncio.Lock globale: serializza i launch (max 1 alla volta da web_fetch)
102
+ - Timeout aggressivo: _TIMEOUT_PLAYWRIGHT = 12s (usa-e-getta ≠ sessione persistente)
103
+ - networkidle: aspetta 500ms senza richieste di rete (copre SPA con lazy loading)
104
+ fallback a domcontentloaded se timeout
105
+ - trafilatura: estrae il mainbody dall'HTML grezzo di Playwright
106
+ - Sempre close() nel finally: zero memory leak
107
+ - Restituisce None se playwright non installato o fallisce (caller gestisce)
108
+ """
109
+ try:
110
+ import playwright # noqa: F401 — verifica disponibilità prima del lock
111
+ except ImportError:
112
+ return None # playwright non installato — caller usa il poco testo statico
113
+
114
+ async with _playwright_lock:
115
+ try:
116
+ from playwright.async_api import async_playwright
117
+ async with async_playwright() as pw:
118
+ browser = await pw.chromium.launch(
119
+ headless=True,
120
+ args=[
121
+ "--no-sandbox", "--disable-setuid-sandbox",
122
+ "--disable-dev-shm-usage", "--disable-gpu",
123
+ "--single-process", "--no-zygote",
124
+ "--disable-extensions", "--mute-audio",
125
+ "--disable-background-networking",
126
+ ],
127
+ )
128
+ ctx = await browser.new_context(
129
+ viewport={"width": 1280, "height": 800},
130
+ user_agent=USER_AGENT,
131
+ )
132
+ page = await ctx.new_page()
133
+ try:
134
+ # networkidle per SPA — fallback su timeout (polling infinito)
135
+ try:
136
+ await page.goto(url, wait_until="networkidle", timeout=_TIMEOUT_PLAYWRIGHT)
137
+ except Exception:
138
+ try:
139
+ await page.wait_for_load_state("domcontentloaded", timeout=3000)
140
+ except Exception:
141
+ pass
142
+
143
+ # Breve attesa per rendering JS post-load
144
+ await page.wait_for_timeout(500)
145
+
146
+ # Estrai HTML e parsa con trafilatura
147
+ html = await page.content()
148
+ text = _parse_content(html, url=url, max_chars=max_chars)
149
+ return text if text and len(text.strip()) > 100 else None
150
+
151
+ finally:
152
+ await ctx.close()
153
+ await browser.close()
154
+
155
+ except Exception:
156
+ return None
157
+
158
+
159
+ # ─── Endpoint principale ──────────────────────────────────────────────────────
160
+
161
+ async def fetch_page(url: str, max_chars: int = MAX_CONTENT) -> dict:
162
+ """
163
+ Fetch + estrazione testo con pipeline a 3 livelli:
164
+ L1: httpx statico + trafilatura (veloce, nessun overhead)
165
+ L2: Playwright usa-e-getta se content < 300 chars (SPA/React/Vue)
166
+ L3: warning nel result se entrambi falliscono (mai ritornare stringa vuota)
167
+
168
+ Invariante W-NAV1: mai ritornare content vuoto senza aver tentato il browser.
169
+ """
170
+ try:
171
+ parsed = urlparse(url)
172
+ if parsed.scheme not in ("http", "https"):
173
+ return {"url": url, "error": "Schema non supportato", "content": ""}
174
+
175
+ async with httpx.AsyncClient(
176
+ timeout=15,
177
+ follow_redirects=True,
178
+ headers={"User-Agent": USER_AGENT},
179
+ ) as c:
180
+ r = await c.get(url)
181
+ html = r.text
182
+ meta = _extract_meta(html)
183
+
184
+ # L1: trafilatura / regex parse
185
+ text = _parse_content(html, url=url, max_chars=max_chars)
186
+ used_playwright = False
187
+
188
+ # L2: Playwright fallback per SPA/JS-heavy (W-NAV1)
189
+ if len(text.strip()) < _PLAYWRIGHT_THRESHOLD:
190
+ pw_text = await _fetch_with_playwright(url, max_chars=max_chars)
191
+ if pw_text and len(pw_text.strip()) >= _PLAYWRIGHT_THRESHOLD:
192
+ text = pw_text
193
+ used_playwright = True
194
+
195
+ # L3: se ancora poco testo, aggiungi warning nel result
196
+ # (mai ritornare errore — il poco testo è comunque utile)
197
+ warning = None
198
+ if len(text.strip()) < _PLAYWRIGHT_THRESHOLD:
199
+ warning = (
200
+ f"Contenuto scarso ({len(text.strip())} chars) — "
201
+ "pagina potrebbe richiedere autenticazione o JavaScript avanzato."
202
+ )
203
+
204
+ result: dict = {
205
+ "url": url,
206
+ "title": meta["title"],
207
+ "description": meta["description"],
208
+ "content": text,
209
+ "status": r.status_code,
210
+ "chars": len(text),
211
+ }
212
+ if used_playwright:
213
+ result["extractor"] = "playwright"
214
+ if warning:
215
+ result["warning"] = warning
216
+ return result
217
+
218
+ except httpx.TimeoutException:
219
+ return {"url": url, "error": "Timeout", "content": ""}
220
+ except Exception as e:
221
+ return {"url": url, "error": str(e), "content": ""}
tools/web_search.py CHANGED
@@ -1,58 +1,148 @@
1
- """web_search.py — Pipeline: search → rank → cite"""
2
- import httpx, re, asyncio
3
- from urllib.parse import quote_plus
4
-
5
- MAX_RESULTS = 8
6
-
7
- async def search_ddg(query: str, max_results: int = MAX_RESULTS) -> list[dict]:
8
- try:
9
- async with httpx.AsyncClient(timeout=8, follow_redirects=True) as c:
10
- r = await c.get("https://api.duckduckgo.com/",
11
- params={"q": query, "format": "json", "no_html": "1", "skip_disambig": "1"})
12
- d = r.json(); results = []
13
- if d.get("AbstractText"):
14
- results.append({"source":"DDG","title":d.get("Heading",query),"snippet":d["AbstractText"][:400],"url":d.get("AbstractURL",""),"score":1.0})
15
- for t in d.get("RelatedTopics",[])[:max_results]:
16
- if isinstance(t,dict) and t.get("Text") and t.get("FirstURL"):
17
- results.append({"source":"DDG","title":t["Text"][:80],"snippet":t["Text"][:250],"url":t["FirstURL"],"score":0.7})
18
- return results[:max_results]
19
- except Exception as e:
20
- return [{"source":"DDG","error":str(e),"title":"","snippet":"","url":"","score":0}]
21
-
22
- async def search_hackernews(query: str, max_results: int = 5) -> list[dict]:
23
- try:
24
- async with httpx.AsyncClient(timeout=8) as c:
25
- r = await c.get("https://hn.algolia.com/api/v1/search",
26
- params={"query":query,"hitsPerPage":max_results,"tags":"story"})
27
- return [{"source":"HackerNews","title":h.get("title","")[:100],
28
- "snippet":(h.get("story_text") or "")[:300],
29
- "url":h.get("url") or f"https://news.ycombinator.com/item?id={h.get('objectID','')}",
30
- "score":min(1.0,(h.get("points",0) or 0)/500)} for h in r.json().get("hits",[]) if h.get("title")]
31
- except: return []
32
-
33
- async def search_stackoverflow(query: str, max_results: int = 4) -> list[dict]:
34
- import re as _re
35
- try:
36
- async with httpx.AsyncClient(timeout=8) as c:
37
- r = await c.get("https://api.stackexchange.com/2.3/search/advanced",
38
- params={"q":query,"site":"stackoverflow","pagesize":max_results,"order":"desc","sort":"relevance","filter":"withbody"})
39
- return [{"source":"StackOverflow","title":it.get("title","")[:100],
40
- "snippet":_re.sub(r"<[^>]+>"," ",it.get("body",""))[:250],
41
- "url":it.get("link",""),"score":min(1.0,(it.get("score",0) or 0)/100),
42
- "answered":it.get("is_answered",False)} for it in r.json().get("items",[])]
43
- except: return []
44
-
45
- async def web_search(query:str, focus:str="general", max_results:int=6) -> dict:
46
- tasks = [search_ddg(query, max_results)]
47
- if focus in ("technical","code"): tasks.append(search_stackoverflow(query,4))
48
- if focus in ("news","general","technical"): tasks.append(search_hackernews(query,4))
49
- all_results=[]
50
- for batch in await asyncio.gather(*tasks, return_exceptions=True):
51
- if isinstance(batch,list): all_results.extend(batch)
52
- seen,ranked=set(),[]
53
- for r in sorted(all_results,key=lambda x:x.get("score",0),reverse=True):
54
- url=r.get("url","")
55
- if url and url not in seen: seen.add(url); ranked.append(r)
56
- elif not url: ranked.append(r)
57
- ranked=ranked[:max_results]
58
- return {"query":query,"focus":focus,"total":len(ranked),"results":ranked,"sources":list({r["source"] for r in ranked})}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ web_search.py Web search pipeline (allineato con registry.py - S434)
3
+ Usa gli stessi provider di registry._web_search: Brave → Tavily → Wikipedia → HN
4
+ con chiamate parallele. Mantiene la firma pubblica originale per compatibilità con api/web.py.
5
+ """
6
+ import httpx
7
+ import asyncio
8
+ import os
9
+
10
+
11
+ async def web_search(query: str, focus: str = "general", max_results: int = 6) -> dict:
12
+ """
13
+ Pipeline parallela: Brave → Tavily → Wikipedia → HackerNews.
14
+ Merge con deduplicazione per URL, priorità per source.
15
+ Allineata con registry._web_search — zero DDG scraping.
16
+ """
17
+ import re as _re, urllib.parse as _urlparse, urllib.request as _urlreq
18
+
19
+ _headers = {"User-Agent": "Mozilla/5.0 (compatible; AgentBot/1.0)"}
20
+ brave_key = os.environ.get("BRAVE_SEARCH_API_KEY", "")
21
+ tavily_key = os.environ.get("TAVILY_API_KEY", "")
22
+
23
+ async def _brave() -> list:
24
+ if not brave_key:
25
+ return []
26
+ try:
27
+ async with httpx.AsyncClient(timeout=10) as c:
28
+ r = await c.get(
29
+ "https://api.search.brave.com/res/v1/web/search",
30
+ params={"q": query, "count": max_results, "text_decorations": "0"},
31
+ headers={**_headers, "Accept": "application/json",
32
+ "X-Subscription-Token": brave_key},
33
+ )
34
+ if r.status_code == 200:
35
+ return [
36
+ {"title": it["title"],
37
+ # S602: snippet 300→500 — Brave web_search parity con Tavily/Wikipedia
38
+ "snippet": (it.get("description") or "")[:500],
39
+ "url": it["url"], "source": "Brave", "score": 1.0}
40
+ for it in r.json().get("web", {}).get("results", [])[:max_results]
41
+ if it.get("title") and it.get("url")
42
+ ]
43
+ except Exception:
44
+ pass
45
+ return []
46
+
47
+ async def _tavily() -> list:
48
+ if not tavily_key:
49
+ return []
50
+ try:
51
+ async with httpx.AsyncClient(timeout=10) as c:
52
+ r = await c.post(
53
+ "https://api.tavily.com/search",
54
+ json={"api_key": tavily_key, "query": query,
55
+ "max_results": max_results, "include_answer": False},
56
+ headers={**_headers, "Content-Type": "application/json"},
57
+ )
58
+ if r.status_code == 200:
59
+ return [
60
+ {"title": it["title"],
61
+ # S601: snippet 300→500 — Tavily web_search parity con registry.py
62
+ "snippet": (it.get("content") or "")[:500],
63
+ "url": it["url"], "source": "Tavily", "score": 0.9}
64
+ for it in r.json().get("results", [])[:max_results]
65
+ if it.get("title") and it.get("url")
66
+ ]
67
+ except Exception:
68
+ pass
69
+ return []
70
+
71
+ async def _wikipedia() -> list:
72
+ try:
73
+ wiki_qs = _urlparse.urlencode({
74
+ "action": "query", "list": "search", "srsearch": query,
75
+ "format": "json", "utf8": "1",
76
+ "srlimit": min(max_results, 4), "srnamespace": "0",
77
+ })
78
+ wiki_req = _urlreq.Request(
79
+ f"https://en.wikipedia.org/w/api.php?{wiki_qs}",
80
+ headers={"User-Agent": "agente-ai/3.2"},
81
+ )
82
+ wiki_data = await asyncio.to_thread(
83
+ lambda: __import__("json").loads(_urlreq.urlopen(wiki_req, timeout=7).read())
84
+ )
85
+ return [
86
+ {"title": it["title"],
87
+ # S601: snippet 300→500 — Wikipedia web_search parity con registry.py
88
+ "snippet": _re.sub(r"<[^>]+>", "", it.get("snippet", ""))[:500],
89
+ "url": f"https://en.wikipedia.org/wiki/{_urlparse.quote(it['title'].replace(' ', '_'))}",
90
+ "source": "Wikipedia", "score": 0.75}
91
+ for it in wiki_data.get("query", {}).get("search", [])[:3]
92
+ if it.get("title")
93
+ ]
94
+ except Exception:
95
+ return []
96
+
97
+ async def _hackernews() -> list:
98
+ if focus not in ("news", "general", "technical"):
99
+ return []
100
+ try:
101
+ async with httpx.AsyncClient(timeout=8) as c:
102
+ r = await c.get(
103
+ "https://hn.algolia.com/api/v1/search",
104
+ params={"query": query, "hitsPerPage": 4, "tags": "story"},
105
+ )
106
+ return [
107
+ {"title": h.get("title", "")[:300], # S607: 200→300
108
+ "snippet": (h.get("story_text") or "")[:300], # S583: 250→300
109
+ "url": h.get("url") or f"https://news.ycombinator.com/item?id={h.get('objectID','')}",
110
+ "source": "HackerNews",
111
+ "score": min(1.0, (h.get("points", 0) or 0) / 500)}
112
+ for h in r.json().get("hits", [])
113
+ if h.get("title")
114
+ ]
115
+ except Exception:
116
+ return []
117
+
118
+ tasks = [_brave(), _tavily(), _wikipedia()]
119
+ if focus in ("technical", "code"):
120
+ # Stack Overflow via HN for technical focus
121
+ tasks.append(_hackernews())
122
+ elif focus in ("news", "general"):
123
+ tasks.append(_hackernews())
124
+
125
+ all_results: list = []
126
+ for batch in await asyncio.gather(*tasks, return_exceptions=True):
127
+ if isinstance(batch, list):
128
+ all_results.extend(batch)
129
+
130
+ # Deduplica per URL, mantieni priorità source
131
+ seen: set = set()
132
+ ranked: list = []
133
+ for r in sorted(all_results, key=lambda x: x.get("score", 0), reverse=True):
134
+ url = r.get("url", "")
135
+ if url and url not in seen:
136
+ seen.add(url)
137
+ ranked.append(r)
138
+ elif not url:
139
+ ranked.append(r)
140
+
141
+ ranked = ranked[:max_results]
142
+ return {
143
+ "query": query,
144
+ "focus": focus,
145
+ "total": len(ranked),
146
+ "results": ranked,
147
+ "sources": list({r["source"] for r in ranked}),
148
+ }